diff --git a/.gitignore b/.gitignore
index 06812952..ac57c53e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,6 +19,3 @@ __pycache__/
# Old B2G registry is unused by the modern host and contains historical paths.
/assets/webapps/webapps.json
-
-# Local issue tracking and roadmap; not included in publication.
-/tickets/
diff --git a/CHANGELOG.en.md b/CHANGELOG.en.md
deleted file mode 100644
index 5a7cf331..00000000
--- a/CHANGELOG.en.md
+++ /dev/null
@@ -1,904 +0,0 @@
-# Changelog — Vulpes OS
-
-[Français](CHANGELOG.md)
-
-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
-
-
-
-### 
-
--   : Unavailable-feature messages are easier to read in light and dark themes.
--   : Contact names now appear when the call history first loads.
--   : Additional dictionaries available offline; improved Gaia keyboard activation and recovery. Verification on affected devices is still needed.
--   : Storage panels restored. A loading error no longer locks the other menus.
-
-### 
-
--   : Read Android contacts, SMS and call history in Gaia after granting access. Existing local Vulpes data is preserved.
--   : Return to Vulpes without a persistent white overlay; a closed catalogue can be reopened.
--   : The reported blank keyboard still needs reproduction on the affected devices.
--   : First, read-only integration, excluding MMS. Sending and editing Android records use Android apps; new Gaia contacts stay local. SMS and call log access depend on ROM and installer permissions. Tested on Android 9; other versions need verification.
-
-### 
-
--   : Shared Gaia fixes are included in full installation images and boot updates.
--   : New installation image built; Pixel boot and flashing must be tested before publication.
-
-
-
-## 13/10/2026 : 2.7-preview.20
-
-
-
-### 
-
--   : Video mode, with thumbnails and playback in Gallery and Video.
--   : Improvements to settings, appearance, pickers, keyboard, contacts and media.
--   : More reliable camera switching, shutdown and resume.
--   : Mozilla Account, Sync, remote location, extensions and USB transfer remain incomplete.
-
-### 
-
--   : Preview 20 APK available.
--   : Calls and SMS use Android. This Preview has not yet been validated in use on Android.
-
-### 
-
--   : Preview runs at around 16 frames per second.
--   : Updates to Wi-Fi, Bluetooth, calls, SMS/MMS and network settings.
--   : Video sound and two-way call audio still need separate testing.
--   : Preview 20 installed on Pixel 3a with data preserved. Wi-Fi connected and 4G registration observed.
-
-### 
-
--   : Simpler French and English history, with badges and platform filters. Shared content for the website and releases.
-
-[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/v2.7-preview.20)
-
-
-
-## 12/10/2026 : Video and time
-
-
-
-### 
-
--   : Video recording with sound, thumbnails and playback in Gallery and Video.
--   : “Improve Vulpes” opens the project’s issues.
--   : Date and time pickers restored; Keyboard back navigation and list text fixed.
-
-### 
-
--   : Recording checked with a simulated camera.
-
-### 
-
--   : Changes prepared for a future APK; video not yet tested on Android.
-
-### 
-
--   : Improved preview, at around 16 frames per second.
--   : Automatic time depends on network information; time zone remains manual.
--   : Video recording and camera switching checked. Video sound still needs listening tests.
-
-
-
-## 12/10/2026 : Calls and hotspot
-
-
-
-### 
-
--   : Dialog closing and retry after an error fixed.
-
-### 
-
--   : Settings for call waiting, caller ID and call forwarding.
--   : Hotspot with or without a password.
--   : Slow settings no longer block calls and SMS.
--   : Hotspot activation checked; Internet access from another device still needs testing.
-
-
-
-## 12/10/2026 : Network settings
-
-
-
-### 
-
--   : IP address and connection speed displayed correctly.
-
-### 
-
--   : Network type selection and mobile access point editing.
--   : More reliable network settings when the modem responds slowly.
--   : Adding, deleting and resetting access points remain incomplete.
-
-
-
-## 12/10/2026 : Settings review
-
-
-
-### 
-
--   : Navigation, accessibility and help fixed. Incomplete panels remain visible.
--   : Menus reviewed; some actions still depend on hardware or the carrier.
-
-### 
-
--   : Storage information and downloads connected.
-
-### 
-
--   : Display of storage used by Vulpes.
-
-### 
-
--   : Mobile data, roaming, time, hidden Wi-Fi networks and Sound panel fixed.
-
-
-
-## 12/10/2026 : Settings navigation
-
-
-
-### 
-
--   : Light/Dark selection and picker confirmation or cancellation restored.
--   : Manager adapted to single-SIM phones.
-
-### 
-
--   : SIM information, message centre and delivery reports.
--   : Faster loading of saved networks.
--   : Wi-Fi certificates, SIM security and USB storage remain incomplete.
-
-
-
-## 12/10/2026 : Appearance on Pixel 3a
-
-
-
-### 
-
--   : Shared light and dark modes available on Pixel 3a.
--   : Settings fixes and Firefox OS fonts included.
--   : No new call, SMS or Bluetooth test at this stage.
--   : Themes and display checked on the phone.
-
-
-
-## 11/10/2026 : Appearance and keyboard
-
-
-
-### 
-
--   : Light, Dark and System modes, preserved after restart.
--   : Text selection and deletion fixed.
-
-### 
-
--   : Themes and SMS typing checked.
-
-### 
-
--   : Experimental Gaia keyboard; Android keyboard remains the default.
--   : Retry available when the camera is busy.
--   : Preview 19 built; themes, keyboard and front camera still need testing on Android.
-
-
-
-## 11/10/2026 : Settings and home screen
-
-
-
-### 
-
--   : Second home screen, wallpaper previews and installation banner fixed.
--   : Error message dismissal restored.
--   : Ringtones, lock code, permissions and French/English labels fixed.
-
-### 
-
--   : Open the notification tray by tapping the status bar.
--   : Preview 18 built locally.
-
-### 
-
--   : Returned to the previous version after an unstable interface restart.
--   : Operation confirmed by the user.
-
-
-
-## 09/10/2026 : MMS and Bluetooth
-
-
-
-### 
-
--   : Gaia panels connected to each platform’s capabilities.
-
-### 
-
--   : Discovery, pairing, connection, forgetting devices and audio output.
--   : Sending preserved and resumed after interruption.
--   : MMS accepted by the carrier, delivery not confirmed. Headset call audio still needs testing.
--   : Bluetooth audio playback to a PC checked.
-
-
-
-## 08/10/2026 : Camera and MMS
-
-
-
-### 
-
--   : Camera switching, shutdown and display after rotation fixed.
-
-### 
-
--   : Attachments preserved and sending shown as pending until confirmation.
-
-### 
-
--   : Preview measured at around 16 fps on the rear camera and 25–27 on the front.
--   : Real sending still blocked by the mobile data connection.
--   : Focus, zoom and photo capture checked.
-
-
-
-## 08/10/2026 : Call history and dialogs
-
-### 
-
--   : Readable error messages with usable buttons.
-
-### 
-
--   : Clear message when sending is unavailable, without losing the draft.
--   : Calling back from call history restored.
-
-### 
-
--   : Flow checked with simulated calls; real audio not tested.
-
-
-
-## 08/10/2026 : SMS and audio
-
-
-
-### 
-
--   : Volume display, lock screen and notification swiping fixed.
--   : Send with one tap without first closing the keyboard. Attachment menu restored.
-
-### 
-
--   : Call audio routing fixes.
--   : Network issues prevented testing a real conversation; two-way audio not confirmed.
-
-
-
-## 08/10/2026 : Network reliability
-
-
-
-### 
-
--   : Messages linked to the correct conversation.
-
-### 
-
--   : Improved modem recovery; retries avoid duplicate calls and SMS.
--   : Network stability and two-way audio still need confirmation.
--   : Return to the 4G network after modem restart checked.
-
-
-
-## 08/10/2026 : Public source code
-
-### 
-
--   : Camera, contacts, SMS and Marketplace fixes published, with updated French/English project overviews.
-
-### 
-
--   : Building from a fresh repository copy fixed.
--   : The public APK remains Preview 17; these fixes are in the source code.
-
-### 
-
--   : Network search still unstable at this stage; no new image published.
-
-
-
-## 08/10/2026 : Camera and contacts
-
-
-
-### 
-
--   : More reliable opening and camera switching.
--   : Photos, dates, edits and number selection from Messages or Phone fixed.
--   : Media selection fixed.
--   : Fixes in source code; public APK unchanged at this stage.
-
-### 
-
--   : Number and text passed correctly to the Android app.
-
-### 
-
--   : Discovery available; pairing and audio use still need testing.
--   : Photo capture, focus, zoom and camera switching checked.
-
-
-
-## 06/10/2026 : Marketplace theme
-
-
-
-### 
-
--   : Integration planned for a future APK.
-
-### 
-
--   : Embedded theme: Fira font, compact navigation, touch buttons and scrollable screenshots.
--   : Embedded display preserved during search, filtering and language changes.
--   : Theme prepared locally, not yet deployed to the website.
--   : French and English pages checked on small screens.
-
-
-
-## 06/10/2026 : Notification banners
-
-
-
-### 
-
--   : The “Application installed” banner disappears automatically and is replaced correctly.
-
-### 
-
--   : Banner display and dismissal checked.
-
-### 
-
--   : Fix prepared for the next APK.
-
-### 
-
--   : Banner disappearance after five seconds checked.
-
-
-
-## 06/10/2026 : Wi-Fi and settings
-
-
-
-### 
-
--   : Panels open even when an animation does not finish normally.
-
-### 
-
--   : Fixes not yet included in the public APK.
-
-### 
-
--   : Detected networks and disconnections displayed correctly.
--   : Search, connection and Internet access checked.
-
-
-
-## 06/10/2026 : Marketplace navigation
-
-
-
-### 
-
--   : Local message with a Retry button when the catalogue is unavailable.
--   : The catalogue stays below the status bar and hides when returning home or closing it.
--   : An earlier error no longer hides a new attempt.
-
-### 
-
--   : Navigation, errors, returning home and closing checked.
-
-### 
-
--   : Preview 17 rebuilt locally, without a new Android test.
-
-### 
-
--   : Interface tested with a local page; remote catalogue not checked without Internet.
-
-
-
-## 05/10/2026 : 2.7-preview.17
-
-
-
-### 
-
--   : Install, launch and uninstall packaged web apps.
--   : Vulpes catalogue on the home screen, in French and English, with its original icon.
--   : Installed apps kept separate from system apps.
--   : Some old Firefox OS apps and APIs are not yet supported.
-
-### 
-
--   : Installation and use of 2048 checked.
-
-### 
-
--   : 2048 installed, preserved after restart and uninstalled on an Android emulator.
-
-### 
-
--   : Home screen checked; app installation still needs validation on Pixel.
-
-
-
-## 05/10/2026 : 2.7-preview.16
-
-
-
-### 
-
--   : Vulpes startup logo.
--   : Unwanted horizontal scrolling removed; test apps hidden.
--   : Wallpaper and sound selection restored. Home screen language applied and preserved after restart.
--   : APK built, not published; Pixel image tried without permanent installation.
-
-### 
-
--   : Android bars hidden and accessible by swiping; launcher mode checked.
--   : Return from sleep and the lock screen camera fixed.
-
-### 
-
--   : Old files no longer overwrite updates at startup.
-
-
-
-## 04/10/2026 : Standalone installation
-
-
-
-### 
-
--   : Installer with device and file checks before writing.
--   : Standalone Vulpes installation without going through Droidian.
--   : Package not published; tested on one Pixel only. Installation replaces phone data.
--   : Backups checked; full restoration still needs testing.
--   : Installed boots, saved settings, camera, keyboard and notifications checked.
-
-
-
-## 03/10/2026 : Per-device installation
-
-
-
-### 
-
--   : Preparation adapted to each Pixel, with device-specific backups.
--   : Focus sometimes times out; issue still open.
--   : Direct installation from stock Android not yet available.
--   : Temporary boots, saved settings and return to the previous installation checked.
-
-
-
-## 03/10/2026 : Installation and recovery
-
-
-
-### 
-
--   : Package preparation and recovery kit with a boot backup.
--   : Restoration possible without booting the diagnostic tool.
--   : Preparation checked locally; installation on another Pixel still needs completion.
-
-### 
-
--   : Shared history included in upcoming source releases.
-
-
-
-## 03/10/2026 : Modem and first startup
-
-
-
-### 
-
--   : Recover the modem without restarting the whole phone.
--   : Fonts and modem initialization fixed on a fresh installation.
--   : Intermittent modem restarts still need fixing; public package incomplete.
--   : Cameras, focus, zoom, keyboard, notifications and Wi-Fi search checked.
-
-
-
-## 01/10/2026 : Preparing distribution
-
-
-
-### 
-
--   : Preparation of an image with clean user data.
--   : Font display on first boot fixed.
--   : Image not published. Calls, SMS and mobile data still need end-to-end testing.
--   : Intermittent modem restart identified.
--   : SIM, 4G, Wi-Fi search, cameras, keyboard and notifications checked in a temporary boot.
-
-
-
-## 30/09/2026 : 2.7-preview.15
-
-
-
-### 
-
--   : First publication of Desktop, Android and Tundra source code, with French/English project overviews.
--   : More reliable camera stream shutdown and replacement.
--   : Dates and sending statuses fixed in conversations.
-
-### 
-
--   : Navigation, settings, gestures and saved data checked.
-
-### 
-
--   : Update, Camera, Gallery and returns from the background checked on an Android emulator.
-
-### 
-
--   : Source code included; universal installation still unavailable at this stage.
--   : Flash, video, rotation, sleep and call audio still incomplete or awaiting tests.
-
-[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/v2.7-preview.15)
-
-
-
-## 30/09/2026 : SMS and camera follow-up
-
-
-
-### 
-
--   : Camera release and switching fixed.
--   : Empty conversations fixed.
-
-### 
-
--   : Shared sources updated, without a new APK.
-
-### 
-
--   : Vibration now stops correctly.
--   : Dialog after sending SMS and camera smoothness still need fixing.
--   : Focus and zoom confirmed by the user.
--   : Sending and receiving confirmed by the user; conversations displayed.
-
-
-
-## 30/09/2026 : SMS and photos
-
-
-
-### 
-
--   : Failed messages preserved and sending statuses updated.
-
-### 
-
--   : Preview 14 APK unchanged at this stage.
-
-### 
-
--   : Tap to focus and digital zoom. Photos up to 12 MP at the rear and 8 MP at the front.
--   : Preview return after taking a photo fixed.
--   : Delivery to the recipient not yet confirmed.
-
-
-
-## 30/09/2026 : 2.7-preview.14
-
-
-
-### 
-
--   : Fonts, version information, lock screen and notifications aligned.
-
-### 
-
--   : Larger keyboard, improved text editing and recipient number entry.
-
-### 
-
--   : Startup and fonts checked on an Android emulator.
-
-### 
-
--   : Vibration stopping fixed.
--   : Notifications preserved, conversation opening and duplicate messages fixed.
--   : Saved networks, reconnection and forgetting a network fixed.
--   : Audio controls tested outside calls; conversation audio and notification sounds still need testing.
-
-[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-preview.14)
-
-
-
-## 27/09/2026 : 2.7-preview.13
-
-
-
-### 
-
--   : Event reminders fixed, including after deletion.
--   : Initial display and closing the editor fixed.
--   : Conversation filters fixed.
-
-### 
-
--   : Photo, video, calendar and conversations checked.
-
-### 
-
--   : Camera, Gallery, Calendar and background resume checked.
-
-### 
-
--   : Camera, Wi-Fi and brightness checked on the installed version.
-
-
-
-## 27/09/2026 : 2.7-preview.12
-
-
-
-### 
-
--   : Local start page, in French or English according to language.
--   : SMS conversations and Calendar translations fixed.
--   : Camera switching and interface zoom fixed; flash disabled when unavailable.
--   : Sharper icons, long press on Home and gestures fixed.
-
-### 
-
--   : Smoother preview, at around 23 frames per second in testing.
--   : More responsive brightness and Wi-Fi connection fixed.
--   : Video, flash, Bluetooth and advanced call settings remain incomplete.
-
-
-
-## 27/09/2026 : Interface and hardware
-
-
-
-### 
-
--   : First local start page.
--   : Original shutdown animation retained, with blue circles.
--   : List and Video text fixed; transparent Home bar adapted to landscape.
--   : Call history headers and icons fixed.
-
-### 
-
--   : Brightness adjustment connected.
--   : Call audio, video, full-resolution photos and rotation still need testing.
-
-
-
-## 27/09/2026 : Wi-Fi stability
-
-
-
-### 
-
--   : Preview 9 built with shared fixes; Wi-Fi handled by Android.
-
-### 
-
--   : Network discovery stabilized to avoid phone freezes.
--   : Camera and calls remain incomplete.
--   : Lock screen, fonts, date, battery and Wi-Fi checked.
-
-
-
-## 27/09/2026 : Restoring Gaia components
-
-
-
-### 
-
--   : Credits, startup screen and browser name updated.
--   : Original Gaia panels and styles restored in Settings, Data Usage, FM Radio and imports.
--   : Home button no longer overlaps controls; date, battery and lock screen restored.
-
-### 
-
--   : Launcher mode, access to Android settings and background resume.
-
-### 
-
--   : Initial integration unstable; returned to the previous version.
-
-
-
-## 27/09/2026 : Calls and hardware buttons
-
-
-
-### 
-
--   : Gaia incoming and outgoing call interfaces, with call history.
--   : Fira fonts, contacts and battery display fixed.
-
-### 
-
--   : Support for Power and Volume keys, shutdown menu and waking the screen.
--   : Native camera still unavailable.
--   : Ringing and hanging up checked; conversation audio still needs testing.
-
-
-
-## 26/09/2026 : Startup and fonts
-
-
-
-### 
-
--   : Fira fonts and battery information fixed; test buttons removed from settings.
-
-### 
-
--   : Data preserved in the installed boot.
--   : Stable fullscreen, without automatic gestures or app launches.
--   : Sound and microphone tested separately; call audio, camera and sleep not validated.
-
-
-
-## 25/09/2026 : Home screen and first native trials
-
-
-
-### 
-
--   : Round button, transparent background on Home and blue background in apps.
--   : Pressed button state, gradient, fonts and system information fixed.
-
-### 
-
--   : App resume changed in source code, not yet tested on Android.
-
-### 
-
--   : First native image trials with accelerated display and battery support.
--   : Temporary trials; many hardware features still need connecting.
-
-
-
-## 2.7-preview.7
-
-
-
-### 
-
--   : Vulpes, Gaia and Gecko versions shown separately.
-
-### 
-
--   : Improved permission handling and clearer error messages.
-
-### 
-
--   : Resume after the first permission request fixed.
-
-
-
-## 2.7-preview.6
-
-
-
-### 
-
--   : Gaia Camera app connected to the webcam.
--   : File import and photos available in Gallery.
--   : Camera switching depends on hardware; flash and video unavailable.
-
-### 
-
--   : Gaia Camera app connected to Android cameras.
--   : File import and photos available in Gallery.
--   : Camera switching depends on hardware; flash and video unavailable.
-
-
-
-## 2.7-preview.5
-
-
-
-### 
-
--   : System settings, media and contact imports, notifications and webcam test.
--   : Interface and version numbering aligned with Android.
-
-### 
-
--   : Interface and version numbering aligned with Desktop.
--   : Improved background resume and display recovery.
-
-
-
-## 2.7-156.0.2
-
-
-
-### 
-
--   : Android settings, sound, brightness, imports and notifications.
--   : Calls, SMS, clock and camera handled by Android apps.
--   : White screens and background resume fixed.
--   : Historical numbering: this number does not represent a Gecko update.
-
-
-
-## 2.7-156.0.1
-
-
-
-### 
-
--   : “Vulpes OS – Preview” name and project icon.
--   : Wallpaper flickering fixed.
--   : Historical numbering retained to identify this release.
-
-[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-156.0.1)
-
-
-
-## 2.7-preview.2
-
-
-
-### 
-
--   : Wallpaper flickering fixed.
-
-[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-preview.2)
-
-
-
-## 2.7-preview.1
-
-
-
-### 
-
--   : First Android Preview to try Vulpes without flashing the phone.
--   : Known wallpaper flickering in this first version.
-
-[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-preview.1)
-
-
-
-## Early milestones
-
-### 
-
--   : Adaptation of contacts, messages, settings, alarms and storage.
--   : Creation of Vulpes: visual identity, translations and system information.
--   : Move from old Firefox OS engines to a modern Gecko.
-
-### 
-
--   : First native boots and hardware trials on Pixel 3a.
diff --git a/CHANGELOG.md b/CHANGELOG.md
deleted file mode 100644
index f047bd5f..00000000
--- a/CHANGELOG.md
+++ /dev/null
@@ -1,904 +0,0 @@
-# Journal des modifications — Vulpes OS
-
-[English](CHANGELOG.en.md)
-
-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
-
-
-
-### 
-
--   : Messages des fonctions indisponibles plus lisibles en thèmes clair et sombre.
--   : Dictionnaires supplémentaires disponibles hors ligne ; activation et reprise du clavier Gaia fiabilisées. La vérification sur les appareils concernés reste nécessaire.
--   : Affichage du nom des contacts rétabli au premier chargement du journal.
--   : Panneaux Stockage rétablis. Une erreur de chargement ne bloque plus les autres menus.
-
-### 
-
--   : Lecture des contacts, SMS et journaux Android dans Gaia, après autorisation. Les données locales de Vulpes sont conservées.
--   : Retour vers Vulpes sans écran blanc persistant ; un catalogue fermé peut être rouvert.
--   : Le rectangle blanc signalé reste à reproduire sur les appareils concernés.
--   : Première étape en lecture seule, hors MMS. Envoi et édition des données Android dans les applications Android ; les nouveaux contacts Gaia restent locaux. Accès SMS et journal selon les autorisations de la ROM et de l’installateur. Essais sur Android 9 ; autres versions à confirmer.
-
-### 
-
--   : Les corrections communes de Gaia sont incluses dans les images complètes et les mises à jour de démarrage.
--   : Nouvelle image d’installation construite ; démarrage et flash sur Pixel à vérifier avant publication.
-
-
-
-## 13/10/2026 : 2.7-preview.20
-
-
-
-### 
-
--   : Mode vidéo, avec miniature et lecture dans Galerie et Vidéo.
--   : Améliorations des paramètres, de l’apparence, des sélecteurs, du clavier, des contacts et des médias.
--   : Changement de capteur, arrêt et reprise fiabilisés.
--   : Compte Mozilla, Sync, localisation distante, extensions et transfert USB restent incomplets.
-
-### 
-
--   : APK Preview 20 disponible.
--   : Appels et SMS utilisent Android. Cette Preview n’a pas encore été validée en utilisation sur Android.
-
-### 
-
--   : Aperçu autour de 16 images par seconde.
--   : Mise à jour du Wi-Fi, du Bluetooth, des appels, des SMS/MMS et des réglages réseau.
--   : Son des vidéos et conversation téléphonique à vérifier séparément.
--   : Preview 20 installée sur le Pixel 3a, données conservées. Wi-Fi connecté et réseau 4G détecté.
-
-### 
-
--   : Historique FR/EN simplifié, badges et filtres par plateforme. Même contenu pour le site et les releases.
-
-[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/v2.7-preview.20)
-
-
-
-## 12/10/2026 : Vidéo et heure
-
-
-
-### 
-
--   : Enregistrement vidéo avec son, miniature et lecture dans Galerie et Vidéo.
--   : « Améliorer Vulpes » mène aux tickets du projet.
--   : Sélecteurs de date et d’heure rétablis ; retour du menu Clavier et texte des listes corrigés.
-
-### 
-
--   : Enregistrement vérifié avec une caméra simulée.
-
-### 
-
--   : Changements préparés pour un prochain APK ; vidéo non encore testée sur Android.
-
-### 
-
--   : Aperçu amélioré, autour de 16 images par seconde.
--   : Heure automatique selon les informations du réseau ; fuseau horaire encore manuel.
--   : Vidéo et changement de capteur vérifiés. Son des vidéos encore à écouter.
-
-
-
-## 12/10/2026 : Appels et partage Wi-Fi
-
-
-
-### 
-
--   : Fermeture des dialogues et nouvelle tentative après une erreur corrigées.
-
-### 
-
--   : Réglages du double appel, de l’affichage du numéro et du renvoi d’appel.
--   : Partage de connexion avec ou sans mot de passe.
--   : Les réglages lents ne bloquent plus les appels et les SMS.
--   : Activation du point d’accès vérifiée ; accès Internet d’un autre appareil encore à tester.
-
-
-
-## 12/10/2026 : Paramètres réseau
-
-
-
-### 
-
--   : Adresse IP et vitesse de connexion affichées correctement.
-
-### 
-
--   : Choix du type de réseau et modification des points d’accès mobiles.
--   : Réglages réseau plus fiables lorsque le modem répond lentement.
--   : Ajout, suppression et réinitialisation des points d’accès encore incomplets.
-
-
-
-## 12/10/2026 : Revue des paramètres
-
-
-
-### 
-
--   : Navigation, accessibilité et aide corrigées. Les panneaux incomplets restent visibles.
--   : Menus parcourus ; certaines actions dépendent encore du matériel ou de l’opérateur.
-
-### 
-
--   : Informations de stockage et téléchargements raccordés.
-
-### 
-
--   : Affichage de l’espace utilisé par Vulpes.
-
-### 
-
--   : Données mobiles, itinérance, heure, réseaux Wi-Fi masqués et panneau Son corrigés.
-
-
-
-## 12/10/2026 : Navigation des paramètres
-
-
-
-### 
-
--   : Choix Clair/Sombre et validation ou annulation des sélecteurs rétablis.
--   : Gestionnaire adapté aux téléphones à une seule SIM.
-
-### 
-
--   : Informations SIM, centre de messagerie et accusés de réception.
--   : Chargement plus rapide des réseaux enregistrés.
--   : Certificats Wi-Fi, sécurité SIM et stockage USB encore incomplets.
-
-
-
-## 12/10/2026 : Apparence sur Pixel 3a
-
-
-
-### 
-
--   : Modes clair et sombre communs disponibles sur le Pixel 3a.
--   : Correctifs des paramètres et polices Firefox OS intégrés.
--   : Pas de nouvel essai des appels, SMS ou Bluetooth à cette étape.
--   : Thèmes et affichage vérifiés sur le téléphone.
-
-
-
-## 11/10/2026 : Apparence et clavier
-
-
-
-### 
-
--   : Modes Clair, Sombre et Système, conservés au redémarrage.
--   : Sélection et suppression de texte corrigées.
-
-### 
-
--   : Thèmes et saisie de SMS vérifiés.
-
-### 
-
--   : Clavier Gaia expérimental ; le clavier Android reste proposé par défaut.
--   : Nouvelle tentative possible quand la caméra est occupée.
--   : Preview 19 construite ; thèmes, clavier et caméra avant encore à vérifier sur Android.
-
-
-
-## 11/10/2026 : Paramètres et accueil
-
-
-
-### 
-
--   : Second écran d’accueil, aperçus de fonds d’écran et bandeau d’installation corrigés.
--   : Fermeture du message d’erreur rétablie.
--   : Sonneries, code de verrouillage, permissions et libellés FR/EN corrigés.
-
-### 
-
--   : Ouverture du volet en touchant la barre de statut.
--   : Preview 18 construite localement.
-
-### 
-
--   : Retour à la version précédente après un redémarrage instable de l’interface.
--   : Fonctionnement confirmé par l’utilisateur.
-
-
-
-## 09/10/2026 : MMS et Bluetooth
-
-
-
-### 
-
--   : Panneaux Gaia reliés aux capacités de chaque plateforme.
-
-### 
-
--   : Recherche, association, connexion, oubli d’appareils et sortie audio.
--   : Envoi conservé et repris après une interruption.
--   : MMS accepté par l’opérateur, réception non confirmée. Audio d’appel sur casque à tester.
--   : Lecture audio Bluetooth vers un PC vérifiée.
-
-
-
-## 08/10/2026 : Caméra et MMS
-
-
-
-### 
-
--   : Changement de capteur, arrêt et affichage après rotation corrigés.
-
-### 
-
--   : Pièces jointes conservées et envoi affiché en attente jusqu’à confirmation.
-
-### 
-
--   : Aperçu mesuré autour de 16 images/s à l’arrière et 25 à 27 à l’avant.
--   : Envoi réel encore bloqué par la connexion aux données mobiles.
--   : Mise au point, zoom et prise de photo vérifiés.
-
-
-
-## 08/10/2026 : Journal d’appel et dialogues
-
-### 
-
--   : Messages d’erreur lisibles, avec des boutons utilisables.
-
-### 
-
--   : Message clair si l’envoi est indisponible, sans perdre le brouillon.
--   : Rappel depuis le journal d’appel rétabli.
-
-### 
-
--   : Parcours vérifié avec des appels simulés ; audio réel non testé.
-
-
-
-## 08/10/2026 : SMS et audio
-
-
-
-### 
-
--   : Affichage du volume, verrouillage et balayage des notifications corrigés.
--   : Envoi en un appui, sans fermer d’abord le clavier. Menu des pièces jointes rétabli.
-
-### 
-
--   : Corrections du routage du son des appels.
--   : Le réseau a empêché de vérifier une conversation réelle ; son dans les deux sens non confirmé.
-
-
-
-## 08/10/2026 : Fiabilité du réseau
-
-
-
-### 
-
--   : Messages associés à la bonne conversation.
-
-### 
-
--   : Récupération du modem améliorée ; les nouvelles tentatives évitent de doubler appels et SMS.
--   : Stabilité du réseau et audio dans les deux sens encore à confirmer.
--   : Retour sur le réseau 4G après redémarrage du modem vérifié.
-
-
-
-## 08/10/2026 : Sources publiques
-
-### 
-
--   : Publication des corrections caméra, contacts, SMS et Marketplace, avec présentations FR/EN à jour.
-
-### 
-
--   : Construction depuis une copie neuve du dépôt corrigée.
--   : L’APK public reste en Preview 17 ; ces corrections sont dans les sources.
-
-### 
-
--   : Recherche de réseau encore instable à cette étape ; pas de nouvelle image publiée.
-
-
-
-## 08/10/2026 : Caméra et contacts
-
-
-
-### 
-
--   : Ouverture et changement de capteur fiabilisés.
--   : Photos, dates, modifications et choix d’un numéro depuis SMS ou Téléphone corrigés.
--   : Sélection des médias corrigée.
--   : Correctifs dans les sources ; APK public inchangé à cette étape.
-
-### 
-
--   : Numéro et texte transmis correctement à l’application Android.
-
-### 
-
--   : Recherche disponible ; association et usages audio encore à vérifier.
--   : Photo, mise au point, zoom et changements de capteur vérifiés.
-
-
-
-## 06/10/2026 : Thème Marketplace
-
-
-
-### 
-
--   : Intégration prévue dans un prochain APK.
-
-### 
-
--   : Thème intégré : police Fira, navigation compacte, boutons tactiles et captures défilantes.
--   : Affichage intégré conservé pendant la recherche, les filtres et les changements de langue.
--   : Thème préparé localement, pas encore déployé sur le site.
--   : Pages françaises et anglaises vérifiées sur petits écrans.
-
-
-
-## 06/10/2026 : Bandeaux de notification
-
-
-
-### 
-
--   : Le bandeau « Application installée » disparaît automatiquement et se remplace correctement.
-
-### 
-
--   : Affichage et fermeture des bandeaux vérifiés.
-
-### 
-
--   : Correctif préparé pour le prochain APK.
-
-### 
-
--   : Disparition du bandeau après cinq secondes vérifiée.
-
-
-
-## 06/10/2026 : Wi-Fi et paramètres
-
-
-
-### 
-
--   : Ouverture des panneaux rétablie quand une animation ne se termine pas normalement.
-
-### 
-
--   : Correctifs encore absents de l’APK public.
-
-### 
-
--   : Réseaux détectés et déconnexions affichés correctement.
--   : Recherche, connexion et accès Internet vérifiés.
-
-
-
-## 06/10/2026 : Navigation Marketplace
-
-
-
-### 
-
--   : Message local avec bouton Réessayer si le catalogue est inaccessible.
--   : Le catalogue reste sous la barre de statut et se masque en revenant à l’accueil ou en le fermant.
--   : Une ancienne erreur ne masque plus une nouvelle tentative.
-
-### 
-
--   : Navigation, erreurs, retour à l’accueil et fermeture vérifiés.
-
-### 
-
--   : Preview 17 reconstruite localement, sans nouveau test Android.
-
-### 
-
--   : Interface testée avec une page locale ; catalogue distant non vérifié sans Internet.
-
-
-
-## 05/10/2026 : 2.7-preview.17
-
-
-
-### 
-
--   : Installation, lancement et désinstallation d’applications web empaquetées.
--   : Catalogue Vulpes sur l’accueil, en français et en anglais, avec son icône originale.
--   : Applications installées séparées des applications système.
--   : Certaines anciennes applications et API Firefox OS ne sont pas encore prises en charge.
-
-### 
-
--   : Installation et utilisation de 2048 vérifiées.
-
-### 
-
--   : 2048 installé, conservé après redémarrage et désinstallé sur un émulateur Android.
-
-### 
-
--   : Accueil vérifié ; installation d’applications encore à valider sur le Pixel.
-
-
-
-## 05/10/2026 : 2.7-preview.16
-
-
-
-### 
-
--   : Logo Vulpes au démarrage.
--   : Défilement horizontal involontaire supprimé ; applications de test masquées.
--   : Choix des fonds d’écran et des sons rétabli. Langue appliquée à l’accueil et conservée au redémarrage.
--   : APK construit, non publié ; image Pixel essayée sans installation permanente.
-
-### 
-
--   : Barres Android masquées, accessibles par balayage ; mode lanceur vérifié.
--   : Retour après veille et depuis la caméra du verrouillage corrigé.
-
-### 
-
--   : Les anciens fichiers ne remplacent plus les mises à jour au démarrage.
-
-
-
-## 04/10/2026 : Installation autonome
-
-
-
-### 
-
--   : Installateur avec vérification du modèle et des fichiers avant écriture.
--   : Installation autonome de Vulpes, sans passer par Droidian.
--   : Paquet non publié ; un seul Pixel testé. L’installation remplace les données du téléphone.
--   : Sauvegardes vérifiées ; restauration complète encore à essayer.
--   : Démarrages installés, conservation des réglages, caméra, clavier et notifications vérifiés.
-
-
-
-## 03/10/2026 : Installation par appareil
-
-
-
-### 
-
--   : Préparation adaptée à chaque Pixel, avec sauvegardes propres à l’appareil.
--   : Mise au point parfois trop lente ; problème encore ouvert.
--   : Installation directe depuis Android d’origine pas encore disponible.
--   : Démarrages temporaires, conservation des réglages et retour à l’installation précédente vérifiés.
-
-
-
-## 03/10/2026 : Installation et secours
-
-
-
-### 
-
--   : Préparation des paquets et kit de secours avec sauvegarde du démarrage.
--   : Restauration possible sans démarrer l’outil de diagnostic.
--   : Préparation vérifiée localement ; installation sur un autre Pixel encore à finaliser.
-
-### 
-
--   : Historique commun inclus dans les prochaines publications de sources.
-
-
-
-## 03/10/2026 : Modem et premier démarrage
-
-
-
-### 
-
--   : Récupération du modem sans redémarrer tout le téléphone.
--   : Polices et initialisation du modem corrigées sur une installation neuve.
--   : Redémarrages intermittents du modem encore à résoudre ; paquet public incomplet.
--   : Caméras, focus, zoom, clavier, notifications et recherche Wi-Fi vérifiés.
-
-
-
-## 01/10/2026 : Préparation de la distribution
-
-
-
-### 
-
--   : Préparation d’une image avec des données vierges.
--   : Affichage des polices au premier démarrage corrigé.
--   : Image non publiée. Appels, SMS et données mobiles encore à vérifier de bout en bout.
--   : Redémarrage intermittent du modem identifié.
--   : SIM, 4G, recherche Wi-Fi, caméras, clavier et notifications vérifiés en démarrage temporaire.
-
-
-
-## 30/09/2026 : 2.7-preview.15
-
-
-
-### 
-
--   : Première publication des sources PC, Android et Tundra, avec présentation FR/EN.
--   : Arrêt et remplacement du flux caméra fiabilisés.
--   : Dates et états d’envoi corrigés dans les conversations.
-
-### 
-
--   : Navigation, réglages, gestes et conservation des données vérifiés.
-
-### 
-
--   : Mise à jour, Photo, Galerie et retours d’arrière-plan vérifiés sur un émulateur Android.
-
-### 
-
--   : Sources incluses ; installation universelle encore indisponible à cette étape.
--   : Flash, vidéo, rotation, veille et audio d’appel encore incomplets ou à vérifier.
-
-[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/v2.7-preview.15)
-
-
-
-## 30/09/2026 : Retours SMS et caméra
-
-
-
-### 
-
--   : Libération du capteur et changement de caméra corrigés.
--   : Conversations vides corrigées.
-
-### 
-
--   : Sources communes mises à jour, sans nouvel APK.
-
-### 
-
--   : Arrêt du vibreur rétabli.
--   : Dialogue après envoi SMS et fluidité caméra encore à corriger.
--   : Focus et zoom confirmés par l’utilisateur.
--   : Envoi et réception confirmés par l’utilisateur ; conversations affichées.
-
-
-
-## 30/09/2026 : SMS et photo
-
-
-
-### 
-
--   : Messages en échec conservés et états d’envoi mis à jour.
-
-### 
-
--   : APK Preview 14 inchangé à cette étape.
-
-### 
-
--   : Mise au point au toucher et zoom numérique. Photos jusqu’à 12 Mpx à l’arrière et 8 Mpx à l’avant.
--   : Retour de l’aperçu après une photo corrigé.
--   : Réception par le destinataire non encore confirmée.
-
-
-
-## 30/09/2026 : 2.7-preview.14
-
-
-
-### 
-
--   : Polices, informations de version, verrouillage et notifications harmonisés.
-
-### 
-
--   : Clavier agrandi, édition de texte et saisie du numéro de destinataire améliorées.
-
-### 
-
--   : Démarrage et polices vérifiés sur un émulateur Android.
-
-### 
-
--   : Arrêt de la vibration corrigé.
--   : Notifications conservées, ouverture de la conversation et doublons corrigés.
--   : Réseaux enregistrés, reconnexion et oubli d’un réseau corrigés.
--   : Contrôles audio testés hors appel ; conversation et son des notifications encore à vérifier.
-
-[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-preview.14)
-
-
-
-## 27/09/2026 : 2.7-preview.13
-
-
-
-### 
-
--   : Rappels des événements corrigés, y compris après suppression.
--   : Premier affichage et fermeture de l’éditeur corrigés.
--   : Filtres des conversations corrigés.
-
-### 
-
--   : Photo, vidéo, calendrier et conversations vérifiés.
-
-### 
-
--   : Caméra, Galerie, Calendrier et reprise après arrière-plan vérifiés.
-
-### 
-
--   : Caméra, Wi-Fi et luminosité vérifiés sur la version installée.
-
-
-
-## 27/09/2026 : 2.7-preview.12
-
-
-
-### 
-
--   : Page d’accueil locale, en français ou en anglais selon la langue.
--   : Conversations SMS et traductions du Calendrier corrigées.
--   : Changement de capteur et zoom de l’interface corrigés ; flash grisé s’il est indisponible.
--   : Icônes plus nettes, appui long sur Accueil et gestes corrigés.
-
-### 
-
--   : Aperçu plus fluide, autour de 23 images par seconde lors des essais.
--   : Luminosité plus réactive et connexion Wi-Fi corrigée.
--   : Vidéo, flash, Bluetooth et réglages d’appel avancés encore incomplets.
-
-
-
-## 27/09/2026 : Interface et matériel
-
-
-
-### 
-
--   : Première page d’accueil locale.
--   : Animation d’extinction conservée, avec des ronds bleus.
--   : Textes des listes et de Vidéo corrigés ; barre Accueil transparente et adaptée au paysage.
--   : En-têtes et icônes du journal d’appel corrigés.
-
-### 
-
--   : Réglage de la luminosité raccordé.
--   : Appels audio, vidéo, pleine résolution photo et rotation encore à vérifier.
-
-
-
-## 27/09/2026 : Stabilité Wi-Fi
-
-
-
-### 
-
--   : Preview 9 construite avec les corrections communes ; Wi-Fi géré par Android.
-
-### 
-
--   : Recherche des réseaux stabilisée pour éviter les blocages du téléphone.
--   : Caméra et appels encore incomplets.
--   : Verrouillage, polices, date, batterie et Wi-Fi vérifiés.
-
-
-
-## 27/09/2026 : Retour aux composants Gaia
-
-
-
-### 
-
--   : Remerciements, écran de démarrage et nom du navigateur mis à jour.
--   : Panneaux et styles Gaia d’origine rétablis dans Paramètres, Suivi conso, Radio FM et les imports.
--   : Bouton Accueil dégagé des commandes ; date, batterie et écran de verrouillage rétablis.
-
-### 
-
--   : Mode lanceur, accès aux paramètres Android et reprise après arrière-plan.
-
-### 
-
--   : Première intégration instable ; retour à la version précédente.
-
-
-
-## 27/09/2026 : Appels et boutons physiques
-
-
-
-### 
-
--   : Interfaces Gaia d’appel entrant et sortant, avec journal d’appel.
--   : Polices Fira, contacts et affichage de la batterie corrigés.
-
-### 
-
--   : Prise en charge des touches Marche/Arrêt et Volume, du menu d’extinction et du réveil de l’écran.
--   : Caméra native encore indisponible.
--   : Sonnerie et raccrochage vérifiés ; conversation audio encore à tester.
-
-
-
-## 26/09/2026 : Démarrage et polices
-
-
-
-### 
-
--   : Polices Fira et informations de batterie corrigées ; boutons de test retirés des paramètres.
-
-### 
-
--   : Conservation des données lors du démarrage installé.
--   : Plein écran stable, sans gestes ni ouverture automatique d’applications.
--   : Son et micro testés séparément ; audio d’appel, caméra et veille non validés.
-
-
-
-## 25/09/2026 : Accueil et premiers essais natifs
-
-
-
-### 
-
--   : Bouton rond, fond transparent sur l’accueil et bleu dans les applications.
--   : État appuyé du bouton, dégradé, polices et informations système corrigés.
-
-### 
-
--   : Reprise de l’application modifiée dans les sources, pas encore testée sur Android.
-
-### 
-
--   : Premiers essais d’une image native avec affichage accéléré et batterie.
--   : Essais temporaires ; de nombreuses fonctions matérielles restent à raccorder.
-
-
-
-## 2.7-preview.7
-
-
-
-### 
-
--   : Versions de Vulpes, Gaia et Gecko distinguées.
-
-### 
-
--   : Gestion des autorisations et messages d’erreur plus précis.
-
-### 
-
--   : Reprise après la première demande d’autorisation corrigée.
-
-
-
-## 2.7-preview.6
-
-
-
-### 
-
--   : Application Photo Gaia reliée à la webcam.
--   : Import de fichiers et photos disponibles dans Galerie.
--   : Changement de capteur selon le matériel ; flash et vidéo indisponibles.
-
-### 
-
--   : Application Photo Gaia reliée aux caméras Android.
--   : Import de fichiers et photos disponibles dans Galerie.
--   : Changement de capteur selon le matériel ; flash et vidéo indisponibles.
-
-
-
-## 2.7-preview.5
-
-
-
-### 
-
--   : Réglages système, imports de médias et de contacts, notifications et essai webcam.
--   : Interface et numérotation des versions alignées avec Android.
-
-### 
-
--   : Interface et numérotation des versions alignées avec le PC.
--   : Reprise après arrière-plan et récupération de l’affichage améliorées.
-
-
-
-## 2.7-156.0.2
-
-
-
-### 
-
--   : Réglages Android, son, luminosité, imports et notifications.
--   : Appels, SMS, horloge et caméra confiés aux applications Android.
--   : Écrans blancs et reprise après arrière-plan corrigés.
--   : Ancienne numérotation : ce numéro ne correspond pas à une mise à jour de Gecko.
-
-
-
-## 2.7-156.0.1
-
-
-
-### 
-
--   : Nom « Vulpes OS – Preview » et icône du projet.
--   : Clignotement du fond d’écran corrigé.
--   : Ancienne numérotation, conservée pour retrouver cette publication.
-
-[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-156.0.1)
-
-
-
-## 2.7-preview.2
-
-
-
-### 
-
--   : Clignotement du fond d’écran corrigé.
-
-[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-preview.2)
-
-
-
-## 2.7-preview.1
-
-
-
-### 
-
--   : Première Preview Android pour découvrir Vulpes sans flasher le téléphone.
--   : Fond d’écran clignotant connu dans cette première version.
-
-[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-preview.1)
-
-
-
-## Premières étapes
-
-### 
-
--   : Adaptation des contacts, messages, paramètres, alarmes et du stockage.
--   : Création de Vulpes : identité visuelle, traductions et informations système.
--   : Passage des anciens moteurs Firefox OS à un Gecko moderne.
-
-### 
-
--   : Premiers démarrages natifs et essais matériels sur le Pixel 3a.
diff --git a/README.fr.md b/README.fr.md
index 92c85dc5..1530c623 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -1,52 +1,45 @@
-
English · Français
-
+🇬🇧 English · 🇫🇷 Français
+
Vulpes OS
-Firefox OS fork · Open source
-
+L'esprit de Firefox OS. Un Gecko moderne. Une base Gaia commune. Un projet communautaire pour retrouver une expérience mobile fondée sur le Web, sur PC, Android et téléphone natif.
-
-
-
-
-
-
-
-
-
-
+
+
+
+🌐 Le projet · 📦 Téléchargements · 📝 Changelog · 🐞 Signaler un bug
-[Site](https://vulpes-os.org/?lang=fr) · [Téléchargements](https://nnsprod.com/git/oversu/VulpesOS/releases) · [Changelog](CHANGELOG.md) · [Signaler un bug](https://nnsprod.com/git/oversu/VulpesOS/issues)
+## 🦊 Le Web comme interface
-Vulpes OS reprend Firefox OS pour faire fonctionner **Gaia**, son interface et ses applications HTML/CSS/JavaScript, avec un moteur **Gecko** récent.
+Accueil, paramètres, appareil photo, galerie, messages : **Gaia reste l'interface**, composée de HTML, CSS et JavaScript. Vulpes adapte les API attendues par ces applications Firefox OS à un moteur Gecko moderne et à des services propres à chaque plateforme.
-Je développe Vulpes principalement seul, sous le nom d’OverSu, avec l’aide des personnes qui testent et contribuent au projet. **Vulpes est libre et gratuit, et le restera.** Aucun abonnement ni fonctionnalité payante n’est prévu.
+Ce dépôt contient désormais l'interface commune, l'hôte PC, l'application Android, le runtime Tundra et les outils de développement. C'est une **première préversion pour développeurs**, pas un système mobile terminé.
-
-
-
-
-Accueil et paramètres de Gaia · captures de la version pour ordinateur.
+
+Captures de l'environnement de développement PC. Les fonctionnalités dépendent de la plateforme et du matériel disponible.
-## Essayer Vulpes
+## 📥 Choisissez votre plateforme
-| Plateforme | Comment l’utiliser |
-| --- | --- |
-| **Linux** | Lancer Gaia depuis les sources pour découvrir l’interface ou développer les applications. |
-| **Android** | Installer l’[APK disponible dans les releases](https://nnsprod.com/git/oversu/VulpesOS/releases). Il fonctionne dans Android et ne remplace pas le système du téléphone. |
-| **Google Pixel 3a · Sargo** | Faire fonctionner Vulpes comme système natif avec Tundra. L’image à télécharger est encore en préparation ; les fonctions testées et les limites sont décrites dans la [fiche de l’appareil](https://vulpes-os.org/devices/pixel-3a.php?lang=fr). |
+| Plateforme | Disponible aujourd'hui | Moteur |
+| --- | --- | --- |
+| **PC Linux** | Sources, fenêtre Gaia au format téléphone, développement et tests d'applications | Gecko **156.0.1** |
+| **APK Android** | Preview installable ; Android et ses permissions restent en dessous | GeckoView **156.0** |
+| **Pixel 3a · Sargo** | Sources Tundra ; build natif de développement testé sur notre appareil | Gecko **156.0.1** ARM64 |
-Sur Pixel 3a, **Tundra démarre directement Gecko et Gaia** et assure leur accès au matériel. Droidian a servi d’environnement de test au début du projet : il n’est pas inclus dans Vulpes OS et Tundra ne repose pas dessus.
+### Android — Preview 15
-### Preview 20 · 13 octobre 2026
+Téléchargez l'APK et `SHA256SUMS.txt` depuis [la release Preview 15](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/v2.7-preview.15). Une installation par-dessus une ancienne Preview Vulpes signée conserve ses données.
-[Télécharger l’APK](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/v2.7-preview.20). Les corrections communes concernent les paramètres Gaia, l’enregistrement vidéo, les contacts et les médias. Les adaptations réseau natives sont incluses dans les sources. Le [changelog](CHANGELOG.md) précise les vérifications et les limites restantes.
+- Minimum déclaré : **Android 8 / API 26**. Cible : **API 37**.
+- APK universel : **ARM64, ARMv7 et x86_64** ; environ **600 Mio**, moteur GeckoView inclus.
+- Photo et Galerie utilisent Gaia, avec les permissions et l'import de fichiers Android.
+- Les appels et SMS sont transmis aux applications Android ; Vulpes n'est pas l'application Téléphone/SMS par défaut.
+- Des parcours sur émulateur Android 16 sont testés. Le minimum SDK déclaré ne signifie pas que tous les appareils Android ont été validés.
+- Il s'agit d'un **build de développement signé**, avec débogage Gecko accessible via ADB autorisé, pas d'une application durcie pour la production.
-PC/Sargo utilisent Gecko **156.0.1** ; Android utilise GeckoView **156.0**. Android 8 / API 26 minimum déclaré, sans validation de tous les appareils. Appels et SMS ouvrent les applications Android. L’APK est un build de développement signé, avec débogage via ADB autorisé.
+### Linux — lancer depuis les sources
-### Linux
-
-Prérequis : Linux x86_64, Git, Python 3 et les bibliothèques système nécessaires à Firefox.
+Prérequis : Linux x86_64, Python 3, Git et les bibliothèques système nécessaires à Firefox. Sur Debian/Ubuntu : `git python3 libgtk-3-0 libdbus-glib-1-2 libasound2` (les noms de paquets peuvent varier selon la distribution).
```sh
git clone https://nnsprod.com/git/oversu/VulpesOS.git
@@ -55,29 +48,45 @@ cd VulpesOS
./run.sh
```
-`setup.sh` télécharge le moteur Gecko prévu pour cette version et vérifie son empreinte.
+`setup.sh` télécharge le moteur Mozilla verrouillé et vérifie son empreinte. Le lancement normal ouvre Gaia sans débogueur. Les profils et moteurs téléchargés restent hors Git.
-### Compiler l’APK
+### Pixel 3a — développement natif Tundra
-Depuis la racine du dépôt :
+**Le build natif démarre directement dans Vulpes**, avec des services matériels Android sélectionnés, un espace utilisateur Linux, Gecko et Gaia. Phosh et l'environnement applicatif Android ne sont pas démarrés.
+
+Notre image de développement Sargo installée a passé les contrôles de démarrage et de persistance. Des appels réels et des SMS dans les deux sens ont été observés ; les derniers essais couvrent les conversations, les bascules de capteur photo, la capture JPEG pleine résolution, le focus tactile, le zoom numérique et l'arrêt du vibreur.
+
+**Cette release ne contient pas d'image générique à flasher.** Le boot privé dépend encore de la disposition du stockage du téléphone inventorié, d'images système/données déjà déployées et d'accès de maintenance. Publier ce fichier ne fournirait pas une installation fonctionnelle à un autre Pixel. Les recettes Tundra sont incluses pour les contributeurs ; elles nécessitent un inventaire, des sauvegardes vérifiées et des composants matériels fournis localement. Ne réutilisez pas la sauvegarde d'un autre appareil et ne considérez pas un boot isolé comme une ROM complète.
+
+## ✨ Ce que nous partageons
+
+- **Une base Gaia :** interface et services communs pour PC, Android et Tundra.
+- **Les applications d'origine :** Photo, Galerie, Paramètres et Messages conservent leur interface Gaia, reliée aux adaptateurs de chaque plateforme.
+- **Du code Web éditable :** les ressources servies sont dans `assets/webapps/`, la compatibilité commune dans `host/`, les remplacements dans `overrides/`. Rechargez ou redémarrez pour tester, sans recompiler Gecko. Certaines sources historiques de `gaia/` nécessitent encore la mise à jour de leur version regroupée.
+- **Un moteur remplaçable :** téléchargements verrouillés, contrôle d'empreinte, tests isolés et outils de retour arrière. Une mise à jour du moteur se teste et se sélectionne explicitement ; la compatibilité n'est pas garantie automatiquement.
+- **Des versions distinctes :** Vulpes, Gaia, Gecko et adaptation matérielle sont identifiés séparément. GeckoView 156.0 n'est pas présenté comme Gecko bureau 156.0.1.
+
+## 🧪 Limites actuelles
+
+Cette Preview contient des adaptateurs matériels incomplets et des comportements hérités à corriger. Sur Sargo, l'aperçu photo tourne autour de 16 images/s ; flash/vidéo, rotation automatique par capteur et suspension complète restent incomplets. L'enregistrement réseau peut être instable et l'audio d'une conversation dans les deux sens reste à qualifier. Un grand dialogue signalé après envoi de SMS est encore en cours d'analyse.
+
+Sur Android, les intégrations suivent les permissions et les restrictions de cycle de vie du système. Les alarmes Gaia ne sont pas garanties après l'arrêt de Vulpes par Android. Certaines commandes non prises en charge restent visibles mais désactivées. Des applications de développement/test sont incluses.
+
+Conservez une sauvegarde avant vos essais. Pour signaler un problème, indiquez la plateforme, les versions Vulpes/Gecko et les étapes de reproduction ; retirez des logs les numéros, contacts et autres informations personnelles.
+
+## 🛠️ Construire l'APK Android
```sh
cd android
./build-apk.sh
```
-L’APK de développement est créé dans `android/dist/`. Une compilation personnelle utilise sa propre clé de signature : elle ne peut pas mettre à jour un APK signé avec la clé du projet.
+Le script installe les outils verrouillés, prépare les ressources Gaia communes et construit un APK de développement signé dans `android/dist/`. L'acceptation des licences SDK Android peut être nécessaire. Un clone neuf génère sa propre clé de signature : il ne pourra pas mettre à jour un APK signé avec la clé privée du projet.
-## Participer
+## 📜 Remerciements et licences
-Vous pouvez tester Vulpes, [signaler un problème](https://nnsprod.com/git/oversu/VulpesOS/issues), améliorer les traductions ou proposer du code. Pour un bug, indiquez la plateforme, la version utilisée et les étapes pour le reproduire.
+Vulpes OS est un projet communautaire d'**oversu / NNS Production**, inspiré de Firefox OS. Merci à **Mozilla, aux contributeurs Gaia, à Capyloon, Droidian, UBports, libhybris, Debian, AOSP** et aux communautés qui font vivre les systèmes mobiles ouverts.
-Les avancées et les comptes rendus d’essais sont dans le [changelog](CHANGELOG.md). Si vous souhaitez contribuer aux frais du projet, la page [Me soutenir](https://vulpes-os.org/support.php?lang=fr) explique à quoi sert ce soutien.
+Les nouveaux fichiers Vulpes utilisent **MPL 2.0**. Gaia conserve **Apache 2.0** ; les composants hérités conservent leurs licences et mentions. Voir [LICENSE](LICENSE), [NOTICE](NOTICE) et [la licence Gaia](gaia/LICENSE). Les forks doivent conserver les mentions d'attribution et de licence applicables.
-## Crédits et licences
-
-Merci à Mozilla, aux contributeurs de Gaia, à Capyloon, Droidian, UBports, libhybris, Debian et AOSP pour le code, les outils et les travaux qui ont aidé le projet.
-
-Les nouveaux fichiers Vulpes sont sous **MPL 2.0**. Gaia conserve sa licence **Apache 2.0** ; les autres composants conservent leurs licences et mentions. Voir [LICENSE](LICENSE), [NOTICE](NOTICE) et [la licence de Gaia](gaia/LICENSE).
-
-Vulpes OS est un projet indépendant d’**OverSu / NNS Production**, sans affiliation avec Mozilla.
+Vulpes OS est indépendant de Mozilla et n'est pas une version officielle de Firefox OS.
diff --git a/README.md b/README.md
index 72bee2ba..475e3270 100644
--- a/README.md
+++ b/README.md
@@ -1,52 +1,45 @@
-English · Français
-
+🇬🇧 English · 🇫🇷 Français
+
Vulpes OS
-Firefox OS fork · Open source
-
+The spirit of Firefox OS. A modern Gecko. One shared Gaia. A community project bringing a web-based phone experience to desktop, Android and native hardware.
-
-
-
-
-
-
-
-
-
-
+
+
+
+🌐 Website · 📦 Downloads · 📝 Changelog · 🐞 Report a bug
-[Website](https://vulpes-os.org/?lang=en) · [Downloads](https://nnsprod.com/git/oversu/VulpesOS/releases) · [Changelog](CHANGELOG.en.md) · [Report a bug](https://nnsprod.com/git/oversu/VulpesOS/issues)
+## 🦊 The Web as your interface
-Vulpes OS picks up where Firefox OS left off, adapting **Gaia**, its interface and HTML/CSS/JavaScript applications, to run on a recent **Gecko** engine.
+Home screen, settings, camera, gallery, messages: **Gaia remains the interface**, built from HTML, CSS and JavaScript. Vulpes adapts the APIs that these Firefox OS applications expect to a modern Gecko engine and platform-specific services.
-I’m OverSu, and I develop Vulpes mostly on my own, with help from people who test and contribute to the project. **Vulpes is free and open source, and will stay that way.** There are no plans for subscriptions or paid features.
+This repository now contains the shared interface, desktop host, Android application, Tundra runtime and development tools. This is an **early developer preview**, not a finished phone operating system.
-
-
-
-
-Gaia home screen and Settings · screenshots from the desktop version.
+
+Desktop development screenshots. Capabilities depend on the platform and available hardware.
-## Try Vulpes
+## 📥 Choose your platform
-| Platform | How to use it |
-| --- | --- |
-| **Linux** | Run Gaia from source to explore the interface or develop applications. |
-| **Android** | Install the [APK from the releases page](https://nnsprod.com/git/oversu/VulpesOS/releases). It runs as an Android application and does not replace your phone’s operating system. |
-| **Google Pixel 3a · Sargo** | Run Vulpes as a native operating system with Tundra. A downloadable image is still being prepared; see the [device page](https://vulpes-os.org/devices/pixel-3a.php?lang=en) for tested features and known limitations. |
+| Platform | Available today | Engine |
+| --- | --- | --- |
+| **Linux desktop** | Source checkout, phone-sized Gaia window, development and app tests | Gecko **156.0.1** |
+| **Android APK** | Installable Preview; keeps Android and its permissions underneath | GeckoView **156.0** |
+| **Pixel 3a · Sargo** | Tundra sources; native development build tested on our device | Gecko **156.0.1** ARM64 |
-On the Pixel 3a, **Tundra boots Gecko and Gaia directly** and provides access to the hardware. Droidian was used as a testing environment early in development. It is not included in Vulpes OS, and Tundra is not based on it.
+### Android — Preview 15
-### Preview 20 · 13 October 2026
+Download the APK and `SHA256SUMS.txt` from [the Preview 15 release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/v2.7-preview.15). Installing over an earlier signed Vulpes Preview preserves its data.
-[Download the APK](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/v2.7-preview.20). Shared updates cover Gaia settings, camera recording, contacts and media. Native network changes are included in the sources. See the [changelog](CHANGELOG.en.md) for checks and remaining limitations.
+- Minimum declared: **Android 8 / API 26**. Target: **API 37**.
+- Universal APK: **ARM64, ARMv7 and x86_64**; approximately **600 MiB** including GeckoView.
+- Camera and Gallery use Gaia, with Android permissions and file import.
+- Calls and SMS hand off to Android's applications; Vulpes is not the default SMS or phone app.
+- Android 16 emulator workflows are tested. Minimum SDK support is not a claim that every Android device is validated.
+- This is a signed **development build** with Gecko debugging available through authorized ADB, not a production-hardened app.
-Desktop/Sargo use Gecko **156.0.1**; Android uses GeckoView **156.0**. Android 8 / API 26 minimum is declared; not every device is tested. Calls and SMS open Android applications. The APK is a signed development build, with debugging through authorized ADB.
+### Linux — run from source
-### Linux
-
-You’ll need Linux x86_64, Git, Python 3 and the system libraries required by Firefox.
+Linux x86_64, Python 3, Git and the system libraries needed by Firefox are required. On Debian/Ubuntu, install `git python3 libgtk-3-0 libdbus-glib-1-2 libasound2` (package names may differ by distribution).
```sh
git clone https://nnsprod.com/git/oversu/VulpesOS.git
@@ -55,29 +48,45 @@ cd VulpesOS
./run.sh
```
-`setup.sh` downloads the Gecko engine selected for this version and verifies its checksum.
+`setup.sh` downloads the pinned Mozilla engine and verifies its checksum. Normal startup opens the Gaia interface without a debugger. Profiles and downloaded engines stay outside Git.
-### Build the APK
+### Pixel 3a — native Tundra development
-From the repository root:
+**The native build boots directly into Vulpes**, with selected Android hardware services, Linux userspace, Gecko and Gaia; Phosh and the Android application framework are not started.
+
+Our installed Sargo development image has passed startup and persistence checks. Real calls and two-way SMS have been observed; the latest checks cover conversation rendering, camera sensor switching, full-resolution JPEG capture, touch focus, digital zoom and bounded vibration.
+
+**A generic flashable image is not included in this release.** The current private boot depends on the inventoried phone's storage layout, staged system/data images and maintenance credentials. Publishing that file would not give another Pixel a working installation. Tundra's source recipes are included for contributors; they require device inventory, verified backups and locally supplied hardware components. Do not use another device's backup or treat a boot image as a complete ROM.
+
+## ✨ What is shared
+
+- **One Gaia base:** interface and common services serve desktop, Android and Tundra.
+- **Original applications:** Camera, Gallery, Settings and Messages keep their Gaia UI, backed by platform adapters.
+- **Editable web code:** change served assets in `assets/webapps/`, shared compatibility code in `host/`, and replacements in `overrides/`. Restart/reload to test; no Gecko rebuild is needed for these changes. Some historical `gaia/` sources still need their bundled counterparts updated.
+- **A replaceable engine:** pinned downloads, checksum verification, isolated compatibility checks and rollback tools. An engine update is tested and explicitly selected; compatibility is not guaranteed automatically.
+- **Separate version numbers:** Vulpes release, Gaia, Gecko and hardware adaptation are reported independently. GeckoView 156.0 is not presented as desktop Gecko 156.0.1.
+
+## 🧪 Current limits
+
+This preview contains incomplete hardware adapters and legacy application behavior. On Sargo, camera preview is around 16 fps; flash/video, automatic sensor rotation and full suspend remain incomplete. Network registration can be unstable and two-way call audio still needs end-to-end qualification. A reported large dialog after SMS sending remains under investigation.
+
+On Android, platform integration follows Android permissions and lifecycle restrictions. Gaia alarms are not guaranteed after Android stops Vulpes. Unsupported controls may remain visible but disabled. Development/test applications are included.
+
+Keep a backup before testing. Include the platform, Vulpes/Gecko versions and reproduction steps when reporting a problem; remove phone numbers, contacts and other personal information from logs.
+
+## 🛠️ Build the Android APK
```sh
cd android
./build-apk.sh
```
-The development APK is created in `android/dist/`. A personal build uses its own signing key, so it cannot update an APK signed with the project’s key.
+The script installs the pinned toolchain, packages the shared Gaia resources and builds a signed development APK in `android/dist/`. Android SDK licenses may need acceptance. A fresh checkout generates its own signing key; it cannot update an APK signed by the project's private release key.
-## Contribute
+## 📜 Credits and licenses
-You can test Vulpes, [report an issue](https://nnsprod.com/git/oversu/VulpesOS/issues), improve translations or contribute code. When reporting a bug, include your platform, the version you’re using and the steps to reproduce it.
+Vulpes OS is a community project by **oversu / NNS Production**, inspired by Firefox OS. Thanks to **Mozilla, Gaia contributors, Capyloon, Droidian, UBports, libhybris, Debian, AOSP** and the communities keeping open mobile systems alive.
-Development updates and test results are in the [changelog](CHANGELOG.en.md). If you’d like to help cover project costs, the [Support me](https://vulpes-os.org/support.php?lang=en) page explains what your contribution supports.
+New Vulpes code uses **MPL 2.0**. Gaia retains **Apache 2.0** and inherited components retain their own licenses and notices. See [LICENSE](LICENSE), [NOTICE](NOTICE) and [Gaia's license](gaia/LICENSE). Preserve the applicable attribution and license notices in forks.
-## Credits and licenses
-
-Thanks to Mozilla, Gaia contributors, Capyloon, Droidian, UBports, libhybris, Debian and AOSP for the code, tools and work that have helped this project.
-
-New Vulpes code is licensed under **MPL 2.0**. Gaia retains its **Apache 2.0** license; other components retain their own licenses and notices. See [LICENSE](LICENSE), [NOTICE](NOTICE) and [Gaia’s license](gaia/LICENSE).
-
-Vulpes OS is an independent project by **OverSu / NNS Production**, with no affiliation with Mozilla.
+Vulpes OS is independent of Mozilla and is not an official Firefox OS release.
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 4eb33b18..1d1d8db4 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -1,11 +1,6 @@
-
-
-
-
-
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
deleted file mode 100644
index ad17c798..00000000
--- a/android/app/src/main/java/org/vulpes_os/preview/AndroidPersonalData.java
+++ /dev/null
@@ -1,172 +0,0 @@
-package org.vulpes_os.preview;
-
-import android.Manifest;
-import android.content.ContentResolver;
-import android.content.ContentUris;
-import android.content.Context;
-import android.content.Intent;
-import android.content.pm.PackageManager;
-import android.database.Cursor;
-import android.database.ContentObserver;
-import android.os.Handler;
-import android.os.Looper;
-import java.util.HashMap;
-import java.util.Map;
-import android.net.Uri;
-import android.provider.CallLog;
-import android.provider.ContactsContract;
-import android.provider.Telephony;
-import org.json.JSONArray;
-import org.json.JSONObject;
-
-/** Read-through access. Android remains the owner of contacts and telephony records. */
-final class AndroidPersonalData {
- private final Context context;
- private final Handler main = new Handler(Looper.getMainLooper());
- private final Map 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 8aded31a..f52c11c4 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,7 +24,6 @@ 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;
@@ -32,7 +31,6 @@ final class AndroidServices {
AndroidServices(VulpesApplication app) {
this.app = app;
- personal = new AndroidPersonalData(app);
}
GeckoResult request(JSONObject message) {
@@ -42,30 +40,6 @@ 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")) {
@@ -128,18 +102,6 @@ final class AndroidServices {
);
result.complete(null);
break;
- case "android.storage":
- io.execute(() -> {
- try {
- File data = new File(app.getApplicationInfo().dataDir);
- File mediaRoot = new File(app.getFilesDir(), "media");
- JSONObject stats = new JSONObject().put("free", data.getUsableSpace())
- .put("apps", storageBytes(data, mediaRoot)).put("sdcard", storageBytes(mediaRoot, null))
- .put("scope", "vulpes-profile");
- main.post(() -> result.complete(stats));
- } catch (Exception e) { fail(result, e); }
- });
- break;
case "android.media":
JSONObject params = p;
io.execute(() -> {
@@ -160,15 +122,6 @@ final class AndroidServices {
return result;
}
- private static long storageBytes(File file, File skip) throws IOException {
- if (file.equals(skip) || !file.exists() || !file.getCanonicalFile().equals(file.getAbsoluteFile())) return 0;
- if (file.isFile()) return file.length();
- long size = 0;
- File[] children = file.listFiles();
- if (children != null) for (File child : children) size += storageBytes(child, skip);
- return size;
- }
-
private void fail(GeckoResult result, Exception e) {
main.post(() -> {
try {
diff --git a/android/app/src/main/java/org/vulpes_os/preview/GaiaServer.java b/android/app/src/main/java/org/vulpes_os/preview/GaiaServer.java
index 4319721d..6ac6e23b 100644
--- a/android/app/src/main/java/org/vulpes_os/preview/GaiaServer.java
+++ b/android/app/src/main/java/org/vulpes_os/preview/GaiaServer.java
@@ -14,13 +14,11 @@ import java.util.zip.*;
final class GaiaServer implements Closeable {
static final int PORT = 18765;
- final InstalledApps installed;
private final ZipFile zip;
private final ServerSocket socket;
private final ExecutorService workers = Executors.newFixedThreadPool(4);
GaiaServer(Context context) throws IOException {
- try {installed=new InstalledApps(context);}catch(Exception e){throw new IOException(e);}
File archive = new File(context.getCacheDir(), "gaia.zip");
// Atomic refresh on every process start; the APK is the source of truth.
File temp = new File(context.getCacheDir(), "gaia.zip.tmp");
@@ -96,7 +94,7 @@ final class GaiaServer implements Closeable {
}
String app = host.substring(0, host.indexOf('.'));
if (app.equals("theme")) app = "default_theme";
- if (!InstalledApps.valid(app) && zip.getEntry(app + "/manifest.webapp") == null) {
+ if (zip.getEntry(app + "/manifest.webapp") == null) {
error(out, 404);
return;
}
@@ -111,16 +109,6 @@ final class GaiaServer implements Closeable {
return;
}
if (path.endsWith("/")) path += "index.html";
- if (InstalledApps.valid(app)) {
- byte[] body=installed.read(app,path.substring(1));
- if(body==null){error(out,404);return;}
- String ext=path.substring(path.lastIndexOf('.')+1).toLowerCase(Locale.ROOT);
- String mime=MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext);
- if(ext.equals("js"))mime="text/javascript";
- if(ext.equals("webapp"))mime="application/json";
- if(mime==null)mime="application/octet-stream";
- out.write(("HTTP/1.1 200 OK\r\nContent-Type: "+mime+"\r\nContent-Length: "+body.length+"\r\nConnection: close\r\nX-Content-Type-Options: nosniff\r\nCache-Control: no-store\r\nAccess-Control-Allow-Origin: *\r\nContent-Security-Policy: object-src 'none'; frame-ancestors http://*.localhost:"+PORT+"\r\n\r\n").getBytes(StandardCharsets.US_ASCII));out.write(body);return;
- }
String key = path.startsWith("/_vulpes/") ? path.substring(1) : app + path;
ZipEntry entry = zip.getEntry(key);
if (entry == null && path.startsWith("/shared/")) entry = zip.getEntry(
diff --git a/android/app/src/main/java/org/vulpes_os/preview/InstalledApps.java b/android/app/src/main/java/org/vulpes_os/preview/InstalledApps.java
deleted file mode 100644
index 8ecd8840..00000000
--- a/android/app/src/main/java/org/vulpes_os/preview/InstalledApps.java
+++ /dev/null
@@ -1,37 +0,0 @@
-package org.vulpes_os.preview;
-
-import android.content.Context;
-import java.io.*;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.*;
-import java.util.*;
-import org.json.*;
-
-final class InstalledApps {
- private final File root;
- private final Map items = new HashMap<>();
- InstalledApps(Context context) throws Exception {
- root=new File(context.getFilesDir(),"installed-apps");root.mkdirs();
- File[] files=root.listFiles((d,n)->n.matches("user-[a-f0-9]{24}\\.json"));
- if(files!=null)for(File file:files){JSONObject item=new JSONObject(new String(Files.readAllBytes(file.toPath()),StandardCharsets.UTF_8));items.put(item.getString("id"),item);}
- }
- static boolean valid(String id){return id!=null && id.matches("user-[a-f0-9]{24}");}
- private JSONObject record(JSONObject item) throws Exception {
- String id=item.getString("id"),origin="http://"+id+".localhost:"+GaiaServer.PORT;
- JSONObject manifest=new JSONObject(item.getJSONObject("manifest").toString());manifest.put("permissions",new JSONObject());manifest.put("type","web");
- return new JSONObject().put("id",id).put("origin",origin).put("manifestURL",origin+"/manifest.webapp").put("manifest",manifest).put("removable",true).put("installed",true);
- }
- synchronized JSONArray list() throws Exception {JSONArray result=new JSONArray();for(JSONObject item:items.values())result.put(record(item));return result;}
- synchronized void save(JSONObject item) throws Exception {
- String id=item.getString("id");if(!valid(id) || !item.has("files"))throw new IOException("INVALID_PACKAGE");
- byte[] data=item.toString().getBytes(StandardCharsets.UTF_8);if(data.length>70*1024*1024)throw new IOException("PACKAGE_TOO_LARGE");
- File stage=new File(root,id+".tmp");try(FileOutputStream out=new FileOutputStream(stage)){out.write(data);out.getFD().sync();}
- Files.move(stage.toPath(),new File(root,id+".json").toPath(),StandardCopyOption.REPLACE_EXISTING);items.put(id,item);
- }
- synchronized void remove(String id) throws Exception {if(!valid(id))throw new IOException("NOT_REMOVABLE");Files.deleteIfExists(new File(root,id+".json").toPath());items.remove(id);}
- synchronized byte[] read(String id,String path) throws Exception {
- JSONObject item=items.get(id);if(item==null)return null;
- if(path.equals("manifest.webapp"))return record(item).getJSONObject("manifest").toString().getBytes(StandardCharsets.UTF_8);
- String data=item.getJSONObject("files").optString(path,null);return data==null?null:android.util.Base64.decode(data,android.util.Base64.DEFAULT);
- }
-}
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 458c8586..221e8f0c 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
@@ -45,7 +45,8 @@ public final class MainActivity extends Activity {
root.setOnApplyWindowInsetsListener((v, insets) -> {
if (Build.VERSION.SDK_INT >= 30) {
android.graphics.Insets bars = insets.getInsets(
- WindowInsets.Type.ime() |
+ WindowInsets.Type.systemBars() |
+ WindowInsets.Type.ime() |
WindowInsets.Type.displayCutout()
);
v.setPadding(bars.left, bars.top, bars.right, bars.bottom);
@@ -53,12 +54,11 @@ public final class MainActivity extends Activity {
insets.getSystemWindowInsetLeft(),
insets.getSystemWindowInsetTop(),
insets.getSystemWindowInsetRight(),
- insets.getSystemWindowInsetBottom() > dp(100) ? insets.getSystemWindowInsetBottom() : 0
+ insets.getSystemWindowInsetBottom()
);
return insets;
});
setContentView(root);
- immersive();
// Respect Android's screen timeout; Gaia must not keep the phone awake indefinitely.
float brightness = app.getSharedPreferences("android", 0).getFloat("brightness", -1);
if (brightness >= 0) {
@@ -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");
- back.setOnClickListener(v -> back());
+ back.setContentDescription("Retour au bureau");
+ back.setOnClickListener(v -> closeBrowser());
nav.addView(back, new LinearLayout.LayoutParams(dp(55), -1));
address = new EditText(this);
address.setSingleLine(true);
@@ -99,9 +99,6 @@ 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("◯");
@@ -169,28 +166,6 @@ public final class MainActivity extends Activity {
}
}
- private void immersive() {
- if (Build.VERSION.SDK_INT >= 30) {
- getWindow().setDecorFitsSystemWindows(false);
- WindowInsetsController controller = getWindow().getInsetsController();
- if (controller != null) {
- controller.setSystemBarsBehavior(WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
- controller.hide(WindowInsets.Type.systemBars());
- }
- } else {
- getWindow().getDecorView().setSystemUiVisibility(
- View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY | View.SYSTEM_UI_FLAG_FULLSCREEN |
- View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_LAYOUT_STABLE |
- View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION);
- }
- }
-
- @Override
- public void onWindowFocusChanged(boolean focused) {
- super.onWindowFocusChanged(focused);
- if (focused) immersive();
- }
-
private int dp(int value) {
return Math.round(value * getResources().getDisplayMetrics().density);
}
@@ -299,13 +274,11 @@ public final class MainActivity extends Activity {
setHomeSurface(app.homeVisible);
}
- void closeBrowser() {
- boolean wasVisible = webPanel.getVisibility() == View.VISIBLE;
+ private void closeBrowser() {
webPanel.setVisibility(View.GONE);
gaiaView.setVisibility(View.VISIBLE);
setHomeSurface(app.homeVisible);
syncActive();
- if (wasVisible) app.browserClosed();
}
private void back() {
@@ -383,7 +356,6 @@ public final class MainActivity extends Activity {
public void onResume() {
super.onResume();
resumed = true;
- immersive();
app.activity = this;
if (recoverGaia) {
recoverGaia = false;
@@ -475,7 +447,7 @@ public final class MainActivity extends Activity {
else callback.accept(granted);
}
- void permission(String[] names, java.util.function.Consumer callback) {
+ private void permission(String[] names, java.util.function.Consumer callback) {
boolean granted = true;
for (String name : names)
granted &= checkSelfPermission(name) == PackageManager.PERMISSION_GRANTED;
@@ -555,17 +527,12 @@ public final class MainActivity extends Activity {
"http".equals(origin.getScheme()) &&
"camera.localhost".equals(origin.getHost()) &&
origin.getPort() == 18765;
- boolean hasVideo = video != null && video.length > 0;
- boolean hasAudio = audio != null && audio.length > 0;
- if (!camera || (!hasVideo && !hasAudio)) {
+ if (!camera || (audio != null && audio.length > 0) || video == null || video.length == 0) {
callback.reject();
return;
}
- java.util.ArrayList requested = new java.util.ArrayList<>();
- if (hasVideo) requested.add(android.Manifest.permission.CAMERA);
- if (hasAudio) requested.add(android.Manifest.permission.RECORD_AUDIO);
- permission(requested.toArray(new String[0]), granted -> {
- if (granted) callback.grant(hasVideo ? video[0] : null, hasAudio ? audio[0] : null);
+ permission(new String[] { android.Manifest.permission.CAMERA }, granted -> {
+ if (granted) callback.grant(video[0], null);
else callback.reject();
});
}
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 4e44e8ea..d9baa910 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
@@ -17,32 +17,9 @@ public final class VulpesApplication extends Application {
GeckoSession gaiaSession;
AndroidServices services;
AndroidNotifications notifications;
- boolean pendingScreenLock;
boolean homeVisible;
boolean homeAtBottom;
- @Override
- public void onCreate() {
- super.onCreate();
- android.content.BroadcastReceiver receiver = new android.content.BroadcastReceiver() {
- @Override public void onReceive(android.content.Context context, android.content.Intent intent) {
- pendingScreenLock = true;
- sendScreenLock();
- }
- };
- android.content.IntentFilter filter = new android.content.IntentFilter(android.content.Intent.ACTION_SCREEN_OFF);
- if (android.os.Build.VERSION.SDK_INT >= 33) registerReceiver(receiver,filter,RECEIVER_NOT_EXPORTED);
- else registerReceiver(receiver,filter);
- }
-
- void sendScreenLock() {
- if (port == null || !pendingScreenLock) return;
- try {
- port.postMessage(new JSONObject().put("type","android-screen-off"));
- pendingScreenLock = false;
- } catch (Exception error) {Log.w("Vulpes","Screen lock pending",error);}
- }
-
void prepare() throws IOException {
if (runtime != null) return;
server = new GaiaServer(this);
@@ -81,7 +58,6 @@ public final class VulpesApplication extends Application {
@Override
public void onConnect(WebExtension.Port p) {
port = p;
- sendScreenLock();
if (activity != null) notifications.click(activity.getIntent());
p.setDelegate(
new WebExtension.PortDelegate() {
@@ -104,18 +80,6 @@ public final class VulpesApplication extends Application {
return GeckoResult.fromException(new SecurityException("EXTENSION_REQUIRED"));
}
JSONObject json = (JSONObject) message;
- if (json.optString("type").equals("marketplace.return") && activity!=null) {activity.runOnUiThread(()->activity.closeBrowser());return GeckoResult.fromValue(null);}
- if (json.optString("type").startsWith("packages.")) {
- GeckoResult result=new GeckoResult<>();
- new Thread(()->{try {
- String operation=json.getString("type");
- if(operation.equals("packages.save"))server.installed.save(json.getJSONObject("item"));
- else if(operation.equals("packages.remove"))server.installed.remove(json.getString("id"));
- else if(!operation.equals("packages.list"))throw new IOException("INVALID_OPERATION");
- result.complete(server.installed.list().toString());
- }catch(Exception error){result.completeExceptionally(error);}},"Vulpes-packages").start();
- return result;
- }
if (json.optString("type").equals("homeScroll")) {
homeAtBottom = json.optBoolean("bottom");
if (activity != null) activity.setHomeSurface(homeVisible);
@@ -150,15 +114,6 @@ public final class VulpesApplication extends Application {
);
}
- void browserClosed() {
- if (port == null) return;
- try {
- port.postMessage(new JSONObject().put("type", "browser-closed"));
- } catch (Exception e) {
- Log.w("Vulpes", "Browser close notification", e);
- }
- }
-
void home(boolean held) {
if (port == null) return;
try {
@@ -169,8 +124,6 @@ public final class VulpesApplication extends Application {
}
void resumed() {
- if (services != null) services.personal.observe();
- sendScreenLock();
if (port == null) return;
try {
port.postMessage(new JSONObject().put("type", "android-resume"));
diff --git a/android/extension/background.js b/android/extension/background.js
index e8d628e4..c08d901a 100644
--- a/android/extension/background.js
+++ b/android/extension/background.js
@@ -1,7 +1,3 @@
-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';
import { authorizePlatform } from './services/platform.mjs';
import { SettingsService } from './services/settings.mjs';
import { DataStoreService } from './services/datastores.mjs';
@@ -11,12 +7,7 @@ import { Messages } from './Messages.mjs';
import { Connections } from './Connections.mjs';
import { Alarms } from './Alarms.mjs';
const peers = new Set();
-let activities, packages;
-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, personal;
+let apps, values, settings, dataStores, contacts, messages, connections, alarms, storage;
const identity = (peer) => peer.app;
function notify(peer, type, data) {
try {
@@ -35,7 +26,7 @@ function grants(app) {
function broadcast(type, data, target) {
for (const peer of peers) {
if (target && peer.app.id !== target) continue;
- if (type === 'settingchange' && !['language.current', 'vulpes.theme'].includes(data.key) && !grants(peer.app).includes('settings.read')) continue;
+ if (type === 'settingchange' && !grants(peer.app).includes('settings.read')) continue;
if (
type === 'contactchange' &&
!peer.app.manifest.permissions?.contacts?.access?.includes('read')
@@ -54,7 +45,7 @@ function broadcast(type, data, target) {
notify(peer, type, data);
}
}
-function launch(target, entryPoint, path, reload = false) {
+function launch(target, entryPoint, path) {
path ||=
target.manifest.entry_points?.[entryPoint]?.launch_path ||
target.manifest.launch_path ||
@@ -62,7 +53,7 @@ function launch(target, entryPoint, path, reload = false) {
const url = new URL(path, target.origin);
if (url.origin !== target.origin) throw Error('INVALID_URL');
broadcast(
- reload ? 'activity-launch' : 'launch',
+ 'launch',
{
manifestURL: target.manifestURL,
origin: target.origin,
@@ -75,8 +66,6 @@ function launch(target, entryPoint, path, reload = false) {
}
const ready = (async () => {
apps = await (await fetch('registry.json')).json();
- await refreshInstalled();
- packages=new Packages({save:item=>nativePackages('save',{item}),changed:refreshInstalled});
const defaults = await (await fetch('defaults.json')).json();
const saved = await browser.storage.local.get('settings');
values = { ...defaults, ...saved.settings };
@@ -93,9 +82,6 @@ const ready = (async () => {
values['wallpaper.image.valid'] = false;
await browser.storage.local.set({ settings: values });
}
- activities = new Activities({identity,apps:()=>apps,notify,uuid:()=>crypto.randomUUID(),
- choose:data=>broadcast('activity-choice',data,'system'),
- launch:(target,path,{reload=false,entryPoint}={})=>launch(target,entryPoint,path,reload)});
let writes = Promise.resolve();
settings = new SettingsService(
{
@@ -105,12 +91,7 @@ const ready = (async () => {
const next = { ...values, ...Object.fromEntries(entries) };
await browser.storage.local.set({ settings: next });
values = next;
- for (const [key, value] of entries) {
- broadcast('settingchange', { key, value });
- if(key==='keyboard.gaia.enabled') {
- input.hide();broadcast('input-mode',{enabled:value===true});
- }
- }
+ for (const [key, value] of entries) broadcast('settingchange', { key, value });
});
writes = write.catch(() => {});
return write;
@@ -122,7 +103,6 @@ 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(
@@ -142,14 +122,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() {
- if (!systemReady || !pendingScreenLock) return;
- pendingScreenLock = false;
- broadcast('screen-off', {}, 'system');
-}
let pendingNotification;
function deliverNotification() {
if (!systemReady || !pendingNotification) return;
@@ -166,27 +139,8 @@ 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 === 'android-resume') await refreshAndroid().catch(console.warn);
if (message.type === 'notification-click') {
pendingNotification = message.data;
deliverNotification();
@@ -229,12 +183,6 @@ 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') : {};
@@ -258,7 +206,6 @@ async function handle(peer, request) {
if (method === 'android.systemReady') {
if (app.id !== 'system') throw Error('PERMISSION_DENIED');
systemReady = true;
- deliverScreenLock();
deliverNotification();
return null;
}
@@ -331,10 +278,6 @@ async function handle(peer, request) {
}
if (!Object.keys(params.values).length) return null;
}
- if (method === 'input.config') return values['keyboard.gaia.enabled'] === true;
- if (method === 'input.request') return input.request(peer,params);
- if (method === 'appearance.current') return values['vulpes.theme'] || 'light';
- if (method === 'locale.current') return values['language.current'] || 'en-US';
if (method === 'settings.snapshot') {
if (!grants(app).includes('settings.read')) throw Error('PERMISSION_DENIED');
return { ...values };
@@ -344,14 +287,6 @@ async function handle(peer, request) {
if (reply.error) throw Error(reply.error.code);
return reply.result;
}
- if(method.startsWith('marketplace.')) {
- if(app.id!=='marketplace')throw Error('PERMISSION_DENIED');
- if(method==='marketplace.poll'){const url=marketplaceDownload;marketplaceDownload=null;return url;}
- if(method==='marketplace.cancel'){packages.cancel();return true;}
- if(method==='marketplace.prepare')return packages.prepare(params.url);
- if(method==='marketplace.install'){const id=await packages.install(params.token);const installed=apps.find(a=>a.id===id);broadcast('apps-installed',installed);return installed;}
- throw Error('METHOD_NOT_SUPPORTED');
- }
if (method === 'apps.self') {
console.info('VULPES app ready', app.id);
return app;
@@ -363,10 +298,6 @@ async function handle(peer, request) {
if (!manage) throw Error('PERMISSION_DENIED');
return apps;
}
- if(method==='apps.uninstall') {
- const installed=apps.find(a=>a.id===params.id && a.installed);if(!manage || !installed)throw Error('PERMISSION_DENIED');
- await nativePackages('remove',{id:params.id});await refreshInstalled();broadcast('apps-uninstalled',installed);return true;
- }
if (method === 'apps.launch') {
const target = apps.find((a) => a.manifestURL === params.manifestURL);
if (!target || (!manage && app.id !== target.id)) throw Error('PERMISSION_DENIED');
@@ -375,8 +306,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 personal.contactRequest(app, params);
- if (method === 'messages.request') return personal.messageRequest(app, params);
+ if (method === 'contacts.request') return contacts.request(app, params);
+ if (method === 'messages.request') return messages.request(app, params);
if (method === 'alarms.request') return alarms.request(peer, app, params);
if (method === 'iac.subscribe') {
connections.subscribe(peer);
@@ -388,14 +319,9 @@ async function handle(peer, request) {
connections.close(peer, params.id);
return null;
}
- if (method === 'activities.cancel') return activities.cancel(peer);
- if (method === 'activities.select') return activities.select(peer,params);
- if (method === 'activities.subscribe') return activities.subscribe(peer);
- if (method === 'activities.finish') return activities.finish(peer,params);
- if (method === 'activities.start' && isGaiaActivity(params)) return activities.start(peer,params);
if (method === 'activities.start') {
if (params.name === 'record' && params.data?.type === 'photos') {
- if (['gallery', 'system'].includes(app.id)) {
+ if (app.id === 'gallery') {
const camera = apps.find((a) => a.id === 'camera');
if (!camera) throw Error('ACTIVITY_NOT_SUPPORTED');
launch(camera);
@@ -417,7 +343,6 @@ 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',
@@ -446,7 +371,6 @@ async function handle(peer, request) {
return null;
}
if (method === 'desktop.wakelock') return null; // Android Activity owns the visible screen.
- if (method === 'storage.stats') {if(app.id!=='settings')throw Error('PERMISSION_DENIED');return android('storage',{});}
if (method === 'media.request') return media(app, params);
throw Error('METHOD_NOT_SUPPORTED');
}
@@ -484,15 +408,6 @@ browser.runtime.onConnect.addListener((port) => {
app,
manager: { documentURI: { spec: url.href } },
sendAsyncMessage: (_, event) => notify(peer, event.type, event.data),
- edits: new Map(),
- sendQuery(_, params) {
- const id=++editSequence;
- return new Promise((resolve,reject)=>{
- const timer=setTimeout(()=>{peer.edits.delete(id);reject(Error('INPUT_CONTEXT_EXPIRED'));},5000);
- peer.edits.set(id,{resolve,reject,timer});
- port.postMessage({edit:{id,params}});
- });
- },
};
peers.add(peer);
});
@@ -500,16 +415,6 @@ browser.runtime.onConnect.addListener((port) => {
let reply;
try {
await attached;
- if(Object.hasOwn(message,'inputFocus')) {
- if(values['keyboard.gaia.enabled']===true) input.changed(peer,message.inputFocus);
- return;
- }
- if(Object.hasOwn(message,'editReply')) {
- const edit=peer.edits.get(message.editReply);
- if(edit) {clearTimeout(edit.timer);peer.edits.delete(message.editReply);
- if(message.error)edit.reject(Error(message.error));else edit.resolve(message.result);}
- return;
- }
reply = { result: await handle(peer, message.request) };
} catch (e) {
reply = { error: { code: e.message || 'HOST_ERROR' } };
@@ -523,27 +428,11 @@ 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();
if (peer.app.id === 'system' && ![...peers].some((p) => p.app.id === 'system'))
systemReady = false;
- activities.cleanup(peer);
connections.cleanup(peer);
alarms.cleanup(peer);
})
.catch(() => {});
});
});
-
-let marketplaceDownload=null;
-browser.runtime.onMessage.addListener(async (message,sender)=>{
- if(!message.marketplaceDownload)return;
- const origin=new URL(sender.url);
- if(origin.origin!=='https://vulpes-os.org' || !origin.pathname.startsWith('/marketplace/'))throw Error('PERMISSION_DENIED');
- await ready;marketplaceDownload=packageURL(message.marketplaceDownload);
- const app=apps.find(a=>a.id==='marketplace');launch(app);
- await browser.runtime.sendNativeMessage('vulpes',{type:'marketplace.return'});
- return true;
-});
diff --git a/android/extension/content.js b/android/extension/content.js
index 745fbb1e..ad150b58 100644
--- a/android/extension/content.js
+++ b/android/extension/content.js
@@ -1,6 +1,6 @@
/* Extension world: the browser provides origin/tab/frame identity, never Gaia. */
(() => {
- if (location.port !== '18765' || location.hostname.startsWith('user-')) return;
+ if (location.port !== '18765') return;
const win = window.wrappedJSObject;
console.info('VULPES Android bridge', location.origin);
let port;
@@ -25,21 +25,7 @@
});
}
function onMessage(message) {
- if (message.edit) {
- try {
- const result=editor.edit(message.edit.params);
- result.inputMode=originalModes.get(editor.inputElement) ||
- (editor.inputElement.classList.contains('recipient')?'tel':'');
- port.postMessage({editReply:message.edit.id, result});
- }
- catch (error) { port.postMessage({editReply:message.edit.id, error:String(error.message)}); }
- return;
- }
if (message.event) {
- if(message.event.type==='input-mode') {
- inputConfigurationRevision++;
- configureInput(message.event.data.enabled===true);
- }
win.dispatchEvent(
new window.CustomEvent('vulpes-service-event', { detail: cloneInto(message.event, win) }),
);
@@ -51,8 +37,6 @@
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,67 +44,12 @@
reconnect = setTimeout(() => {
try {
connect();
- refreshInputConfiguration();
} catch (_) {
onDisconnect();
}
}, 1000);
}
- const editor = new InputEditor(window);
- let gaiaKeyboard = false;
- let inputConfigurationRevision = 0;
- const originalModes = new WeakMap();
- function restoreMode() {
- const field = editor.inputElement;
- if (!field || !originalModes.has(field)) return;
- const value = originalModes.get(field);
- if (value === null) field.removeAttribute('inputmode');
- else field.setAttribute('inputmode', value);
- originalModes.delete(field);
- }
- function configureInput(enabled) {
- gaiaKeyboard = enabled;
- if(!enabled) restoreMode();
- win.VulpesNativeInput = gaiaKeyboard;
- win.dispatchEvent(new window.Event('vulpes-input-configured'));
- if(enabled && !document.hidden && document.hasFocus()) reportFocus(document.activeElement);
- }
- function refreshInputConfiguration() {
- const revision = inputConfigurationRevision;
- request({version:1,id:0,method:'input.config',params:{}}).then(reply => {
- if(revision===inputConfigurationRevision) configureInput(reply.result===true);
- }).catch(error => console.error('Gaia input configuration:', error));
- }
- 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) {
- return false;
- }
- if (element !== editor.inputElement) restoreMode();
- editor.focus(element);
- if (!originalModes.has(element)) originalModes.set(element, element.getAttribute('inputmode'));
- // Suppress Android's IME only in this editor; external web pages keep it.
- element.setAttribute('inputmode', 'none');
- 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' });
const frames = new Map();
diff --git a/android/extension/manifest.json b/android/extension/manifest.json
index 740241d3..b5ec06a8 100644
--- a/android/extension/manifest.json
+++ b/android/extension/manifest.json
@@ -1,46 +1 @@
-{
- "manifest_version": 2,
- "name": "Vulpes Android host",
- "version": "1.1",
- "browser_specific_settings": {
- "gecko": {
- "id": "android-host@vulpes-os.org"
- }
- },
- "permissions": [
- "storage",
- "unlimitedStorage",
- "nativeMessaging",
- "geckoViewAddons",
- "http://*.localhost/*",
- "https://vulpes-os.org/*"
- ],
- "background": {
- "page": "background.html",
- "persistent": true
- },
- "content_scripts": [
- {
- "matches": [
- "http://*.localhost/*"
- ],
- "js": [
- "input-editor.js",
- "content.js"
- ],
- "all_frames": true,
- "run_at": "document_start"
- },
- {
- "matches": [
- "https://vulpes-os.org/marketplace/*"
- ],
- "js": [
- "marketplace-page.js",
- "marketplace-content.js"
- ],
- "all_frames": true,
- "run_at": "document_end"
- }
- ]
-}
+{"manifest_version":2,"name":"Vulpes Android host","version":"1.1","browser_specific_settings":{"gecko":{"id":"android-host@vulpes-os.org"}},"permissions":["storage","unlimitedStorage","nativeMessaging","geckoViewAddons","http://*.localhost/*"],"background":{"page":"background.html","persistent":true},"content_scripts":[{"matches":["http://*.localhost/*"],"js":["content.js"],"all_frames":true,"run_at":"document_start"}]}
diff --git a/android/extension/marketplace-content.js b/android/extension/marketplace-content.js
deleted file mode 100644
index 496dd521..00000000
--- a/android/extension/marketplace-content.js
+++ /dev/null
@@ -1 +0,0 @@
-installMarketplacePage(window,url=>browser.runtime.sendMessage({marketplaceDownload:url}).catch(console.error));
diff --git a/android/extension/personal-data.mjs b/android/extension/personal-data.mjs
deleted file mode 100644
index b97e1c9a..00000000
--- a/android/extension/personal-data.mjs
+++ /dev/null
@@ -1,88 +0,0 @@
-import {Contacts} from './Contacts.mjs';
-import {Messages} from './Messages.mjs';
-
-export function authorizePersonal(app, kind, operation) {
- if (app.installed || !['settings', 'communications', 'sms', 'system'].includes(app.id))
- throw Error('PERMISSION_DENIED');
- if (operation === 'status' && app.id === 'settings') return;
- if (!['contacts', 'sms', 'calls'].includes(kind)) throw Error('INVALID_DATA_KIND');
- if (['permission', 'disconnect'].includes(operation)) {
- if (app.id !== 'settings') throw Error('PERMISSION_DENIED');
- return;
- }
- if (kind === 'contacts' && !app.manifest.permissions?.contacts?.access?.includes('read')) throw Error('PERMISSION_DENIED');
- if (kind === 'sms' && app.id !== 'sms') throw Error('PERMISSION_DENIED');
- if (kind === 'calls' && app.id !== 'communications') throw Error('PERMISSION_DENIED');
-}
-
-export function contactFromAndroid(row) {
- return {id:'android:'+row._id, name:[row.display_name || ''], givenName:row.givenName || [],
- familyName:row.familyName || [], tel:row.tel || [], email:row.email || [],
- category:row.starred ? ['favorite'] : [], updated:new Date(row.contact_last_updated_timestamp || 0).toISOString()};
-}
-export function smsFromAndroid(row) {
- // Negative IDs keep Android provider rows separate from existing Vulpes messages.
- if (![row._id, row.thread_id].every(n => Number.isSafeInteger(n) && n > 0)) throw Error('INVALID_ANDROID_ID');
- const incoming = row.type === 1;
- return {id:-row._id, threadId:-row.thread_id, type:'sms',
- sender:incoming ? row.address : null, receiver:incoming ? null : row.address,
- body:row.body || '', timestamp:row.date, read:!!row.read,
- delivery:incoming ? 'received' : row.type === 2 ? 'sent' : row.type === 5 ? 'error' : 'sending',
- deliveryStatus:row.status === 0 ? 'success' : row.status >= 64 ? 'error' : 'not-applicable'};
-}
-
-export class AndroidPersonalData {
- constructor(native, contacts, messages, storage) {
- Object.assign(this, {native, contacts, messages, storage});
- }
- async rows(kind) {
- const status = await this.native({operation:'status'});
- if (!status[kind]) return [];
- const rows = [];
- let after = 0;
- do {
- const page = await this.native({operation:'page', kind, after});
- rows.push(...page.rows);
- if (rows.length > 100000) throw Error('ANDROID_DATA_TOO_LARGE');
- if (page.next === null) break;
- if (!Number.isSafeInteger(page.next) || page.next <= after) throw Error('INVALID_CURSOR');
- after = page.next;
- } while (true);
- return rows;
- }
- async contactRequest(app, params) {
- const write = ['save', 'remove', 'clear'].includes(params.operation);
- const access = app.manifest.permissions?.contacts?.access || '';
- if (!access.includes(write ? 'write' : 'read')) throw Error('PERMISSION_DENIED');
- // Third-party apps keep the local address book and never inherit Android's grant.
- if (app.installed || !['communications','sms','system'].includes(app.id)) return this.contacts.request(app, params);
- if (write) {
- if (String(params.contact?.id || params.id || '').startsWith('android:') ||
- params.operation === 'clear' && (await this.native({operation:'status'})).contacts)
- throw Error('ANDROID_DATA_READ_ONLY');
- return this.contacts.request(app, params);
- }
- const remote = (await this.rows('contacts')).map(contactFromAndroid);
- const local = await this.contacts.request(app, {operation:'all'});
- const view = new Contacts(null, ()=>{});
- let revision = this.contacts.data.revision;
- for (const c of remote) for (const character of JSON.stringify(c)) revision = (revision * 31 + character.charCodeAt(0)) >>> 0;
- view.data = {revision, contacts:[...local, ...remote]};
- return view.request(app, params);
- }
- async messageRequest(app, params) {
- if (!app.manifest.permissions?.sms) throw Error('PERMISSION_DENIED');
- if (app.installed || app.id !== 'sms') return this.messages.request(app, params);
- const {operation, args=[]} = params;
- if (['delete','markMessageRead'].includes(operation) && [args[0]].flat().some(id => id < 0))
- throw Error('ANDROID_DATA_READ_ONLY');
- if (!['getMessages','getThreads','getMessage'].includes(operation)) return this.messages.request(app, params);
- const remote = (await this.rows('sms')).filter(row => row.type !== 3).map(smsFromAndroid);
- const local = await this.messages.request(app, {operation:'getMessages'});
- const view = new Messages({run:async(_store, op)=>{
- if (op !== 'get') throw Error('ANDROID_DATA_READ_ONLY');
- return {value:[...local, ...remote]};
- }});
- return view.request(app, params);
- }
-}
diff --git a/android/tests/settings-codec.test.mjs b/android/tests/settings-codec.test.mjs
index e43eec1f..ce863a0b 100644
--- a/android/tests/settings-codec.test.mjs
+++ b/android/tests/settings-codec.test.mjs
@@ -5,10 +5,10 @@ import { SettingsService } from '../../services/settings.mjs';
const context = vm.createContext({ window: {}, Blob, Uint8Array, btoa, atob });
vm.runInContext(
- await readFile(new URL('../../host/settings-codec.js', import.meta.url), 'utf8'),
+ await readFile(new URL('../ui/settings-codec.js', import.meta.url), 'utf8'),
context,
);
-const { encode, decode } = context.window.VulpesSettingsCodec;
+const { encode, decode } = context.window.VulpesAndroidSettingsCodec;
// A binary wallpaper larger than the original 1 MiB JSON ceiling, including
// every byte value. Exercise the actual service, JSON storage and observer path.
const pixels = Uint8Array.from({ length: 1200000 }, (_, i) => i % 256);
diff --git a/android/tools/check-camera.py b/android/tools/check-camera.py
index a6da92f7..636d32ce 100644
--- a/android/tools/check-camera.py
+++ b/android/tools/check-camera.py
@@ -125,11 +125,11 @@ result["camera_resumes_from_android_background"] = wait(
"camera",
'!document.hidden && !!app.camera.mozCamera && app.camera.mozCamera!==__streamBefore && app.camera.mozCamera.getVideoTracks().every(t=>t.readyState==="live") && !app.camera.isBusy',
)
-result["capability_controls"] = ev(
+result["unavailable_controls"] = ev(
"camera",
- 'document.querySelector(".js-flash").getAttribute("aria-disabled")==="true" && (document.querySelector(".mode-switch").getAttribute("aria-disabled")!=="true") === !!Object.keys(app.camera.mozCamera.capabilities.recorderProfiles).length',
+ '[".js-flash",".mode-switch"].every(s=>document.querySelector(s).getAttribute("aria-disabled")==="true")',
)
-assert result["capability_controls"]
+assert result["unavailable_controls"]
result["errors"] = ev(
"camera", '__vulpesDiagnostics.filter(d=>d.kind==="error" || d.kind==="rejection")'
)
diff --git a/android/tools/pack-assets.py b/android/tools/pack-assets.py
index 5f004300..e3af8394 100644
--- a/android/tools/pack-assets.py
+++ b/android/tools/pack-assets.py
@@ -12,7 +12,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "tools"))
-from project import SOURCE_OVERRIDES, build_info, validate_assets, release_info, application_manifest
+from project import build_info, validate_assets, release_info
validate_assets()
OUT = ROOT / "android/app/src/main/assets"
@@ -39,15 +39,24 @@ for manifest in sorted(APPS.glob("*/manifest.webapp")):
id=name,
origin=origin,
manifestURL=origin + "/manifest.webapp",
- manifest=application_manifest(name, json.loads(translate(manifest.read_text())), "--include-dev-apps" in sys.argv),
+ manifest=json.loads(translate(manifest.read_text())),
)
)
info = build_info()
release = release_info()
revision = info["revision"]
commit_time = str(info["timestamp"])
+source_overrides = {
+ "system": [
+ "js/launcher.js",
+ "js/gaia_scheduler.js",
+ "js/keyboard_manager.js",
+ "js/core.js",
+ "js/rocketbar.js",
+ ]
+}
textsuffix = {".html", ".js", ".css", ".json", ".webapp", ".properties", ".svg"}
-head = b' '
+head = b' '
if "--bridge-only" not in sys.argv or not (OUT / "gaia.zip").exists():
with zipfile.ZipFile(OUT / "gaia.zip", "w", zipfile.ZIP_DEFLATED, compresslevel=6) as z:
hashes = {}
@@ -73,7 +82,7 @@ if "--bridge-only" not in sys.argv or not (OUT / "gaia.zip").exists():
if p.is_file() and p.name != "manifest.webapp"
}
)
- for name in SOURCE_OVERRIDES.get(app["id"], []):
+ for name in source_overrides.get(app["id"], []):
files[name] = ROOT / "gaia/apps" / app["id"] / name
for name, p in files.items():
data = p.read_bytes()
@@ -111,13 +120,13 @@ if "--bridge-only" not in sys.argv or not (OUT / "gaia.zip").exists():
data = translate(data.decode("utf8")).encode()
put("_shared/" + p.relative_to(ROOT / "gaia/shared").as_posix(), data)
put("_vulpes/platform-config.js", b'window.VulpesPlatform="android";')
- for name in ["gaia-compat.js", "gaia-input.js", "gaia-services.js", "gaia-styles.js", "gaia-theme.js", "gaia-select.js", "gaia-mobile-settings.js", "gaia-theme.css", "gaia-restoration.js", "gaia-wifi.js", "gaia-bluetooth.js", "desktop.css", "platform-ui.js", "gaia-camera.js"]:
+ for name in ["gaia-compat.js", "gaia-services.js", "gaia-styles.js", "gaia-restoration.js", "gaia-wifi.js", "desktop.css", "platform-ui.js", "gaia-camera.js"]:
text = (
(ROOT / "host" / name)
.read_text()
)
put("_vulpes/" + name, translate(text).encode())
- put("_vulpes/settings-codec.js", (ROOT / "host/settings-codec.js").read_bytes())
+ put("_vulpes/settings-codec.js", (ROOT / "android/ui/settings-codec.js").read_bytes())
put("_vulpes/android-ui.js", (ROOT / "android/ui/android-ui.js").read_bytes())
put(
"_vulpes/android.css",
@@ -136,8 +145,6 @@ for name in ["Connections", "Contacts", "Messages"]:
)
(ext / f"{name}.mjs").write_text(text)
shutil.copy2(ROOT / "host/Alarms.sys.mjs", ext / "Alarms.mjs")
-(ext / "input-editor.js").write_text((ROOT / "services/input-editor.mjs").read_text().replace('export class InputEditor', 'class InputEditor'))
-shutil.copy2(ROOT / "host/marketplace-page.js", ext / "marketplace-page.js")
(ext / "release.json").write_text(json.dumps(release))
(ext / "registry.json").write_text(json.dumps(registry, ensure_ascii=False))
settings = json.loads(translate((ROOT / "assets/settings-defaults.json").read_text()))
diff --git a/android/tools/probe-android.py b/android/tools/probe-android.py
index 985d5ca5..f6591e57 100644
--- a/android/tools/probe-android.py
+++ b/android/tools/probe-android.py
@@ -3,15 +3,14 @@
import json,sys,time
from rdp import RDP
-def evaluate(host, script, connection=None):
- hostname, _, path = host.partition('/')
- r=connection or RDP()
+def evaluate(host, script):
+ r=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 && (!%s || w.location.pathname.startsWith(%s))); if(!w)return null; return w.wrappedJSObject.eval(%s); })()''' % (json.dumps(hostname+'.localhost'),json.dumps(path),json.dumps('/'+path),json.dumps(script))
+ text='''(() => { const w=[...Services.ww.getWindowEnumerator()].find(w=>w.location.hostname===%s); if(!w)return null; return w.wrappedJSObject.eval(%s); })()''' % (json.dumps(host+'.localhost'),json.dumps(script))
response=r.call(target['consoleActor'],'evaluateJSAsync',text=text)
if 'resultID' not in response: continue
while True:
@@ -21,32 +20,24 @@ def evaluate(host, script, connection=None):
if value.get('result') is not None and value.get('result') != {'type':'null'}: return value.get('result')
break
raise RuntimeError('App not open: '+host)
- finally:
- if connection is None:r.s.close()
+ finally:r.s.close()
-def _run(host,expression,timeout,connection):
+def run(host,expression,timeout=30):
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"',connection)
+ evaluate(host,'window.__androidProbe=null; Promise.resolve().then(async()=>('+expression+')).then(value=>window.__androidProbe=JSON.stringify({value}),error=>window.__androidProbe=JSON.stringify({error:String(error),stack:error.stack})); "started"')
break
- except RuntimeError as e:
+ except (RuntimeError, EOFError, OSError) as e:
if time.monotonic()>=start:raise
- if not str(e).startswith('App not open:'):raise
+ if isinstance(e, RuntimeError) and not str(e).startswith('App not open:'):raise
time.sleep(.3)
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();
-})();
+// Home layout recovery now lives in the shared host/platform-ui.js.
diff --git a/assets/webapps/camera.gaiamobile.org/js/main.js b/assets/webapps/camera.gaiamobile.org/js/main.js
index 1d04d37a..6ed05434 100644
--- a/assets/webapps/camera.gaiamobile.org/js/main.js
+++ b/assets/webapps/camera.gaiamobile.org/js/main.js
@@ -5447,9 +5447,6 @@ define('views/viewfinder', [
if (!preview) {
return;
}
- // Recompute when the embedded app becomes visible or rotates.
- this.getSize();
- if (!this.width || !this.height) { return; }
var aspect;
if (sensorAngle % 180 === 0) {
this.container = {
@@ -5700,7 +5697,6 @@ define('controllers/viewfinder', [
this.views.viewfinder.set('grid', 'off');
};
ViewfinderController.prototype.bindEvents = function () {
- window.addEventListener('resize', this.configurePreview);
this.views.viewfinder.on('fadedin', this.app.firer('viewfinder:visible'));
this.views.viewfinder.on('fadedout', this.app.firer('viewfinder:hidden'));
this.views.viewfinder.on('click', this.app.firer('viewfinder:click'));
diff --git a/assets/webapps/communications.gaiamobile.org/contacts/js/activities.js b/assets/webapps/communications.gaiamobile.org/contacts/js/activities.js
index 84e333b1..12cb2a1f 100644
--- a/assets/webapps/communications.gaiamobile.org/contacts/js/activities.js
+++ b/assets/webapps/communications.gaiamobile.org/contacts/js/activities.js
@@ -19,7 +19,7 @@ const VCARD_MIME_TYPES = [
var ActivityHandler = {
_currentActivity: null,
- _launchedAsInlineActivity: new URLSearchParams(window.location.search).has('pick'),
+ _launchedAsInlineActivity: (window.location.search == '?pick'),
mozContactParam: null,
@@ -124,7 +124,6 @@ var ActivityHandler = {
this.launch_activity(activity, 'add-parameters');
break;
case 'pick':
- if (window.VulpesCompat) this._launchedAsInlineActivity = true;
if (!this._launchedAsInlineActivity) {
return;
}
@@ -357,18 +356,15 @@ var ActivityHandler = {
postNewSuccess: function ah_postNewSuccess(contact) {
this._currentActivity.postResult({ contact: contact });
this._currentActivity = null;
- if (window.VulpesCompat) this._launchedAsInlineActivity = false;
},
postPickSuccess: function ah_postPickSuccess(result) {
this._currentActivity.postResult(result);
this._currentActivity = null;
- if (window.VulpesCompat) this._launchedAsInlineActivity = false;
},
postCancel: function ah_postCancel() {
this._currentActivity.postError('canceled');
this._currentActivity = null;
- if (window.VulpesCompat) this._launchedAsInlineActivity = false;
}
};
diff --git a/assets/webapps/communications.gaiamobile.org/contacts/js/contacts.js b/assets/webapps/communications.gaiamobile.org/contacts/js/contacts.js
index 4ea36df1..64dc91a5 100644
--- a/assets/webapps/communications.gaiamobile.org/contacts/js/contacts.js
+++ b/assets/webapps/communications.gaiamobile.org/contacts/js/contacts.js
@@ -655,9 +655,7 @@ var Contacts = (function() {
utils.PerformanceHelper.contentInteractive();
utils.PerformanceHelper.chromeInteractive();
window.setTimeout(Contacts && Contacts.onLocalized);
- // Vulpes embeds each app in its own permission-checked frame.
- if (window.navigator.mozSetMessageHandler &&
- (window.self == window.top || window.VulpesCompat)) {
+ if (window.navigator.mozSetMessageHandler && window.self == window.top) {
LazyLoader.load(['/shared/js/contacts/import/utilities/misc.js',
'/shared/js/contacts/import/utilities/vcard_reader.js',
'/shared/js/contacts/import/utilities/vcard_parser.js'],
diff --git a/assets/webapps/communications.gaiamobile.org/contacts/js/views/form.js b/assets/webapps/communications.gaiamobile.org/contacts/js/views/form.js
index ef5e805c..865eead5 100644
--- a/assets/webapps/communications.gaiamobile.org/contacts/js/views/form.js
+++ b/assets/webapps/communications.gaiamobile.org/contacts/js/views/form.js
@@ -1038,8 +1038,6 @@ contacts.Form = (function() {
// (Bug 1041455).
if (editingContact) {
Contacts.setCurrent(contact);
- // The change event may arrive while the edit form is still visible.
- Contacts.updateContactDetail(contact.id);
}
}
);
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 9a83f306..7141e811 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=0){container.order=this.startupMetadata[entry].order;var children=this.icons.children;for(var i=0,iLen=children.length;iMarketplace
-
-
-
-
diff --git a/assets/webapps/marketplace.gaiamobile.org/manifest.webapp b/assets/webapps/marketplace.gaiamobile.org/manifest.webapp
deleted file mode 100644
index 8cded036..00000000
--- a/assets/webapps/marketplace.gaiamobile.org/manifest.webapp
+++ /dev/null
@@ -1,19 +0,0 @@
-{
- "name": "Marketplace",
- "description": "Vulpes OS application catalogue",
- "launch_path": "/index.html",
- "type": "certified",
- "icons": {
- "512": "/icon.png"
- },
- "permissions": {
- "browser": {},
- "webapps-manage": {}
- },
- "locales": {
- "fr": {
- "name": "Marketplace",
- "description": "Catalogue des applications Vulpes OS"
- }
- }
-}
diff --git a/assets/webapps/marketplace.gaiamobile.org/marketplace.css b/assets/webapps/marketplace.gaiamobile.org/marketplace.css
deleted file mode 100644
index 0125ce10..00000000
--- a/assets/webapps/marketplace.gaiamobile.org/marketplace.css
+++ /dev/null
@@ -1 +0,0 @@
-html,body{width:100%;height:100%;margin:0;background:#f4f4f4;font-family:Fira Sans,sans-serif}#catalogue{position:absolute;inset:0;width:100%;height:100%;border:0}#message{padding:3rem 2rem;text-align:center;font-size:1.8rem}#message button{display:block;width:100%;padding:1rem;margin:1rem 0;font:inherit}#open[hidden],[hidden]{display:none!important}#confirm{z-index:10}#confirm section{overflow:auto}#permissions{font-size:1.5rem}
diff --git a/assets/webapps/marketplace.gaiamobile.org/marketplace.js b/assets/webapps/marketplace.gaiamobile.org/marketplace.js
deleted file mode 100644
index b00ac828..00000000
--- a/assets/webapps/marketplace.gaiamobile.org/marketplace.js
+++ /dev/null
@@ -1,38 +0,0 @@
-(async()=>{
- const frame=document.getElementById('catalogue'),message=document.getElementById('message'),dialog=document.getElementById('confirm');
- let attempt=0,busy=false,prepared=null,installed=null,language=await VulpesCompat.call('locale.current');
- const fr=()=>/^fr(?:-|$)/i.test(language);
- const text=(f,e)=>fr()?f:e;
- 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){
- if(busy)return;const current=++attempt;busy=true;prepared=null;installed=null;document.getElementById('open').hidden=true;
- status(text('Vérification de l’application…','Checking application…'));
- try {const value=await VulpesCompat.call('marketplace.prepare',{url});if(current!==attempt)return;prepared=value;
- document.getElementById('title').textContent=prepared.name;
- document.getElementById('question').textContent=text('Installer cette application archivée ?','Install this archived application?')+' ('+(prepared.bytes/1048576).toFixed(1)+' Mio)';
- document.getElementById('permissions').textContent=text('Elle sera exécutée comme une application web, sans accès aux fonctions système. Certains anciens services peuvent ne plus fonctionner.','It will run as a web application without system access. Some old services may no longer work.');
- message.hidden=true;dialog.hidden=false;
- }catch(error){if(current!==attempt)return;status(text('Installation impossible : ','Cannot install: ')+error.message);busy=false;}
- }
- dialog.addEventListener('submit',async event=>{event.preventDefault();if(!prepared)return;const token=prepared.token;prepared=null;dialog.hidden=true;status(text('Installation…','Installing…'));
- try {installed=await VulpesCompat.call('marketplace.install',{token});status(text('Application ajoutée à l’accueil.','Application added to Home.'));document.getElementById('open').hidden=false;}
- catch(error){status(text('Installation impossible : ','Cannot install: ')+error.message);}finally{busy=false;}
- });
- const cancel=()=>{attempt++;prepared=null;busy=false;VulpesCompat.call('marketplace.cancel').catch(console.error);browse();};
- document.getElementById('cancel').onclick=cancel;
- document.getElementById('browse').onclick=cancel;
- document.getElementById('open').onclick=()=>installed && VulpesCompat.call('apps.launch',{manifestURL:installed.manifestURL});
- addEventListener('languagechange',()=>{language=navigator.language;if(!busy)browse();});
- browse();
- setInterval(async()=>{if(busy)return;try{const url=await VulpesCompat.call('marketplace.poll');if(url)await prepare(url);}catch(error){console.error(error);}},750);
-})();
diff --git a/assets/webapps/ringtones.gaiamobile.org/gaia_build_defer_manage.js b/assets/webapps/ringtones.gaiamobile.org/gaia_build_defer_manage.js
index 3ec99130..ecc52c01 100644
--- a/assets/webapps/ringtones.gaiamobile.org/gaia_build_defer_manage.js
+++ b/assets/webapps/ringtones.gaiamobile.org/gaia_build_defer_manage.js
@@ -471,7 +471,7 @@ this.continue();}else{resolve(results);}};cursor.onerror=function(){if(this.erro
return{remove:remove,get:get,list:list};})();;'use strict';function TonePlayer(){this._currentTone=null;this._isValid=true;this._player=new Audio();this._player.addEventListener('loadedmetadata',function(){if(this._player.src){this._isValid=true;this._player.dispatchEvent(new CustomEvent('validated',{detail:this._isValid}));}}.bind(this));this._player.addEventListener('error',function(){if(this._player.src){this._isValid=false;this._player.dispatchEvent(new CustomEvent('validated',{detail:this._isValid}));}}.bind(this));this._player.addEventListener('ended',function(){this._firePlayingCallback(false);}.bind(this));window.addEventListener('visibilitychange',function(){if(document.hidden){this.stop();this._setExclusiveMode(false);}}.bind(this));}
TonePlayer.prototype={setTone:function(tone,callback){if(tone!==this._currentTone){this._firePlayingCallback(false);this._currentTone=tone;this._playingCallback=callback;if(tone&&tone.url){this._isValid=undefined;this._player.src=tone.url;this._player.play();this._firePlayingCallback(true);this._setExclusiveMode(true);}else{this._isValid=true;this._player.removeAttribute('src');this._player.load();}}else{if(!this._isValid){return;}
if(this._player.paused||this._player.ended){this._player.currentTime=0;this._player.play();this._firePlayingCallback(true);this._setExclusiveMode(true);}else{this._player.pause();this._firePlayingCallback(false);}}},stop:function(){this._player.pause();this._firePlayingCallback(false);},get currentTone(){return this._currentTone;},isValid:function(callback){if(this._isValid!==undefined){callback(this._isValid);return;}
-this._player.addEventListener('validated',function validated(event){this.removeEventListener('validated',validated);callback(event.detail);});},_firePlayingCallback:function(playing){if(this._playingCallback){this._playingCallback(playing);}},_setExclusiveMode:function(exclusive){if(exclusive){if(!this._source){this._context=new AudioContext();this._source=this._context.createMediaElementSource(this._player);this._source.connect(this._context.destination);}}else{if(this._source){this._source.disconnect();this._context=null;this._source=null;}}}};;'use strict';var ToneList=(function(){var NONE_ID='none:none';function toneCompare(a,b){if(a.id===NONE_ID&&b.id===NONE_ID){return 0;}else if(a.id===NONE_ID){return-1;}else if(b.id===NONE_ID){return 1;}
+this._player.addEventListener('validated',function validated(event){this.removeEventListener('validated',validated);callback(event.detail);});},_firePlayingCallback:function(playing){if(this._playingCallback){this._playingCallback(playing);}},_setExclusiveMode:function(exclusive){if(exclusive){if(!this._source){this._context=new AudioContext('ringer');this._source=this._context.createMediaElementSource(this._player);this._source.connect(this._context.destination);}}else{if(this._source){this._source.disconnect();this._context=null;this._source=null;}}}};;'use strict';var ToneList=(function(){var NONE_ID='none:none';function toneCompare(a,b){if(a.id===NONE_ID&&b.id===NONE_ID){return 0;}else if(a.id===NONE_ID){return-1;}else if(b.id===NONE_ID){return 1;}
var aName=a.name.toLocaleLowerCase();var bName=b.name.toLocaleLowerCase();var cmp=aName.localeCompare(bName);if(cmp){return cmp;}
var aSubtitle=(a.subtitle||'').toLocaleLowerCase();var bSubtitle=(b.subtitle||'').toLocaleLowerCase();return aSubtitle.localeCompare(bSubtitle);}
function domify(htmlText){var dummyDiv=document.createElement('div');dummyDiv.innerHTML=htmlText;var element=dummyDiv.firstElementChild;return element;}
diff --git a/assets/webapps/ringtones.gaiamobile.org/gaia_build_defer_pick.js b/assets/webapps/ringtones.gaiamobile.org/gaia_build_defer_pick.js
index 504d9d47..22920cc3 100644
--- a/assets/webapps/ringtones.gaiamobile.org/gaia_build_defer_pick.js
+++ b/assets/webapps/ringtones.gaiamobile.org/gaia_build_defer_pick.js
@@ -447,7 +447,7 @@ this.continue();}else{resolve(results);}};cursor.onerror=function(){if(this.erro
return{remove:remove,get:get,list:list};})();;;'use strict';function TonePlayer(){this._currentTone=null;this._isValid=true;this._player=new Audio();this._player.addEventListener('loadedmetadata',function(){if(this._player.src){this._isValid=true;this._player.dispatchEvent(new CustomEvent('validated',{detail:this._isValid}));}}.bind(this));this._player.addEventListener('error',function(){if(this._player.src){this._isValid=false;this._player.dispatchEvent(new CustomEvent('validated',{detail:this._isValid}));}}.bind(this));this._player.addEventListener('ended',function(){this._firePlayingCallback(false);}.bind(this));window.addEventListener('visibilitychange',function(){if(document.hidden){this.stop();this._setExclusiveMode(false);}}.bind(this));}
TonePlayer.prototype={setTone:function(tone,callback){if(tone!==this._currentTone){this._firePlayingCallback(false);this._currentTone=tone;this._playingCallback=callback;if(tone&&tone.url){this._isValid=undefined;this._player.src=tone.url;this._player.play();this._firePlayingCallback(true);this._setExclusiveMode(true);}else{this._isValid=true;this._player.removeAttribute('src');this._player.load();}}else{if(!this._isValid){return;}
if(this._player.paused||this._player.ended){this._player.currentTime=0;this._player.play();this._firePlayingCallback(true);this._setExclusiveMode(true);}else{this._player.pause();this._firePlayingCallback(false);}}},stop:function(){this._player.pause();this._firePlayingCallback(false);},get currentTone(){return this._currentTone;},isValid:function(callback){if(this._isValid!==undefined){callback(this._isValid);return;}
-this._player.addEventListener('validated',function validated(event){this.removeEventListener('validated',validated);callback(event.detail);});},_firePlayingCallback:function(playing){if(this._playingCallback){this._playingCallback(playing);}},_setExclusiveMode:function(exclusive){if(exclusive){if(!this._source){this._context=new AudioContext();this._source=this._context.createMediaElementSource(this._player);this._source.connect(this._context.destination);}}else{if(this._source){this._source.disconnect();this._context=null;this._source=null;}}}};;;'use strict';var ToneList=(function(){var NONE_ID='none:none';function toneCompare(a,b){if(a.id===NONE_ID&&b.id===NONE_ID){return 0;}else if(a.id===NONE_ID){return-1;}else if(b.id===NONE_ID){return 1;}
+this._player.addEventListener('validated',function validated(event){this.removeEventListener('validated',validated);callback(event.detail);});},_firePlayingCallback:function(playing){if(this._playingCallback){this._playingCallback(playing);}},_setExclusiveMode:function(exclusive){if(exclusive){if(!this._source){this._context=new AudioContext('ringer');this._source=this._context.createMediaElementSource(this._player);this._source.connect(this._context.destination);}}else{if(this._source){this._source.disconnect();this._context=null;this._source=null;}}}};;;'use strict';var ToneList=(function(){var NONE_ID='none:none';function toneCompare(a,b){if(a.id===NONE_ID&&b.id===NONE_ID){return 0;}else if(a.id===NONE_ID){return-1;}else if(b.id===NONE_ID){return 1;}
var aName=a.name.toLocaleLowerCase();var bName=b.name.toLocaleLowerCase();var cmp=aName.localeCompare(bName);if(cmp){return cmp;}
var aSubtitle=(a.subtitle||'').toLocaleLowerCase();var bSubtitle=(b.subtitle||'').toLocaleLowerCase();return aSubtitle.localeCompare(bSubtitle);}
function domify(htmlText){var dummyDiv=document.createElement('div');dummyDiv.innerHTML=htmlText;var element=dummyDiv.firstElementChild;return element;}
diff --git a/assets/webapps/ringtones.gaiamobile.org/js/tone_player.js b/assets/webapps/ringtones.gaiamobile.org/js/tone_player.js
index dfb17d68..18cf4537 100644
--- a/assets/webapps/ringtones.gaiamobile.org/js/tone_player.js
+++ b/assets/webapps/ringtones.gaiamobile.org/js/tone_player.js
@@ -1,4 +1,4 @@
;;'use strict';function TonePlayer(){this._currentTone=null;this._isValid=true;this._player=new Audio();this._player.addEventListener('loadedmetadata',function(){if(this._player.src){this._isValid=true;this._player.dispatchEvent(new CustomEvent('validated',{detail:this._isValid}));}}.bind(this));this._player.addEventListener('error',function(){if(this._player.src){this._isValid=false;this._player.dispatchEvent(new CustomEvent('validated',{detail:this._isValid}));}}.bind(this));this._player.addEventListener('ended',function(){this._firePlayingCallback(false);}.bind(this));window.addEventListener('visibilitychange',function(){if(document.hidden){this.stop();this._setExclusiveMode(false);}}.bind(this));}
TonePlayer.prototype={setTone:function(tone,callback){if(tone!==this._currentTone){this._firePlayingCallback(false);this._currentTone=tone;this._playingCallback=callback;if(tone&&tone.url){this._isValid=undefined;this._player.src=tone.url;this._player.play();this._firePlayingCallback(true);this._setExclusiveMode(true);}else{this._isValid=true;this._player.removeAttribute('src');this._player.load();}}else{if(!this._isValid){return;}
if(this._player.paused||this._player.ended){this._player.currentTime=0;this._player.play();this._firePlayingCallback(true);this._setExclusiveMode(true);}else{this._player.pause();this._firePlayingCallback(false);}}},stop:function(){this._player.pause();this._firePlayingCallback(false);},get currentTone(){return this._currentTone;},isValid:function(callback){if(this._isValid!==undefined){callback(this._isValid);return;}
-this._player.addEventListener('validated',function validated(event){this.removeEventListener('validated',validated);callback(event.detail);});},_firePlayingCallback:function(playing){if(this._playingCallback){this._playingCallback(playing);}},_setExclusiveMode:function(exclusive){if(exclusive){if(!this._source){this._context=new AudioContext();this._source=this._context.createMediaElementSource(this._player);this._source.connect(this._context.destination);}}else{if(this._source){this._source.disconnect();this._context=null;this._source=null;}}}};
\ No newline at end of file
+this._player.addEventListener('validated',function validated(event){this.removeEventListener('validated',validated);callback(event.detail);});},_firePlayingCallback:function(playing){if(this._playingCallback){this._playingCallback(playing);}},_setExclusiveMode:function(exclusive){if(exclusive){if(!this._source){this._context=new AudioContext('ringer');this._source=this._context.createMediaElementSource(this._player);this._source.connect(this._context.destination);}}else{if(this._source){this._source.disconnect();this._context=null;this._source=null;}}}};
\ No newline at end of file
diff --git a/assets/webapps/settings.gaiamobile.org/js/panels/app_permissions_list/panel.js b/assets/webapps/settings.gaiamobile.org/js/panels/app_permissions_list/panel.js
index 773df448..86119b44 100644
--- a/assets/webapps/settings.gaiamobile.org/js/panels/app_permissions_list/panel.js
+++ b/assets/webapps/settings.gaiamobile.org/js/panels/app_permissions_list/panel.js
@@ -1,7 +1,7 @@
-define('modules/navigator/mozPermissionSettings',[],function(){'use strict';return window.navigator.mozPermissionSettings;});define('panels/app_permissions_list/app_permissions_list',['require','modules/settings_service','shared/manifest_helper','modules/apps_cache','modules/navigator/mozApps','modules/navigator/mozPermissionSettings'],function(require){'use strict';var SettingsService=require('modules/settings_service');var ManifestHelper=require('shared/manifest_helper');var AppsCache=require('modules/apps_cache');var mozApps=require('modules/navigator/mozApps');var mozPerms=require('modules/navigator/mozPermissionSettings');var PermissionsList=function pl(){this._listRoot=null;this._permissionsTable=null;this._permissionTableHaveProcessed=false;this._apps=null;this._enabled=false;};PermissionsList.prototype={init:function pl_init(listRoot){this._listRoot=listRoot;this._boundOnAppChoose=this._onAppChoose.bind(this);this._boundOnApplicationInstall=this._onApplicationInstall.bind(this);this._boundOnApplicationUninstall=this._onApplicationUninstall.bind(this);},set enabled(value){if(value!==this._enabled){this._enabled=value;if(this._enabled){this._bindEvents();}else{this._unbindEvents();}}},_bindEvents:function pl__bindEvents(){this._listRoot.addEventListener('click',this._boundOnAppChoose);AppsCache.addEventListener('oninstall',this._boundOnApplicationInstall);AppsCache.addEventListener('onuninstall',this._boundOnApplicationUninstall);},_unbindEvents:function pl__unbindEvents(){this._listRoot.removeEventListener('click',this._boundOnAppChoose);AppsCache.removeEventListener('oninstall',this._boundOnApplicationInstall);AppsCache.removeEventListener('onuninstall',this._boundOnApplicationUninstall);},setPermissionsTable:function pl_setPermissionsTable(permissionTable){this._permissionTable=permissionTable;},refresh:function pl_refresh(){var self=this;this._apps=[];if(this._permissionTableHaveProcessed){return this.loadApps();}else{return mozApps.getSelf().then(function(app){return self._initExplicitPermissionsTable(app);}).then(function(){return self.loadApps();});}},_onAppChoose:function pl__onAppChoose(evt){var target=evt.target.closest('[data-app-index]');if(target&&this._listRoot.contains(target)){SettingsService.navigate('appPermissions-details',{app:this._apps[target.dataset.appIndex],permissionsTable:this._permissionTable});}},_onApplicationInstall:function pl__onApplicationInstall(evt){var app=evt.application;this._apps.push(app);this._sortApps();this.renderList();},_onApplicationUninstall:function pl__onApplicationUninstall(evt){var app;var appIndex;this._apps.some(function findApp(anApp,index){if(anApp.origin===evt.application.origin){app=anApp;appIndex=index;return true;}
+define('modules/navigator/mozPermissionSettings',[],function(){'use strict';return window.navigator.mozPermissionSettings;});define('panels/app_permissions_list/app_permissions_list',['require','modules/settings_service','shared/manifest_helper','modules/apps_cache','modules/navigator/mozApps','modules/navigator/mozPermissionSettings'],function(require){'use strict';var SettingsService=require('modules/settings_service');var ManifestHelper=require('shared/manifest_helper');var AppsCache=require('modules/apps_cache');var mozApps=require('modules/navigator/mozApps');var mozPerms=require('modules/navigator/mozPermissionSettings');var PermissionsList=function pl(){this._listRoot=null;this._permissionsTable=null;this._permissionTableHaveProcessed=false;this._apps=null;this._enabled=false;};PermissionsList.prototype={init:function pl_init(listRoot){this._listRoot=listRoot;this._boundOnAppChoose=this._onAppChoose.bind(this);this._boundOnApplicationInstall=this._onApplicationInstall.bind(this);this._boundOnApplicationUninstall=this._onApplicationUninstall.bind(this);},set enabled(value){if(value!==this._enabled){this._enabled=value;if(this._enabled){this._bindEvents();}else{this._unbindEvents();}}},_bindEvents:function pl__bindEvents(){this._listRoot.addEventListener('click',this._boundOnAppChoose);AppsCache.addEventListener('oninstall',this._boundOnApplicationInstall);AppsCache.addEventListener('onuninstall',this._boundOnApplicationUninstall);},_unbindEvents:function pl__unbindEvents(){this._listRoot.removeEventListener('click',this._boundOnAppChoose);AppsCache.removeEventListener('oninstall',this._boundOnApplicationInstall);AppsCache.removeEventListener('onuninstall',this._boundOnApplicationUninstall);},setPermissionsTable:function pl_setPermissionsTable(permissionTable){this._permissionTable=permissionTable;},refresh:function pl_refresh(){var self=this;this._apps=[];if(this._permissionTableHaveProcessed){return this.loadApps();}else{return mozApps.getSelf().then(function(app){return self._initExplicitPermissionsTable(app);}).then(function(){return self.loadApps();});}},_onAppChoose:function pl__onAppChoose(evt){if(evt.target.dataset&&evt.target.dataset.appIndex){SettingsService.navigate('appPermissions-details',{app:this._apps[evt.target.dataset.appIndex],permissionsTable:this._permissionTable});}},_onApplicationInstall:function pl__onApplicationInstall(evt){var app=evt.application;this._apps.push(app);this._sortApps();this.renderList();},_onApplicationUninstall:function pl__onApplicationUninstall(evt){var app;var appIndex;this._apps.some(function findApp(anApp,index){if(anApp.origin===evt.application.origin){app=anApp;appIndex=index;return true;}
return false;});if(!app){return;}
-SettingsService.navigate('appPermissions');this._apps.splice(appIndex,1);this.renderList();},_sortApps:function pl__sortApps(){this._apps.sort(function alphabeticalSort(app,otherApp){var manifest=new ManifestHelper(app.manifest?app.manifest:app.updateManifest);var otherManifest=new ManifestHelper(otherApp.manifest?otherApp.manifest:otherApp.updateManifest);return manifest.name>otherManifest.name;});},_genAppItemTemplate:function pl__genAppItemTemplate(itemData){var icon=document.createElement('img');var item=document.createElement('li');var link=document.createElement('a');var span=document.createElement('span');span.textContent=itemData.name;span.dir='auto';icon.src=itemData.iconSrc;link.dataset.appIndex=itemData.index;link.href='#';link.classList.add('menu-item');link.appendChild(icon);link.appendChild(span);item.appendChild(link);return item;},renderList:function pl_renderList(){this._listRoot.innerHTML='';var listFragment=document.createDocumentFragment();this._apps.forEach(function appIterator(app,index){var manifest=new ManifestHelper(app.manifest?app.manifest:app.updateManifest);var li=this._genAppItemTemplate({name:manifest.displayName,index:index,iconSrc:this._getBestIconURL(app,manifest.icons)});listFragment.appendChild(li);}.bind(this));this._listRoot.appendChild(listFragment);},_initExplicitPermissionsTable:function pl__initExplicitPermissionsTable(app){this._currentApp=app;var table=this._permissionTable;table.explicitCertifiedPermissions=[];table.plainPermissions.forEach(function plainPermIterator(perm){if(this._isExplicitPerm(perm)){table.explicitCertifiedPermissions.push({explicitPermission:perm,permission:perm});}}.bind(this));table.composedPermissions.forEach(function permIterator(perm){table.accessModes.forEach(function modeIterator(mode){var composedPerm=perm+'-'+mode;if(this._isExplicitPerm(composedPerm)){table.explicitCertifiedPermissions.push({explicitPermission:composedPerm,permission:perm});}}.bind(this));}.bind(this));this._permissionTableHaveProcessed=true;},_isExplicitPerm:function pl_isExplicitPerm(perm){return mozPerms&&mozPerms.isExplicit(perm,this._currentApp.manifestURL,this._currentApp.origin,false);},loadApps:function pl_loadApps(){var self=this;return AppsCache.apps().then(function(apps){self._loadApps(apps);});},_loadApps:function pl__loadApps(apps){var table=this._permissionTable;apps.forEach(function(app){var manifest=app.manifest?app.manifest:app.updateManifest;if(manifest.type!='certified'){this._apps.push(app);return;}
+SettingsService.navigate('appPermissions');this._apps.splice(appIndex,1);this.renderList();},_sortApps:function pl__sortApps(){this._apps.sort(function alphabeticalSort(app,otherApp){var manifest=new ManifestHelper(app.manifest?app.manifest:app.updateManifest);var otherManifest=new ManifestHelper(otherApp.manifest?otherApp.manifest:otherApp.updateManifest);return manifest.name>otherManifest.name;});},_genAppItemTemplate:function pl__genAppItemTemplate(itemData){var icon=document.createElement('img');var item=document.createElement('li');var link=document.createElement('a');var span=document.createElement('span');span.textContent=itemData.name;span.dir='auto';icon.src=itemData.iconSrc;link.dataset.appIndex=itemData.index;link.href='#';link.classList.add('menu-item');link.appendChild(icon);link.appendChild(span);item.appendChild(link);return item;},renderList:function pl_renderList(){this._listRoot.innerHTML='';var listFragment=document.createDocumentFragment();this._apps.forEach(function appIterator(app,index){var manifest=new ManifestHelper(app.manifest?app.manifest:app.updateManifest);var li=this._genAppItemTemplate({name:manifest.displayName,index:index,iconSrc:this._getBestIconURL(app,manifest.icons)});listFragment.appendChild(li);}.bind(this));this._listRoot.appendChild(listFragment);},_initExplicitPermissionsTable:function pl__initExplicitPermissionsTable(app){this._currentApp=app;var table=this._permissionTable;table.explicitCertifiedPermissions=[];table.plainPermissions.forEach(function plainPermIterator(perm){if(this._isExplicitPerm(perm)){table.explicitCertifiedPermissions.push({explicitPermission:perm,permission:perm});}}.bind(this));table.composedPermissions.forEach(function permIterator(perm){table.accessModes.forEach(function modeIterator(mode){var composedPerm=perm+'-'+mode;if(this._isExplicitPerm(composedPerm)){table.explicitCertifiedPermissions.push({explicitPermission:composedPerm,permission:perm});}}.bind(this));}.bind(this));this._permissionTableHaveProcessed=true;},_isExplicitPerm:function pl_isExplicitPerm(perm){return mozPerms.isExplicit(perm,this._currentApp.manifestURL,this._currentApp.origin,false);},loadApps:function pl_loadApps(){var self=this;return AppsCache.apps().then(function(apps){self._loadApps(apps);});},_loadApps:function pl__loadApps(apps){var table=this._permissionTable;apps.forEach(function(app){var manifest=app.manifest?app.manifest:app.updateManifest;if(manifest.type!='certified'){this._apps.push(app);return;}
var display=table.explicitCertifiedPermissions.some(function iterator(perm){var permInfo=mozPerms.get(perm.explicitPermission,app.manifestURL,app.origin,false);return permInfo!='unknown';}.bind(this));if(display){this._apps.push(app);}}.bind(this));this._sortApps();this.renderList();},_getBestIconURL:function pl__getBestIconURL(app,icons){if(!icons||!Object.keys(icons).length){return'../style/images/default.png';}
var preferredIconSize=30*(window.devicePixelRatio||1);var preferredSize=Number.MAX_VALUE;var max=0;for(var size in icons){size=parseInt(size,10);if(size>max){max=size;}
if(size>=preferredIconSize&&size0x39)){return;}
+var key=String.fromCharCode(code);if(evt.charCode===0){if(this._passcodeBuffer.length>0){this._passcodeBuffer=this._getPasscodeBuffer('all');if(this._elements.panel.dataset.passcodeStatus==='success'){this._resetPasscodeStatus();}}}else if(this._passcodeBuffer.length<8){this._passcodeBuffer+=key;}
+this._updatePassCodeUI();this._processPasscode();},_updateHeaderAndButtonUI:function(){var headerL10nId=this._headerL10nIdMapping[this._mode];var buttonL10nId=this._submitButtonL10nIdMapping[this._mode];if(headerL10nId){this._elements.header.setAttribute('data-l10n-id',headerL10nId);}
if(buttonL10nId===''){this._elements.submitButton.disabled=true;this._elements.submitButtonText.removeAttribute('data-l10n-id');this._elements.submitButtonText.textContent='';}else if(buttonL10nId){this._elements.submitButton.disabled=false;this._elements.submitButtonText.setAttribute('data-l10n-id',buttonL10nId);}
-SettingsUtils.runHeaderFontFit(this._elements.gaiaHeader);},_updatePassCodeUI:function(){this._elements.passcodeInput.value=this._passcodeBuffer;for(var i=0;i<8;i++){if(i{if(result){mozSettings.createLock().set({'lockscreen.passcode-lock.enabled':false}).then(()=>{this._showSuccessPasscodeStatus();this.cancel();}).catch((error)=>{console.log(error);});}else{this._passcodeBuffer='';this._updatePassCodeUI();}});break;case'confirmLock':this._checkPasscode().then((result)=>{if(result){mozSettings.createLock().set({'lockscreen.enabled':false,'lockscreen.passcode-lock.enabled':false}).then(()=>{this._showSuccessPasscodeStatus();this.cancel();}).catch((error)=>{console.log(error);});}else{this._passcodeBuffer='';this._updatePassCodeUI();}});break;case'edit':this._checkPasscode().then((result)=>{if(result){this._passcodeBuffer='';this._updatePassCodeUI();this._showDialogInMode('new');}else{this._passcodeBuffer='';this._updatePassCodeUI();}});break;}}},_showDialogInMode:function(mode){this._mode=mode;this._elements.panel.dataset.mode=mode;this._hideErrorMessage();this._updatePassCodeUI();this._updateHeaderAndButtonUI();},onSubmit:function(){if(this._elements.panel.dataset.passcodeStatus!=='success'){this._showErrorMessage();this._elements.passcodeInput.focus();return Promise.reject();}else{var originalPasscode=this._getPasscodeBuffer('original');return mozSettings.createLock().set({'lockscreen.passcode-lock.enabled':true}).then(()=>{return PasscodeHelper.set(originalPasscode);});}},onCancel:function(){this._elements.passcodeInput.blur();},onBeforeShow:function(panel,options){this._mode=options.mode;if(!this._leftApp){this._showDialogInMode(this._mode);}
this._leftApp=false;},onShow:function(){this._elements.passcodeInput.focus();},onHide:function(){this._leftApp=document.hidden;if(!this._leftApp){this._passcodeBuffer='';}},_checkPasscode:function(){return PasscodeHelper.check(this._passcodeBuffer).then((result)=>{if(result){this._hideErrorMessage();}else{this._showErrorMessage();}
return result;}).catch(()=>{this._showErrorMessage();});},_showErrorMessage:function(){this._elements.panel.dataset.passcodeStatus='error';},_hideErrorMessage:function(){this._elements.panel.dataset.passcodeStatus='';},_resetPasscodeStatus:function(){this._elements.panel.dataset.passcodeStatus='';},_showSuccessPasscodeStatus:function(){this._elements.panel.dataset.passcodeStatus='success';},_getPasscodeBuffer:function(part){if(part==='original'){return this._passcodeBuffer.substring(0,4);}else if(part==='confirm'){return this._passcodeBuffer.substring(4,8);}else if(part==='all'){return this._passcodeBuffer.substring(0,this._passcodeBuffer.length-1);}else{console.log('You are using _getPasscodeBuffer wrongly, '+'please check your argument again - '+part);}}});};});
\ No newline at end of file
diff --git a/assets/webapps/verticalhome.gaiamobile.org/gaia_build_defer_index.js b/assets/webapps/verticalhome.gaiamobile.org/gaia_build_defer_index.js
index 7aa6a9b6..e56f7a77 100644
--- a/assets/webapps/verticalhome.gaiamobile.org/gaia_build_defer_index.js
+++ b/assets/webapps/verticalhome.gaiamobile.org/gaia_build_defer_index.js
@@ -399,7 +399,7 @@ function iteratorSV(value){this.applicationSource.addPreviouslyInstalledSvApp(va
loadTable(DB_SV_APP_STORE_NAME,'indexSV',iteratorSV.bind(this));loadTable(DB_ITEM_STORE,'index',iterator,finish.bind(this));function addIfUnique(item){if(!cached[item.identifier]){cached[item.identifier]=true;this._allItems.push(item);}}
function finish(){for(var i=0,iLen=collected.length;i{if(event.data.action!=='migrate'){console.error('Unhandled migration action requested:',event.data.action);return;}
this.migrate(event.data.manifestURL);};port.start();},migrate:function(manifestURL){this.migratingTo=manifestURL;console.info('verticalhome migrating to '+this.migratingTo);if(this.migrating){return;}
this.migrating=true;(datastore?Promise.resolve():navigator.getDataStores(DATASTORE_NAME).then(stores=>{datastore=stores[0];return Promise.resolve();},(e)=>{return Promise.reject(e);})).then(()=>{this.all(entries=>{this.saveMigrationData(entries,'detail',true).then(()=>{this.migrating=false;window.navigator.mozSettings.createLock().set({'homescreen.manifestURL':this.migratingTo});navigator.getDataStores(PREFS_STORE).then(stores=>{if(stores.length<1){console.error('Error opening verticalhome prefs datastore');return;}
diff --git a/assets/webapps/wallpaper.gaiamobile.org/style/wallpaper.css b/assets/webapps/wallpaper.gaiamobile.org/style/wallpaper.css
index 51e131ed..0a581104 100644
--- a/assets/webapps/wallpaper.gaiamobile.org/style/wallpaper.css
+++ b/assets/webapps/wallpaper.gaiamobile.org/style/wallpaper.css
@@ -70,6 +70,3 @@ html[dir="rtl"] .wallpaper {
margin: 0 0.5rem;
}
-
-/* Keep preview tiles aligned when source wallpapers have different ratios. */
-#wallpapers .wallpaper { aspect-ratio: 2 / 3; object-fit: cover; display: block; }
diff --git a/changelog.json b/changelog.json
deleted file mode 100644
index b99b823a..00000000
--- a/changelog.json
+++ /dev/null
@@ -1,938 +0,0 @@
-{
- "schema": 1,
- "preamble": {
- "fr": "# Journal des modifications — Vulpes OS\n\n[English](CHANGELOG.en.md)\n\nLes 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.\n",
- "en": "# Changelog — Vulpes OS\n\n[Français](CHANGELOG.md)\n\nUpdates by date and platform. Within each group: Add, Change, Fix, Note, Test. Entries describe the state at their date; a fix in development requires an updated installed version.\n"
- },
- "entries": [
- {
- "id": "2.7-preview.21",
- "text": {
- "fr": "## 14/10/2026 : 2.7-preview.21\n\n\n\n### \n\n-   : Messages des fonctions indisponibles plus lisibles en thèmes clair et sombre.\n-   : Dictionnaires supplémentaires disponibles hors ligne ; activation et reprise du clavier Gaia fiabilisées. La vérification sur les appareils concernés reste nécessaire.\n-   : Affichage du nom des contacts rétabli au premier chargement du journal.\n-   : Panneaux Stockage rétablis. Une erreur de chargement ne bloque plus les autres menus.\n\n### \n\n-   : Lecture des contacts, SMS et journaux Android dans Gaia, après autorisation. Les données locales de Vulpes sont conservées.\n-   : Retour vers Vulpes sans écran blanc persistant ; un catalogue fermé peut être rouvert.\n-   : Le rectangle blanc signalé reste à reproduire sur les appareils concernés.\n-   : Première étape en lecture seule, hors MMS. Envoi et édition des données Android dans les applications Android ; les nouveaux contacts Gaia restent locaux. Accès SMS et journal selon les autorisations de la ROM et de l’installateur. Essais sur Android 9 ; autres versions à confirmer.\n\n### \n\n-   : Les corrections communes de Gaia sont incluses dans les images complètes et les mises à jour de démarrage.\n-   : Nouvelle image d’installation construite ; démarrage et flash sur Pixel à vérifier avant publication.\n",
- "en": "## 14/10/2026 : 2.7-preview.21\n\n\n\n### \n\n-   : Unavailable-feature messages are easier to read in light and dark themes.\n-   : Contact names now appear when the call history first loads.\n-   : Additional dictionaries available offline; improved Gaia keyboard activation and recovery. Verification on affected devices is still needed.\n-   : Storage panels restored. A loading error no longer locks the other menus.\n\n### \n\n-   : Read Android contacts, SMS and call history in Gaia after granting access. Existing local Vulpes data is preserved.\n-   : Return to Vulpes without a persistent white overlay; a closed catalogue can be reopened.\n-   : The reported blank keyboard still needs reproduction on the affected devices.\n-   : First, read-only integration, excluding MMS. Sending and editing Android records use Android apps; new Gaia contacts stay local. SMS and call log access depend on ROM and installer permissions. Tested on Android 9; other versions need verification.\n\n### \n\n-   : Shared Gaia fixes are included in full installation images and boot updates.\n-   : New installation image built; Pixel boot and flashing must be tested before publication.\n"
- },
- "metadata": {
- "kind": "version",
- "status": "published",
- "platforms": [
- "pc",
- "apk",
- "models"
- ],
- "models": [
- "sargo"
- ]
- }
- },
- {
- "id": "2.7-preview.20",
- "text": {
- "fr": "## 13/10/2026 : 2.7-preview.20\n\n\n\n### \n\n-   : Mode vidéo, avec miniature et lecture dans Galerie et Vidéo.\n-   : Améliorations des paramètres, de l’apparence, des sélecteurs, du clavier, des contacts et des médias.\n-   : Changement de capteur, arrêt et reprise fiabilisés.\n-   : Compte Mozilla, Sync, localisation distante, extensions et transfert USB restent incomplets.\n\n### \n\n-   : APK Preview 20 disponible.\n-   : Appels et SMS utilisent Android. Cette Preview n’a pas encore été validée en utilisation sur Android.\n\n### \n\n-   : Aperçu autour de 16 images par seconde.\n-   : Mise à jour du Wi-Fi, du Bluetooth, des appels, des SMS/MMS et des réglages réseau.\n-   : Son des vidéos et conversation téléphonique à vérifier séparément.\n-   : Preview 20 installée sur le Pixel 3a, données conservées. Wi-Fi connecté et réseau 4G détecté.\n\n### \n\n-   : Historique FR/EN simplifié, badges et filtres par plateforme. Même contenu pour le site et les releases.\n\n[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/v2.7-preview.20)\n",
- "en": "## 13/10/2026 : 2.7-preview.20\n\n\n\n### \n\n-   : Video mode, with thumbnails and playback in Gallery and Video.\n-   : Improvements to settings, appearance, pickers, keyboard, contacts and media.\n-   : More reliable camera switching, shutdown and resume.\n-   : Mozilla Account, Sync, remote location, extensions and USB transfer remain incomplete.\n\n### \n\n-   : Preview 20 APK available.\n-   : Calls and SMS use Android. This Preview has not yet been validated in use on Android.\n\n### \n\n-   : Preview runs at around 16 frames per second.\n-   : Updates to Wi-Fi, Bluetooth, calls, SMS/MMS and network settings.\n-   : Video sound and two-way call audio still need separate testing.\n-   : Preview 20 installed on Pixel 3a with data preserved. Wi-Fi connected and 4G registration observed.\n\n### \n\n-   : Simpler French and English history, with badges and platform filters. Shared content for the website and releases.\n\n[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/v2.7-preview.20)\n"
- },
- "metadata": {
- "kind": "version",
- "status": "published",
- "platforms": [
- "pc",
- "apk",
- "models",
- "web"
- ],
- "models": [
- "sargo"
- ]
- }
- },
- {
- "id": "development-2026-10-12-video-clock",
- "text": {
- "fr": "## 12/10/2026 : Vidéo et heure\n\n\n\n### \n\n-   : Enregistrement vidéo avec son, miniature et lecture dans Galerie et Vidéo.\n-   : « Améliorer Vulpes » mène aux tickets du projet.\n-   : Sélecteurs de date et d’heure rétablis ; retour du menu Clavier et texte des listes corrigés.\n\n### \n\n-   : Enregistrement vérifié avec une caméra simulée.\n\n### \n\n-   : Changements préparés pour un prochain APK ; vidéo non encore testée sur Android.\n\n### \n\n-   : Aperçu amélioré, autour de 16 images par seconde.\n-   : Heure automatique selon les informations du réseau ; fuseau horaire encore manuel.\n-   : Vidéo et changement de capteur vérifiés. Son des vidéos encore à écouter.\n",
- "en": "## 12/10/2026 : Video and time\n\n\n\n### \n\n-   : Video recording with sound, thumbnails and playback in Gallery and Video.\n-   : “Improve Vulpes” opens the project’s issues.\n-   : Date and time pickers restored; Keyboard back navigation and list text fixed.\n\n### \n\n-   : Recording checked with a simulated camera.\n\n### \n\n-   : Changes prepared for a future APK; video not yet tested on Android.\n\n### \n\n-   : Improved preview, at around 16 frames per second.\n-   : Automatic time depends on network information; time zone remains manual.\n-   : Video recording and camera switching checked. Video sound still needs listening tests.\n"
- },
- "metadata": {
- "kind": "development",
- "status": "development",
- "platforms": [
- "pc",
- "apk",
- "models"
- ],
- "models": [
- "sargo"
- ]
- }
- },
- {
- "id": "development-2026-10-12-settings-calls-hotspot",
- "text": {
- "fr": "## 12/10/2026 : Appels et partage Wi-Fi\n\n\n\n### \n\n-   : Fermeture des dialogues et nouvelle tentative après une erreur corrigées.\n\n### \n\n-   : Réglages du double appel, de l’affichage du numéro et du renvoi d’appel.\n-   : Partage de connexion avec ou sans mot de passe.\n-   : Les réglages lents ne bloquent plus les appels et les SMS.\n-   : Activation du point d’accès vérifiée ; accès Internet d’un autre appareil encore à tester.\n",
- "en": "## 12/10/2026 : Calls and hotspot\n\n\n\n### \n\n-   : Dialog closing and retry after an error fixed.\n\n### \n\n-   : Settings for call waiting, caller ID and call forwarding.\n-   : Hotspot with or without a password.\n-   : Slow settings no longer block calls and SMS.\n-   : Hotspot activation checked; Internet access from another device still needs testing.\n"
- },
- "metadata": {
- "kind": "development",
- "status": "development",
- "platforms": [
- "pc",
- "apk",
- "models"
- ],
- "models": [
- "sargo"
- ]
- }
- },
- {
- "id": "development-2026-10-12-settings-network",
- "text": {
- "fr": "## 12/10/2026 : Paramètres réseau\n\n\n\n### \n\n-   : Adresse IP et vitesse de connexion affichées correctement.\n\n### \n\n-   : Choix du type de réseau et modification des points d’accès mobiles.\n-   : Réglages réseau plus fiables lorsque le modem répond lentement.\n-   : Ajout, suppression et réinitialisation des points d’accès encore incomplets.\n",
- "en": "## 12/10/2026 : Network settings\n\n\n\n### \n\n-   : IP address and connection speed displayed correctly.\n\n### \n\n-   : Network type selection and mobile access point editing.\n-   : More reliable network settings when the modem responds slowly.\n-   : Adding, deleting and resetting access points remain incomplete.\n"
- },
- "metadata": {
- "kind": "development",
- "status": "development",
- "platforms": [
- "pc",
- "apk",
- "models"
- ],
- "models": [
- "sargo"
- ]
- }
- },
- {
- "id": "development-2026-10-12-settings-audit",
- "text": {
- "fr": "## 12/10/2026 : Revue des paramètres\n\n\n\n### \n\n-   : Navigation, accessibilité et aide corrigées. Les panneaux incomplets restent visibles.\n-   : Menus parcourus ; certaines actions dépendent encore du matériel ou de l’opérateur.\n\n### \n\n-   : Informations de stockage et téléchargements raccordés.\n\n### \n\n-   : Affichage de l’espace utilisé par Vulpes.\n\n### \n\n-   : Données mobiles, itinérance, heure, réseaux Wi-Fi masqués et panneau Son corrigés.\n",
- "en": "## 12/10/2026 : Settings review\n\n\n\n### \n\n-   : Navigation, accessibility and help fixed. Incomplete panels remain visible.\n-   : Menus reviewed; some actions still depend on hardware or the carrier.\n\n### \n\n-   : Storage information and downloads connected.\n\n### \n\n-   : Display of storage used by Vulpes.\n\n### \n\n-   : Mobile data, roaming, time, hidden Wi-Fi networks and Sound panel fixed.\n"
- },
- "metadata": {
- "kind": "development",
- "status": "development",
- "platforms": [
- "pc",
- "apk",
- "models"
- ],
- "models": [
- "sargo"
- ]
- }
- },
- {
- "id": "development-2026-10-12-settings-navigation",
- "text": {
- "fr": "## 12/10/2026 : Navigation des paramètres\n\n\n\n### \n\n-   : Choix Clair/Sombre et validation ou annulation des sélecteurs rétablis.\n-   : Gestionnaire adapté aux téléphones à une seule SIM.\n\n### \n\n-   : Informations SIM, centre de messagerie et accusés de réception.\n-   : Chargement plus rapide des réseaux enregistrés.\n-   : Certificats Wi-Fi, sécurité SIM et stockage USB encore incomplets.\n",
- "en": "## 12/10/2026 : Settings navigation\n\n\n\n### \n\n-   : Light/Dark selection and picker confirmation or cancellation restored.\n-   : Manager adapted to single-SIM phones.\n\n### \n\n-   : SIM information, message centre and delivery reports.\n-   : Faster loading of saved networks.\n-   : Wi-Fi certificates, SIM security and USB storage remain incomplete.\n"
- },
- "metadata": {
- "kind": "development",
- "status": "development",
- "platforms": [
- "pc",
- "apk",
- "models"
- ],
- "models": [
- "sargo"
- ]
- }
- },
- {
- "id": "development-2026-10-12-sargo-appearance",
- "text": {
- "fr": "## 12/10/2026 : Apparence sur Pixel 3a\n\n\n\n### \n\n-   : Modes clair et sombre communs disponibles sur le Pixel 3a.\n-   : Correctifs des paramètres et polices Firefox OS intégrés.\n-   : Pas de nouvel essai des appels, SMS ou Bluetooth à cette étape.\n-   : Thèmes et affichage vérifiés sur le téléphone.\n",
- "en": "## 12/10/2026 : Appearance on Pixel 3a\n\n\n\n### \n\n-   : Shared light and dark modes available on Pixel 3a.\n-   : Settings fixes and Firefox OS fonts included.\n-   : No new call, SMS or Bluetooth test at this stage.\n-   : Themes and display checked on the phone.\n"
- },
- "metadata": {
- "kind": "development",
- "status": "development",
- "platforms": [
- "models"
- ],
- "models": [
- "sargo"
- ]
- }
- },
- {
- "id": "development-2026-10-11-appearance-input",
- "text": {
- "fr": "## 11/10/2026 : Apparence et clavier\n\n\n\n### \n\n-   : Modes Clair, Sombre et Système, conservés au redémarrage.\n-   : Sélection et suppression de texte corrigées.\n\n### \n\n-   : Thèmes et saisie de SMS vérifiés.\n\n### \n\n-   : Clavier Gaia expérimental ; le clavier Android reste proposé par défaut.\n-   : Nouvelle tentative possible quand la caméra est occupée.\n-   : Preview 19 construite ; thèmes, clavier et caméra avant encore à vérifier sur Android.\n",
- "en": "## 11/10/2026 : Appearance and keyboard\n\n\n\n### \n\n-   : Light, Dark and System modes, preserved after restart.\n-   : Text selection and deletion fixed.\n\n### \n\n-   : Themes and SMS typing checked.\n\n### \n\n-   : Experimental Gaia keyboard; Android keyboard remains the default.\n-   : Retry available when the camera is busy.\n-   : Preview 19 built; themes, keyboard and front camera still need testing on Android.\n"
- },
- "metadata": {
- "kind": "development",
- "status": "development",
- "platforms": [
- "pc",
- "apk",
- "models"
- ],
- "models": [
- "sargo"
- ]
- }
- },
- {
- "id": "development-2026-10-11-settings",
- "text": {
- "fr": "## 11/10/2026 : Paramètres et accueil\n\n\n\n### \n\n-   : Second écran d’accueil, aperçus de fonds d’écran et bandeau d’installation corrigés.\n-   : Fermeture du message d’erreur rétablie.\n-   : Sonneries, code de verrouillage, permissions et libellés FR/EN corrigés.\n\n### \n\n-   : Ouverture du volet en touchant la barre de statut.\n-   : Preview 18 construite localement.\n\n### \n\n-   : Retour à la version précédente après un redémarrage instable de l’interface.\n-   : Fonctionnement confirmé par l’utilisateur.\n",
- "en": "## 11/10/2026 : Settings and home screen\n\n\n\n### \n\n-   : Second home screen, wallpaper previews and installation banner fixed.\n-   : Error message dismissal restored.\n-   : Ringtones, lock code, permissions and French/English labels fixed.\n\n### \n\n-   : Open the notification tray by tapping the status bar.\n-   : Preview 18 built locally.\n\n### \n\n-   : Returned to the previous version after an unstable interface restart.\n-   : Operation confirmed by the user.\n"
- },
- "metadata": {
- "kind": "development",
- "status": "development",
- "platforms": [
- "pc",
- "apk",
- "models"
- ],
- "models": [
- "sargo"
- ]
- }
- },
- {
- "id": "development-2026-10-09-mms-bluetooth",
- "text": {
- "fr": "## 09/10/2026 : MMS et Bluetooth\n\n\n\n### \n\n-   : Panneaux Gaia reliés aux capacités de chaque plateforme.\n\n### \n\n-   : Recherche, association, connexion, oubli d’appareils et sortie audio.\n-   : Envoi conservé et repris après une interruption.\n-   : MMS accepté par l’opérateur, réception non confirmée. Audio d’appel sur casque à tester.\n-   : Lecture audio Bluetooth vers un PC vérifiée.\n",
- "en": "## 09/10/2026 : MMS and Bluetooth\n\n\n\n### \n\n-   : Gaia panels connected to each platform’s capabilities.\n\n### \n\n-   : Discovery, pairing, connection, forgetting devices and audio output.\n-   : Sending preserved and resumed after interruption.\n-   : MMS accepted by the carrier, delivery not confirmed. Headset call audio still needs testing.\n-   : Bluetooth audio playback to a PC checked.\n"
- },
- "metadata": {
- "kind": "development",
- "status": "development",
- "platforms": [
- "pc",
- "apk",
- "models"
- ],
- "models": [
- "sargo"
- ]
- }
- },
- {
- "id": "development-2026-10-08-camera-mms",
- "text": {
- "fr": "## 08/10/2026 : Caméra et MMS\n\n\n\n### \n\n-   : Changement de capteur, arrêt et affichage après rotation corrigés.\n\n### \n\n-   : Pièces jointes conservées et envoi affiché en attente jusqu’à confirmation.\n\n### \n\n-   : Aperçu mesuré autour de 16 images/s à l’arrière et 25 à 27 à l’avant.\n-   : Envoi réel encore bloqué par la connexion aux données mobiles.\n-   : Mise au point, zoom et prise de photo vérifiés.\n",
- "en": "## 08/10/2026 : Camera and MMS\n\n\n\n### \n\n-   : Camera switching, shutdown and display after rotation fixed.\n\n### \n\n-   : Attachments preserved and sending shown as pending until confirmation.\n\n### \n\n-   : Preview measured at around 16 fps on the rear camera and 25–27 on the front.\n-   : Real sending still blocked by the mobile data connection.\n-   : Focus, zoom and photo capture checked.\n"
- },
- "metadata": {
- "kind": "development",
- "status": "development",
- "platforms": [
- "pc",
- "apk",
- "models"
- ],
- "models": [
- "sargo"
- ]
- }
- },
- {
- "id": "development-2026-10-08-call-log-dialogs",
- "text": {
- "fr": "## 08/10/2026 : Journal d’appel et dialogues\n\n### \n\n-   : Messages d’erreur lisibles, avec des boutons utilisables.\n\n### \n\n-   : Message clair si l’envoi est indisponible, sans perdre le brouillon.\n-   : Rappel depuis le journal d’appel rétabli.\n\n### \n\n-   : Parcours vérifié avec des appels simulés ; audio réel non testé.\n",
- "en": "## 08/10/2026 : Call history and dialogs\n\n### \n\n-   : Readable error messages with usable buttons.\n\n### \n\n-   : Clear message when sending is unavailable, without losing the draft.\n-   : Calling back from call history restored.\n\n### \n\n-   : Flow checked with simulated calls; real audio not tested.\n"
- },
- "metadata": {
- "kind": "history",
- "status": "history",
- "platforms": [
- "pc",
- "apk",
- "models"
- ],
- "models": [
- "sargo"
- ]
- }
- },
- {
- "id": "development-2026-10-08-sms-audio",
- "text": {
- "fr": "## 08/10/2026 : SMS et audio\n\n\n\n### \n\n-   : Affichage du volume, verrouillage et balayage des notifications corrigés.\n-   : Envoi en un appui, sans fermer d’abord le clavier. Menu des pièces jointes rétabli.\n\n### \n\n-   : Corrections du routage du son des appels.\n-   : Le réseau a empêché de vérifier une conversation réelle ; son dans les deux sens non confirmé.\n",
- "en": "## 08/10/2026 : SMS and audio\n\n\n\n### \n\n-   : Volume display, lock screen and notification swiping fixed.\n-   : Send with one tap without first closing the keyboard. Attachment menu restored.\n\n### \n\n-   : Call audio routing fixes.\n-   : Network issues prevented testing a real conversation; two-way audio not confirmed.\n"
- },
- "metadata": {
- "kind": "development",
- "status": "development",
- "platforms": [
- "pc",
- "apk",
- "models"
- ],
- "models": [
- "sargo"
- ]
- }
- },
- {
- "id": "development-2026-10-08-radio-reliability",
- "text": {
- "fr": "## 08/10/2026 : Fiabilité du réseau\n\n\n\n### \n\n-   : Messages associés à la bonne conversation.\n\n### \n\n-   : Récupération du modem améliorée ; les nouvelles tentatives évitent de doubler appels et SMS.\n-   : Stabilité du réseau et audio dans les deux sens encore à confirmer.\n-   : Retour sur le réseau 4G après redémarrage du modem vérifié.\n",
- "en": "## 08/10/2026 : Network reliability\n\n\n\n### \n\n-   : Messages linked to the correct conversation.\n\n### \n\n-   : Improved modem recovery; retries avoid duplicate calls and SMS.\n-   : Network stability and two-way audio still need confirmation.\n-   : Return to the 4G network after modem restart checked.\n"
- },
- "metadata": {
- "kind": "development",
- "status": "development",
- "platforms": [
- "pc",
- "apk",
- "models"
- ],
- "models": [
- "sargo"
- ]
- }
- },
- {
- "id": "2026-10-08-source-publication",
- "text": {
- "fr": "## 08/10/2026 : Sources publiques\n\n### \n\n-   : Publication des corrections caméra, contacts, SMS et Marketplace, avec présentations FR/EN à jour.\n\n### \n\n-   : Construction depuis une copie neuve du dépôt corrigée.\n-   : L’APK public reste en Preview 17 ; ces corrections sont dans les sources.\n\n### \n\n-   : Recherche de réseau encore instable à cette étape ; pas de nouvelle image publiée.\n",
- "en": "## 08/10/2026 : Public source code\n\n### \n\n-   : Camera, contacts, SMS and Marketplace fixes published, with updated French/English project overviews.\n\n### \n\n-   : Building from a fresh repository copy fixed.\n-   : The public APK remains Preview 17; these fixes are in the source code.\n\n### \n\n-   : Network search still unstable at this stage; no new image published.\n"
- },
- "metadata": {
- "kind": "history",
- "status": "history",
- "platforms": [
- "pc",
- "apk",
- "models"
- ],
- "models": [
- "sargo"
- ]
- }
- },
- {
- "id": "development-2026-10-08-camera-lifecycle",
- "text": {
- "fr": "## 08/10/2026 : Caméra et contacts\n\n\n\n### \n\n-   : Ouverture et changement de capteur fiabilisés.\n-   : Photos, dates, modifications et choix d’un numéro depuis SMS ou Téléphone corrigés.\n-   : Sélection des médias corrigée.\n-   : Correctifs dans les sources ; APK public inchangé à cette étape.\n\n### \n\n-   : Numéro et texte transmis correctement à l’application Android.\n\n### \n\n-   : Recherche disponible ; association et usages audio encore à vérifier.\n-   : Photo, mise au point, zoom et changements de capteur vérifiés.\n",
- "en": "## 08/10/2026 : Camera and contacts\n\n\n\n### \n\n-   : More reliable opening and camera switching.\n-   : Photos, dates, edits and number selection from Messages or Phone fixed.\n-   : Media selection fixed.\n-   : Fixes in source code; public APK unchanged at this stage.\n\n### \n\n-   : Number and text passed correctly to the Android app.\n\n### \n\n-   : Discovery available; pairing and audio use still need testing.\n-   : Photo capture, focus, zoom and camera switching checked.\n"
- },
- "metadata": {
- "kind": "history",
- "status": "history",
- "platforms": [
- "pc",
- "apk",
- "models"
- ],
- "models": [
- "sargo"
- ]
- }
- },
- {
- "id": "development-2026-10-06-marketplace-theme",
- "text": {
- "fr": "## 06/10/2026 : Thème Marketplace\n\n\n\n### \n\n-   : Intégration prévue dans un prochain APK.\n\n### \n\n-   : Thème intégré : police Fira, navigation compacte, boutons tactiles et captures défilantes.\n-   : Affichage intégré conservé pendant la recherche, les filtres et les changements de langue.\n-   : Thème préparé localement, pas encore déployé sur le site.\n-   : Pages françaises et anglaises vérifiées sur petits écrans.\n",
- "en": "## 06/10/2026 : Marketplace theme\n\n\n\n### \n\n-   : Integration planned for a future APK.\n\n### \n\n-   : Embedded theme: Fira font, compact navigation, touch buttons and scrollable screenshots.\n-   : Embedded display preserved during search, filtering and language changes.\n-   : Theme prepared locally, not yet deployed to the website.\n-   : French and English pages checked on small screens.\n"
- },
- "metadata": {
- "kind": "history",
- "status": "history",
- "platforms": [
- "apk",
- "web"
- ],
- "models": []
- }
- },
- {
- "id": "development-2026-10-06-system-banner",
- "text": {
- "fr": "## 06/10/2026 : Bandeaux de notification\n\n\n\n### \n\n-   : Le bandeau « Application installée » disparaît automatiquement et se remplace correctement.\n\n### \n\n-   : Affichage et fermeture des bandeaux vérifiés.\n\n### \n\n-   : Correctif préparé pour le prochain APK.\n\n### \n\n-   : Disparition du bandeau après cinq secondes vérifiée.\n",
- "en": "## 06/10/2026 : Notification banners\n\n\n\n### \n\n-   : The “Application installed” banner disappears automatically and is replaced correctly.\n\n### \n\n-   : Banner display and dismissal checked.\n\n### \n\n-   : Fix prepared for the next APK.\n\n### \n\n-   : Banner disappearance after five seconds checked.\n"
- },
- "metadata": {
- "kind": "history",
- "status": "history",
- "platforms": [
- "pc",
- "apk",
- "models"
- ],
- "models": [
- "sargo"
- ]
- }
- },
- {
- "id": "development-2026-10-06-wifi",
- "text": {
- "fr": "## 06/10/2026 : Wi-Fi et paramètres\n\n\n\n### \n\n-   : Ouverture des panneaux rétablie quand une animation ne se termine pas normalement.\n\n### \n\n-   : Correctifs encore absents de l’APK public.\n\n### \n\n-   : Réseaux détectés et déconnexions affichés correctement.\n-   : Recherche, connexion et accès Internet vérifiés.\n",
- "en": "## 06/10/2026 : Wi-Fi and settings\n\n\n\n### \n\n-   : Panels open even when an animation does not finish normally.\n\n### \n\n-   : Fixes not yet included in the public APK.\n\n### \n\n-   : Detected networks and disconnections displayed correctly.\n-   : Search, connection and Internet access checked.\n"
- },
- "metadata": {
- "kind": "history",
- "status": "history",
- "platforms": [
- "pc",
- "apk",
- "models"
- ],
- "models": [
- "sargo"
- ]
- }
- },
- {
- "id": "development-2026-10-06-marketplace-views",
- "text": {
- "fr": "## 06/10/2026 : Navigation Marketplace\n\n\n\n### \n\n-   : Message local avec bouton Réessayer si le catalogue est inaccessible.\n-   : Le catalogue reste sous la barre de statut et se masque en revenant à l’accueil ou en le fermant.\n-   : Une ancienne erreur ne masque plus une nouvelle tentative.\n\n### \n\n-   : Navigation, erreurs, retour à l’accueil et fermeture vérifiés.\n\n### \n\n-   : Preview 17 reconstruite localement, sans nouveau test Android.\n\n### \n\n-   : Interface testée avec une page locale ; catalogue distant non vérifié sans Internet.\n",
- "en": "## 06/10/2026 : Marketplace navigation\n\n\n\n### \n\n-   : Local message with a Retry button when the catalogue is unavailable.\n-   : The catalogue stays below the status bar and hides when returning home or closing it.\n-   : An earlier error no longer hides a new attempt.\n\n### \n\n-   : Navigation, errors, returning home and closing checked.\n\n### \n\n-   : Preview 17 rebuilt locally, without a new Android test.\n\n### \n\n-   : Interface tested with a local page; remote catalogue not checked without Internet.\n"
- },
- "metadata": {
- "kind": "history",
- "status": "history",
- "platforms": [
- "pc",
- "apk",
- "models"
- ],
- "models": [
- "sargo"
- ]
- }
- },
- {
- "id": "2.7-preview.17",
- "text": {
- "fr": "## 05/10/2026 : 2.7-preview.17\n\n\n\n### \n\n-   : Installation, lancement et désinstallation d’applications web empaquetées.\n-   : Catalogue Vulpes sur l’accueil, en français et en anglais, avec son icône originale.\n-   : Applications installées séparées des applications système.\n-   : Certaines anciennes applications et API Firefox OS ne sont pas encore prises en charge.\n\n### \n\n-   : Installation et utilisation de 2048 vérifiées.\n\n### \n\n-   : 2048 installé, conservé après redémarrage et désinstallé sur un émulateur Android.\n\n### \n\n-   : Accueil vérifié ; installation d’applications encore à valider sur le Pixel.\n",
- "en": "## 05/10/2026 : 2.7-preview.17\n\n\n\n### \n\n-   : Install, launch and uninstall packaged web apps.\n-   : Vulpes catalogue on the home screen, in French and English, with its original icon.\n-   : Installed apps kept separate from system apps.\n-   : Some old Firefox OS apps and APIs are not yet supported.\n\n### \n\n-   : Installation and use of 2048 checked.\n\n### \n\n-   : 2048 installed, preserved after restart and uninstalled on an Android emulator.\n\n### \n\n-   : Home screen checked; app installation still needs validation on Pixel.\n"
- },
- "metadata": {
- "kind": "version",
- "status": "built",
- "platforms": [
- "pc",
- "apk",
- "models"
- ],
- "models": [
- "sargo"
- ]
- }
- },
- {
- "id": "2.7-preview.16",
- "text": {
- "fr": "## 05/10/2026 : 2.7-preview.16\n\n\n\n### \n\n-   : Logo Vulpes au démarrage.\n-   : Défilement horizontal involontaire supprimé ; applications de test masquées.\n-   : Choix des fonds d’écran et des sons rétabli. Langue appliquée à l’accueil et conservée au redémarrage.\n-   : APK construit, non publié ; image Pixel essayée sans installation permanente.\n\n### \n\n-   : Barres Android masquées, accessibles par balayage ; mode lanceur vérifié.\n-   : Retour après veille et depuis la caméra du verrouillage corrigé.\n\n### \n\n-   : Les anciens fichiers ne remplacent plus les mises à jour au démarrage.\n",
- "en": "## 05/10/2026 : 2.7-preview.16\n\n\n\n### \n\n-   : Vulpes startup logo.\n-   : Unwanted horizontal scrolling removed; test apps hidden.\n-   : Wallpaper and sound selection restored. Home screen language applied and preserved after restart.\n-   : APK built, not published; Pixel image tried without permanent installation.\n\n### \n\n-   : Android bars hidden and accessible by swiping; launcher mode checked.\n-   : Return from sleep and the lock screen camera fixed.\n\n### \n\n-   : Old files no longer overwrite updates at startup.\n"
- },
- "metadata": {
- "kind": "version",
- "status": "built",
- "platforms": [
- "pc",
- "apk",
- "models"
- ],
- "models": [
- "sargo"
- ]
- }
- },
- {
- "id": "2026-10-04-sargo-standalone",
- "text": {
- "fr": "## 04/10/2026 : Installation autonome\n\n\n\n### \n\n-   : Installateur avec vérification du modèle et des fichiers avant écriture.\n-   : Installation autonome de Vulpes, sans passer par Droidian.\n-   : Paquet non publié ; un seul Pixel testé. L’installation remplace les données du téléphone.\n-   : Sauvegardes vérifiées ; restauration complète encore à essayer.\n-   : Démarrages installés, conservation des réglages, caméra, clavier et notifications vérifiés.\n",
- "en": "## 04/10/2026 : Standalone installation\n\n\n\n### \n\n-   : Installer with device and file checks before writing.\n-   : Standalone Vulpes installation without going through Droidian.\n-   : Package not published; tested on one Pixel only. Installation replaces phone data.\n-   : Backups checked; full restoration still needs testing.\n-   : Installed boots, saved settings, camera, keyboard and notifications checked.\n"
- },
- "metadata": {
- "kind": "development",
- "status": "development",
- "platforms": [
- "models"
- ],
- "models": [
- "sargo"
- ]
- }
- },
- {
- "id": "2026-10-03-sargo-per-device-installation",
- "text": {
- "fr": "## 03/10/2026 : Installation par appareil\n\n\n\n### \n\n-   : Préparation adaptée à chaque Pixel, avec sauvegardes propres à l’appareil.\n-   : Mise au point parfois trop lente ; problème encore ouvert.\n-   : Installation directe depuis Android d’origine pas encore disponible.\n-   : Démarrages temporaires, conservation des réglages et retour à l’installation précédente vérifiés.\n",
- "en": "## 03/10/2026 : Per-device installation\n\n\n\n### \n\n-   : Preparation adapted to each Pixel, with device-specific backups.\n-   : Focus sometimes times out; issue still open.\n-   : Direct installation from stock Android not yet available.\n-   : Temporary boots, saved settings and return to the previous installation checked.\n"
- },
- "metadata": {
- "kind": "development",
- "status": "development",
- "platforms": [
- "models"
- ],
- "models": [
- "sargo"
- ]
- }
- },
- {
- "id": "2026-10-03-sargo-installation-tools",
- "text": {
- "fr": "## 03/10/2026 : Installation et secours\n\n\n\n### \n\n-   : Préparation des paquets et kit de secours avec sauvegarde du démarrage.\n-   : Restauration possible sans démarrer l’outil de diagnostic.\n-   : Préparation vérifiée localement ; installation sur un autre Pixel encore à finaliser.\n\n### \n\n-   : Historique commun inclus dans les prochaines publications de sources.\n",
- "en": "## 03/10/2026 : Installation and recovery\n\n\n\n### \n\n-   : Package preparation and recovery kit with a boot backup.\n-   : Restoration possible without booting the diagnostic tool.\n-   : Preparation checked locally; installation on another Pixel still needs completion.\n\n### \n\n-   : Shared history included in upcoming source releases.\n"
- },
- "metadata": {
- "kind": "development",
- "status": "development",
- "platforms": [
- "models",
- "web"
- ],
- "models": [
- "sargo"
- ]
- }
- },
- {
- "id": "2026-10-03-sargo-modem-persistence",
- "text": {
- "fr": "## 03/10/2026 : Modem et premier démarrage\n\n\n\n### \n\n-   : Récupération du modem sans redémarrer tout le téléphone.\n-   : Polices et initialisation du modem corrigées sur une installation neuve.\n-   : Redémarrages intermittents du modem encore à résoudre ; paquet public incomplet.\n-   : Caméras, focus, zoom, clavier, notifications et recherche Wi-Fi vérifiés.\n",
- "en": "## 03/10/2026 : Modem and first startup\n\n\n\n### \n\n-   : Recover the modem without restarting the whole phone.\n-   : Fonts and modem initialization fixed on a fresh installation.\n-   : Intermittent modem restarts still need fixing; public package incomplete.\n-   : Cameras, focus, zoom, keyboard, notifications and Wi-Fi search checked.\n"
- },
- "metadata": {
- "kind": "development",
- "status": "development",
- "platforms": [
- "models"
- ],
- "models": [
- "sargo"
- ]
- }
- },
- {
- "id": "2026-10-01-sargo-distribution-preparation",
- "text": {
- "fr": "## 01/10/2026 : Préparation de la distribution\n\n\n\n### \n\n-   : Préparation d’une image avec des données vierges.\n-   : Affichage des polices au premier démarrage corrigé.\n-   : Image non publiée. Appels, SMS et données mobiles encore à vérifier de bout en bout.\n-   : Redémarrage intermittent du modem identifié.\n-   : SIM, 4G, recherche Wi-Fi, caméras, clavier et notifications vérifiés en démarrage temporaire.\n",
- "en": "## 01/10/2026 : Preparing distribution\n\n\n\n### \n\n-   : Preparation of an image with clean user data.\n-   : Font display on first boot fixed.\n-   : Image not published. Calls, SMS and mobile data still need end-to-end testing.\n-   : Intermittent modem restart identified.\n-   : SIM, 4G, Wi-Fi search, cameras, keyboard and notifications checked in a temporary boot.\n"
- },
- "metadata": {
- "kind": "history",
- "status": "history",
- "platforms": [
- "models"
- ],
- "models": [
- "sargo"
- ]
- }
- },
- {
- "id": "2.7-preview.15",
- "text": {
- "fr": "## 30/09/2026 : 2.7-preview.15\n\n\n\n### \n\n-   : Première publication des sources PC, Android et Tundra, avec présentation FR/EN.\n-   : Arrêt et remplacement du flux caméra fiabilisés.\n-   : Dates et états d’envoi corrigés dans les conversations.\n\n### \n\n-   : Navigation, réglages, gestes et conservation des données vérifiés.\n\n### \n\n-   : Mise à jour, Photo, Galerie et retours d’arrière-plan vérifiés sur un émulateur Android.\n\n### \n\n-   : Sources incluses ; installation universelle encore indisponible à cette étape.\n-   : Flash, vidéo, rotation, veille et audio d’appel encore incomplets ou à vérifier.\n\n[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/v2.7-preview.15)\n",
- "en": "## 30/09/2026 : 2.7-preview.15\n\n\n\n### \n\n-   : First publication of Desktop, Android and Tundra source code, with French/English project overviews.\n-   : More reliable camera stream shutdown and replacement.\n-   : Dates and sending statuses fixed in conversations.\n\n### \n\n-   : Navigation, settings, gestures and saved data checked.\n\n### \n\n-   : Update, Camera, Gallery and returns from the background checked on an Android emulator.\n\n### \n\n-   : Source code included; universal installation still unavailable at this stage.\n-   : Flash, video, rotation, sleep and call audio still incomplete or awaiting tests.\n\n[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/v2.7-preview.15)\n"
- },
- "metadata": {
- "kind": "version",
- "status": "published",
- "platforms": [
- "pc",
- "apk",
- "models"
- ],
- "models": [
- "sargo"
- ]
- }
- },
- {
- "id": "development-2026-09-30-sms-camera-followup",
- "text": {
- "fr": "## 30/09/2026 : Retours SMS et caméra\n\n\n\n### \n\n-   : Libération du capteur et changement de caméra corrigés.\n-   : Conversations vides corrigées.\n\n### \n\n-   : Sources communes mises à jour, sans nouvel APK.\n\n### \n\n-   : Arrêt du vibreur rétabli.\n-   : Dialogue après envoi SMS et fluidité caméra encore à corriger.\n-   : Focus et zoom confirmés par l’utilisateur.\n-   : Envoi et réception confirmés par l’utilisateur ; conversations affichées.\n",
- "en": "## 30/09/2026 : SMS and camera follow-up\n\n\n\n### \n\n-   : Camera release and switching fixed.\n-   : Empty conversations fixed.\n\n### \n\n-   : Shared sources updated, without a new APK.\n\n### \n\n-   : Vibration now stops correctly.\n-   : Dialog after sending SMS and camera smoothness still need fixing.\n-   : Focus and zoom confirmed by the user.\n-   : Sending and receiving confirmed by the user; conversations displayed.\n"
- },
- "metadata": {
- "kind": "development",
- "status": "development",
- "platforms": [
- "pc",
- "apk",
- "models"
- ],
- "models": [
- "sargo"
- ]
- }
- },
- {
- "id": "development-2026-09-30-sms-camera",
- "text": {
- "fr": "## 30/09/2026 : SMS et photo\n\n\n\n### \n\n-   : Messages en échec conservés et états d’envoi mis à jour.\n\n### \n\n-   : APK Preview 14 inchangé à cette étape.\n\n### \n\n-   : Mise au point au toucher et zoom numérique. Photos jusqu’à 12 Mpx à l’arrière et 8 Mpx à l’avant.\n-   : Retour de l’aperçu après une photo corrigé.\n-   : Réception par le destinataire non encore confirmée.\n",
- "en": "## 30/09/2026 : SMS and photos\n\n\n\n### \n\n-   : Failed messages preserved and sending statuses updated.\n\n### \n\n-   : Preview 14 APK unchanged at this stage.\n\n### \n\n-   : Tap to focus and digital zoom. Photos up to 12 MP at the rear and 8 MP at the front.\n-   : Preview return after taking a photo fixed.\n-   : Delivery to the recipient not yet confirmed.\n"
- },
- "metadata": {
- "kind": "development",
- "status": "development",
- "platforms": [
- "pc",
- "apk",
- "models"
- ],
- "models": [
- "sargo"
- ]
- }
- },
- {
- "id": "2.7-preview.14",
- "text": {
- "fr": "## 30/09/2026 : 2.7-preview.14\n\n\n\n### \n\n-   : Polices, informations de version, verrouillage et notifications harmonisés.\n\n### \n\n-   : Clavier agrandi, édition de texte et saisie du numéro de destinataire améliorées.\n\n### \n\n-   : Démarrage et polices vérifiés sur un émulateur Android.\n\n### \n\n-   : Arrêt de la vibration corrigé.\n-   : Notifications conservées, ouverture de la conversation et doublons corrigés.\n-   : Réseaux enregistrés, reconnexion et oubli d’un réseau corrigés.\n-   : Contrôles audio testés hors appel ; conversation et son des notifications encore à vérifier.\n\n[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-preview.14)\n",
- "en": "## 30/09/2026 : 2.7-preview.14\n\n\n\n### \n\n-   : Fonts, version information, lock screen and notifications aligned.\n\n### \n\n-   : Larger keyboard, improved text editing and recipient number entry.\n\n### \n\n-   : Startup and fonts checked on an Android emulator.\n\n### \n\n-   : Vibration stopping fixed.\n-   : Notifications preserved, conversation opening and duplicate messages fixed.\n-   : Saved networks, reconnection and forgetting a network fixed.\n-   : Audio controls tested outside calls; conversation audio and notification sounds still need testing.\n\n[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-preview.14)\n"
- },
- "metadata": {
- "kind": "version",
- "status": "published",
- "platforms": [
- "pc",
- "apk",
- "models"
- ],
- "models": [
- "sargo"
- ]
- }
- },
- {
- "id": "2.7-preview.13",
- "text": {
- "fr": "## 27/09/2026 : 2.7-preview.13\n\n\n\n### \n\n-   : Rappels des événements corrigés, y compris après suppression.\n-   : Premier affichage et fermeture de l’éditeur corrigés.\n-   : Filtres des conversations corrigés.\n\n### \n\n-   : Photo, vidéo, calendrier et conversations vérifiés.\n\n### \n\n-   : Caméra, Galerie, Calendrier et reprise après arrière-plan vérifiés.\n\n### \n\n-   : Caméra, Wi-Fi et luminosité vérifiés sur la version installée.\n",
- "en": "## 27/09/2026 : 2.7-preview.13\n\n\n\n### \n\n-   : Event reminders fixed, including after deletion.\n-   : Initial display and closing the editor fixed.\n-   : Conversation filters fixed.\n\n### \n\n-   : Photo, video, calendar and conversations checked.\n\n### \n\n-   : Camera, Gallery, Calendar and background resume checked.\n\n### \n\n-   : Camera, Wi-Fi and brightness checked on the installed version.\n"
- },
- "metadata": {
- "kind": "version",
- "status": "built",
- "platforms": [
- "pc",
- "apk",
- "models"
- ],
- "models": [
- "sargo"
- ]
- }
- },
- {
- "id": "2.7-preview.12",
- "text": {
- "fr": "## 27/09/2026 : 2.7-preview.12\n\n\n\n### \n\n-   : Page d’accueil locale, en français ou en anglais selon la langue.\n-   : Conversations SMS et traductions du Calendrier corrigées.\n-   : Changement de capteur et zoom de l’interface corrigés ; flash grisé s’il est indisponible.\n-   : Icônes plus nettes, appui long sur Accueil et gestes corrigés.\n\n### \n\n-   : Aperçu plus fluide, autour de 23 images par seconde lors des essais.\n-   : Luminosité plus réactive et connexion Wi-Fi corrigée.\n-   : Vidéo, flash, Bluetooth et réglages d’appel avancés encore incomplets.\n",
- "en": "## 27/09/2026 : 2.7-preview.12\n\n\n\n### \n\n-   : Local start page, in French or English according to language.\n-   : SMS conversations and Calendar translations fixed.\n-   : Camera switching and interface zoom fixed; flash disabled when unavailable.\n-   : Sharper icons, long press on Home and gestures fixed.\n\n### \n\n-   : Smoother preview, at around 23 frames per second in testing.\n-   : More responsive brightness and Wi-Fi connection fixed.\n-   : Video, flash, Bluetooth and advanced call settings remain incomplete.\n"
- },
- "metadata": {
- "kind": "version",
- "status": "built",
- "platforms": [
- "pc",
- "apk",
- "models"
- ],
- "models": [
- "sargo"
- ]
- }
- },
- {
- "id": "development-2026-09-27-4",
- "text": {
- "fr": "## 27/09/2026 : Interface et matériel\n\n\n\n### \n\n-   : Première page d’accueil locale.\n-   : Animation d’extinction conservée, avec des ronds bleus.\n-   : Textes des listes et de Vidéo corrigés ; barre Accueil transparente et adaptée au paysage.\n-   : En-têtes et icônes du journal d’appel corrigés.\n\n### \n\n-   : Réglage de la luminosité raccordé.\n-   : Appels audio, vidéo, pleine résolution photo et rotation encore à vérifier.\n",
- "en": "## 27/09/2026 : Interface and hardware\n\n\n\n### \n\n-   : First local start page.\n-   : Original shutdown animation retained, with blue circles.\n-   : List and Video text fixed; transparent Home bar adapted to landscape.\n-   : Call history headers and icons fixed.\n\n### \n\n-   : Brightness adjustment connected.\n-   : Call audio, video, full-resolution photos and rotation still need testing.\n"
- },
- "metadata": {
- "kind": "development",
- "status": "development",
- "platforms": [
- "pc",
- "apk",
- "models"
- ],
- "models": [
- "sargo"
- ]
- }
- },
- {
- "id": "development-2026-09-27-3",
- "text": {
- "fr": "## 27/09/2026 : Stabilité Wi-Fi\n\n\n\n### \n\n-   : Preview 9 construite avec les corrections communes ; Wi-Fi géré par Android.\n\n### \n\n-   : Recherche des réseaux stabilisée pour éviter les blocages du téléphone.\n-   : Caméra et appels encore incomplets.\n-   : Verrouillage, polices, date, batterie et Wi-Fi vérifiés.\n",
- "en": "## 27/09/2026 : Wi-Fi stability\n\n\n\n### \n\n-   : Preview 9 built with shared fixes; Wi-Fi handled by Android.\n\n### \n\n-   : Network discovery stabilized to avoid phone freezes.\n-   : Camera and calls remain incomplete.\n-   : Lock screen, fonts, date, battery and Wi-Fi checked.\n"
- },
- "metadata": {
- "kind": "development",
- "status": "development",
- "platforms": [
- "apk",
- "models"
- ],
- "models": [
- "sargo"
- ]
- }
- },
- {
- "id": "development-2026-09-27-2",
- "text": {
- "fr": "## 27/09/2026 : Retour aux composants Gaia\n\n\n\n### \n\n-   : Remerciements, écran de démarrage et nom du navigateur mis à jour.\n-   : Panneaux et styles Gaia d’origine rétablis dans Paramètres, Suivi conso, Radio FM et les imports.\n-   : Bouton Accueil dégagé des commandes ; date, batterie et écran de verrouillage rétablis.\n\n### \n\n-   : Mode lanceur, accès aux paramètres Android et reprise après arrière-plan.\n\n### \n\n-   : Première intégration instable ; retour à la version précédente.\n",
- "en": "## 27/09/2026 : Restoring Gaia components\n\n\n\n### \n\n-   : Credits, startup screen and browser name updated.\n-   : Original Gaia panels and styles restored in Settings, Data Usage, FM Radio and imports.\n-   : Home button no longer overlaps controls; date, battery and lock screen restored.\n\n### \n\n-   : Launcher mode, access to Android settings and background resume.\n\n### \n\n-   : Initial integration unstable; returned to the previous version.\n"
- },
- "metadata": {
- "kind": "development",
- "status": "development",
- "platforms": [
- "pc",
- "apk",
- "models"
- ],
- "models": [
- "sargo"
- ]
- }
- },
- {
- "id": "development-2026-09-27-1",
- "text": {
- "fr": "## 27/09/2026 : Appels et boutons physiques\n\n\n\n### \n\n-   : Interfaces Gaia d’appel entrant et sortant, avec journal d’appel.\n-   : Polices Fira, contacts et affichage de la batterie corrigés.\n\n### \n\n-   : Prise en charge des touches Marche/Arrêt et Volume, du menu d’extinction et du réveil de l’écran.\n-   : Caméra native encore indisponible.\n-   : Sonnerie et raccrochage vérifiés ; conversation audio encore à tester.\n",
- "en": "## 27/09/2026 : Calls and hardware buttons\n\n\n\n### \n\n-   : Gaia incoming and outgoing call interfaces, with call history.\n-   : Fira fonts, contacts and battery display fixed.\n\n### \n\n-   : Support for Power and Volume keys, shutdown menu and waking the screen.\n-   : Native camera still unavailable.\n-   : Ringing and hanging up checked; conversation audio still needs testing.\n"
- },
- "metadata": {
- "kind": "development",
- "status": "development",
- "platforms": [
- "pc",
- "models"
- ],
- "models": [
- "sargo"
- ]
- }
- },
- {
- "id": "development-2026-09-26-1",
- "text": {
- "fr": "## 26/09/2026 : Démarrage et polices\n\n\n\n### \n\n-   : Polices Fira et informations de batterie corrigées ; boutons de test retirés des paramètres.\n\n### \n\n-   : Conservation des données lors du démarrage installé.\n-   : Plein écran stable, sans gestes ni ouverture automatique d’applications.\n-   : Son et micro testés séparément ; audio d’appel, caméra et veille non validés.\n",
- "en": "## 26/09/2026 : Startup and fonts\n\n\n\n### \n\n-   : Fira fonts and battery information fixed; test buttons removed from settings.\n\n### \n\n-   : Data preserved in the installed boot.\n-   : Stable fullscreen, without automatic gestures or app launches.\n-   : Sound and microphone tested separately; call audio, camera and sleep not validated.\n"
- },
- "metadata": {
- "kind": "development",
- "status": "development",
- "platforms": [
- "pc",
- "apk",
- "models"
- ],
- "models": [
- "sargo"
- ]
- }
- },
- {
- "id": "development-2026-09-25-1",
- "text": {
- "fr": "## 25/09/2026 : Accueil et premiers essais natifs\n\n\n\n### \n\n-   : Bouton rond, fond transparent sur l’accueil et bleu dans les applications.\n-   : État appuyé du bouton, dégradé, polices et informations système corrigés.\n\n### \n\n-   : Reprise de l’application modifiée dans les sources, pas encore testée sur Android.\n\n### \n\n-   : Premiers essais d’une image native avec affichage accéléré et batterie.\n-   : Essais temporaires ; de nombreuses fonctions matérielles restent à raccorder.\n",
- "en": "## 25/09/2026 : Home screen and first native trials\n\n\n\n### \n\n-   : Round button, transparent background on Home and blue background in apps.\n-   : Pressed button state, gradient, fonts and system information fixed.\n\n### \n\n-   : App resume changed in source code, not yet tested on Android.\n\n### \n\n-   : First native image trials with accelerated display and battery support.\n-   : Temporary trials; many hardware features still need connecting.\n"
- },
- "metadata": {
- "kind": "development",
- "status": "development",
- "platforms": [
- "pc",
- "apk",
- "models"
- ],
- "models": [
- "sargo"
- ]
- }
- },
- {
- "id": "2.7-preview.7",
- "text": {
- "fr": "## 2.7-preview.7\n\n\n\n### \n\n-   : Versions de Vulpes, Gaia et Gecko distinguées.\n\n### \n\n-   : Gestion des autorisations et messages d’erreur plus précis.\n\n### \n\n-   : Reprise après la première demande d’autorisation corrigée.\n",
- "en": "## 2.7-preview.7\n\n\n\n### \n\n-   : Vulpes, Gaia and Gecko versions shown separately.\n\n### \n\n-   : Improved permission handling and clearer error messages.\n\n### \n\n-   : Resume after the first permission request fixed.\n"
- },
- "metadata": {
- "kind": "version",
- "status": "built",
- "platforms": [
- "pc",
- "apk"
- ],
- "models": []
- }
- },
- {
- "id": "2.7-preview.6",
- "text": {
- "fr": "## 2.7-preview.6\n\n\n\n### \n\n-   : Application Photo Gaia reliée à la webcam.\n-   : Import de fichiers et photos disponibles dans Galerie.\n-   : Changement de capteur selon le matériel ; flash et vidéo indisponibles.\n\n### \n\n-   : Application Photo Gaia reliée aux caméras Android.\n-   : Import de fichiers et photos disponibles dans Galerie.\n-   : Changement de capteur selon le matériel ; flash et vidéo indisponibles.\n",
- "en": "## 2.7-preview.6\n\n\n\n### \n\n-   : Gaia Camera app connected to the webcam.\n-   : File import and photos available in Gallery.\n-   : Camera switching depends on hardware; flash and video unavailable.\n\n### \n\n-   : Gaia Camera app connected to Android cameras.\n-   : File import and photos available in Gallery.\n-   : Camera switching depends on hardware; flash and video unavailable.\n"
- },
- "metadata": {
- "kind": "version",
- "status": "built",
- "platforms": [
- "pc",
- "apk"
- ],
- "models": []
- }
- },
- {
- "id": "2.7-preview.5",
- "text": {
- "fr": "## 2.7-preview.5\n\n\n\n### \n\n-   : Réglages système, imports de médias et de contacts, notifications et essai webcam.\n-   : Interface et numérotation des versions alignées avec Android.\n\n### \n\n-   : Interface et numérotation des versions alignées avec le PC.\n-   : Reprise après arrière-plan et récupération de l’affichage améliorées.\n",
- "en": "## 2.7-preview.5\n\n\n\n### \n\n-   : System settings, media and contact imports, notifications and webcam test.\n-   : Interface and version numbering aligned with Android.\n\n### \n\n-   : Interface and version numbering aligned with Desktop.\n-   : Improved background resume and display recovery.\n"
- },
- "metadata": {
- "kind": "version",
- "status": "built",
- "platforms": [
- "pc",
- "apk"
- ],
- "models": []
- }
- },
- {
- "id": "2.7-156.0.2",
- "text": {
- "fr": "## 2.7-156.0.2\n\n\n\n### \n\n-   : Réglages Android, son, luminosité, imports et notifications.\n-   : Appels, SMS, horloge et caméra confiés aux applications Android.\n-   : Écrans blancs et reprise après arrière-plan corrigés.\n-   : Ancienne numérotation : ce numéro ne correspond pas à une mise à jour de Gecko.\n",
- "en": "## 2.7-156.0.2\n\n\n\n### \n\n-   : Android settings, sound, brightness, imports and notifications.\n-   : Calls, SMS, clock and camera handled by Android apps.\n-   : White screens and background resume fixed.\n-   : Historical numbering: this number does not represent a Gecko update.\n"
- },
- "metadata": {
- "kind": "version",
- "status": "built",
- "platforms": [
- "apk"
- ],
- "models": []
- }
- },
- {
- "id": "2.7-156.0.1",
- "text": {
- "fr": "## 2.7-156.0.1\n\n\n\n### \n\n-   : Nom « Vulpes OS – Preview » et icône du projet.\n-   : Clignotement du fond d’écran corrigé.\n-   : Ancienne numérotation, conservée pour retrouver cette publication.\n\n[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-156.0.1)\n",
- "en": "## 2.7-156.0.1\n\n\n\n### \n\n-   : “Vulpes OS – Preview” name and project icon.\n-   : Wallpaper flickering fixed.\n-   : Historical numbering retained to identify this release.\n\n[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-156.0.1)\n"
- },
- "metadata": {
- "kind": "version",
- "status": "published",
- "platforms": [
- "apk"
- ],
- "models": []
- }
- },
- {
- "id": "2.7-preview.2",
- "text": {
- "fr": "## 2.7-preview.2\n\n\n\n### \n\n-   : Clignotement du fond d’écran corrigé.\n\n[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-preview.2)\n",
- "en": "## 2.7-preview.2\n\n\n\n### \n\n-   : Wallpaper flickering fixed.\n\n[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-preview.2)\n"
- },
- "metadata": {
- "kind": "version",
- "status": "published",
- "platforms": [
- "apk"
- ],
- "models": []
- }
- },
- {
- "id": "2.7-preview.1",
- "text": {
- "fr": "## 2.7-preview.1\n\n\n\n### \n\n-   : Première Preview Android pour découvrir Vulpes sans flasher le téléphone.\n-   : Fond d’écran clignotant connu dans cette première version.\n\n[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-preview.1)\n",
- "en": "## 2.7-preview.1\n\n\n\n### \n\n-   : First Android Preview to try Vulpes without flashing the phone.\n-   : Known wallpaper flickering in this first version.\n\n[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-preview.1)\n"
- },
- "metadata": {
- "kind": "version",
- "status": "published",
- "platforms": [
- "apk"
- ],
- "models": []
- }
- },
- {
- "id": "history",
- "text": {
- "fr": "## Premières étapes\n\n### \n\n-   : Adaptation des contacts, messages, paramètres, alarmes et du stockage.\n-   : Création de Vulpes : identité visuelle, traductions et informations système.\n-   : Passage des anciens moteurs Firefox OS à un Gecko moderne.\n\n### \n\n-   : Premiers démarrages natifs et essais matériels sur le Pixel 3a.\n",
- "en": "## Early milestones\n\n### \n\n-   : Adaptation of contacts, messages, settings, alarms and storage.\n-   : Creation of Vulpes: visual identity, translations and system information.\n-   : Move from old Firefox OS engines to a modern Gecko.\n\n### \n\n-   : First native boots and hardware trials on Pixel 3a.\n"
- },
- "metadata": {
- "kind": "history",
- "status": "history",
- "platforms": [
- "pc",
- "models"
- ],
- "models": [
- "sargo"
- ]
- }
- }
- ],
- "releases": {
- "android-2.7-preview.14": {
- "version": "2.7-preview.14",
- "after": "2.7-156.0.1",
- "url": "https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-preview.14",
- "summary": "# Vulpes OS — 2.7-preview.14\n\n🇫🇷 Preview expérimentale. L’APK peut être installé par-dessus la Preview précédente. Les appels et SMS passent par Android. Les avancées Sargo ci-dessous sont distinctes de l’APK ; cette release ne fournit pas de nouvelle image pour le Pixel.\n\n🇬🇧 Experimental Preview. Install the APK over the previous Preview. Calls and SMS use Android. Sargo progress below is separate from the APK; this release does not provide a new Pixel image.\n\nAndroid 8 minimum · Essais sur émulateur Android. / Tested on an Android emulator.",
- "include_entry": true
- },
- "v2.7-preview.15": {
- "version": "2.7-preview.15",
- "after": "2.7-preview.14",
- "summary": "# Vulpes OS — 2.7-preview.15\n\n🇫🇷 Preview expérimentale. L’APK peut être installé par-dessus la Preview précédente. Les appels et SMS passent par Android. Les avancées Sargo ci-dessous sont distinctes de l’APK ; cette release ne fournit pas de nouvelle image pour le Pixel.\n\n🇬🇧 Experimental Preview. Install the APK over the previous Preview. Calls and SMS use Android. Sargo progress below is separate from the APK; this release does not provide a new Pixel image.\n\nAndroid 8 minimum · Essais sur émulateur Android. / Tested on an Android emulator.",
- "include_entry": true,
- "url": "https://nnsprod.com/git/oversu/VulpesOS/releases/tag/v2.7-preview.15"
- },
- "v2.7-preview.17": {
- "version": "2.7-preview.17",
- "after": "2.7-preview.15",
- "url": "https://nnsprod.com/git/oversu/VulpesOS/releases/tag/v2.7-preview.17",
- "summary": "# Vulpes OS — 2.7-preview.17\n\n🇫🇷 Preview expérimentale. L’APK peut être installé par-dessus la Preview précédente. Les appels et SMS passent par Android. Les avancées Sargo ci-dessous sont distinctes de l’APK ; cette release ne fournit pas de nouvelle image pour le Pixel.\n\n🇬🇧 Experimental Preview. Install the APK over the previous Preview. Calls and SMS use Android. Sargo progress below is separate from the APK; this release does not provide a new Pixel image.\n\nAndroid 8 minimum · Essais sur émulateur Android. / Tested on an Android emulator.",
- "include_entry": true
- },
- "v2.7-preview.20": {
- "version": "2.7-preview.20",
- "after": "2.7-preview.17",
- "url": "https://nnsprod.com/git/oversu/VulpesOS/releases/tag/v2.7-preview.20",
- "summary": "# Vulpes OS — 2.7-preview.20\n\n🇫🇷 Preview expérimentale. L’APK peut être installé par-dessus la Preview précédente. Les appels et SMS passent par Android. Les avancées Sargo ci-dessous sont distinctes de l’APK ; cette release ne fournit pas de nouvelle image pour le Pixel.\n\n🇬🇧 Experimental Preview. Install the APK over the previous Preview. Calls and SMS use Android. Sargo progress below is separate from the APK; this release does not provide a new Pixel image.\n\nAndroid 8 minimum · Exécution de cette Preview sur Android encore à valider. / Android execution of this Preview still needs validation.",
- "include_entry": true
- },
- "android-2.7-156.0.1": {
- "version": "2.7-156.0.1",
- "after": "2.7-preview.2",
- "url": "https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-156.0.1",
- "summary": "# Vulpes OS — 2.7-156.0.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
- },
- "android-2.7-preview.2": {
- "version": "2.7-preview.2",
- "after": "2.7-preview.1",
- "url": "https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-preview.2",
- "summary": "# Vulpes OS — 2.7-preview.2\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
- },
- "android-2.7-preview.1": {
- "version": "2.7-preview.1",
- "after": "history",
- "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/docs/media/vulpes-logo.png b/docs/media/vulpes-logo.png
index 1ea614d9..c83af644 100644
Binary files a/docs/media/vulpes-logo.png and b/docs/media/vulpes-logo.png differ
diff --git a/gaia/apps/camera/js/controllers/activity.js b/gaia/apps/camera/js/controllers/activity.js
index 3f167e07..9be7de3f 100755
--- a/gaia/apps/camera/js/controllers/activity.js
+++ b/gaia/apps/camera/js/controllers/activity.js
@@ -272,7 +272,7 @@ ActivityController.prototype.onActivityConfirmed = function(newMedia) {
// Video
if (newMedia.isVideo) {
- media.type = media.blob.type || 'video/3gpp';
+ media.type = 'video/3gpp';
media.poster = newMedia.poster.blob;
activity.postResult(media);
this.app.clearSpinner();
diff --git a/gaia/apps/camera/js/controllers/overlay.js b/gaia/apps/camera/js/controllers/overlay.js
index 89d29fa1..952d8633 100755
--- a/gaia/apps/camera/js/controllers/overlay.js
+++ b/gaia/apps/camera/js/controllers/overlay.js
@@ -107,7 +107,7 @@ OverlayController.prototype.updateOverlay = function(type, enabled, reason) {
return;
}
- var closable = self.activity.pick || reason === 'request-fail';
+ var closable = self.activity.pick && type !== 'request-fail';
var view = new OverlayView({
type: reason,
closable: closable,
@@ -118,10 +118,6 @@ OverlayController.prototype.updateOverlay = function(type, enabled, reason) {
overlay.view = view.appendTo(document.body)
.on('click:close-btn', function() {
- if (reason === 'request-fail' && !self.activity.pick) {
- self.updateOverlay(type, false);
- return;
- }
self.app.emit('activitycanceled');
});
diff --git a/gaia/apps/camera/js/controllers/storage.js b/gaia/apps/camera/js/controllers/storage.js
index 23a8058c..0f196f32 100755
--- a/gaia/apps/camera/js/controllers/storage.js
+++ b/gaia/apps/camera/js/controllers/storage.js
@@ -147,7 +147,7 @@ StorageController.prototype.storeVideo = function(video) {
var self = this;
// Add the poster image to the image storage
- poster.filepath = video.filepath.replace(/\.(?:3gp|mp4|webm)$/, '.jpg');
+ poster.filepath = video.filepath.replace('.3gp', '.jpg');
video.isVideo = true;
this.storage.addPicture(
diff --git a/gaia/apps/camera/js/controllers/viewfinder.js b/gaia/apps/camera/js/controllers/viewfinder.js
index 8462f776..ccd9d398 100755
--- a/gaia/apps/camera/js/controllers/viewfinder.js
+++ b/gaia/apps/camera/js/controllers/viewfinder.js
@@ -102,7 +102,6 @@ ViewfinderController.prototype.hideGrid = function() {
* @private
*/
ViewfinderController.prototype.bindEvents = function() {
- window.addEventListener('resize', this.configurePreview);
// View
this.views.viewfinder.on('fadedin', this.app.firer('viewfinder:visible'));
diff --git a/gaia/apps/camera/js/lib/dcf.js b/gaia/apps/camera/js/lib/dcf.js
index ba28ba98..98ffb082 100755
--- a/gaia/apps/camera/js/lib/dcf.js
+++ b/gaia/apps/camera/js/lib/dcf.js
@@ -25,7 +25,7 @@ var dcfConfig = {
seq: null,
postFix: 'MZLLA',
prefix: {video: 'VID_', image: 'IMG_'},
- ext: {video: window.VulpesVideoExtension || '3gp', image: 'jpg'}
+ ext: {video: '3gp', image: 'jpg'}
};
exports.init = function() {
diff --git a/gaia/apps/camera/js/lib/storage.js b/gaia/apps/camera/js/lib/storage.js
index e92f6a5c..fcc18ac4 100755
--- a/gaia/apps/camera/js/lib/storage.js
+++ b/gaia/apps/camera/js/lib/storage.js
@@ -252,7 +252,7 @@ Storage.prototype.checkFilepath = function(filepath) {
// image and get the video filepath by changing '.jpg' to '.3gp'
if (filepath.indexOf('VID') != -1 &&
filepath.lastIndexOf('.jpg') === filepath.length - 4) {
- filepath = filepath.replace('.jpg', '.' + (window.VulpesVideoExtension || '3gp'));
+ filepath = filepath.replace('.jpg', '.3gp');
}
return filepath;
@@ -380,7 +380,7 @@ Storage.prototype.deletePicture = function(filepath, done) {
* @param {String} filepath
*/
Storage.prototype.deleteVideo = function(filepath) {
- var poster = filepath.replace(/\.(?:3gp|mp4|webm)$/, '.jpg');
+ var poster = filepath.replace('.3gp', '.jpg');
this.video.delete(filepath).onerror = function(e) {
console.warn(
diff --git a/gaia/apps/camera/js/views/viewfinder.js b/gaia/apps/camera/js/views/viewfinder.js
index d89e3c23..ed6f508a 100755
--- a/gaia/apps/camera/js/views/viewfinder.js
+++ b/gaia/apps/camera/js/views/viewfinder.js
@@ -205,9 +205,6 @@ module.exports = View.extend({
*/
updatePreview: function(preview, sensorAngle, mirrored) {
if (!preview) { return; }
- // An embedded app can initialize before its window is visible.
- this.getSize();
- if (!this.width || !this.height) { return; }
var aspect;
// Invert dimensions if the camera's `sensorAngle` is
diff --git a/gaia/apps/communications/contacts/js/activities.js b/gaia/apps/communications/contacts/js/activities.js
index 84e333b1..12cb2a1f 100755
--- a/gaia/apps/communications/contacts/js/activities.js
+++ b/gaia/apps/communications/contacts/js/activities.js
@@ -19,7 +19,7 @@ const VCARD_MIME_TYPES = [
var ActivityHandler = {
_currentActivity: null,
- _launchedAsInlineActivity: new URLSearchParams(window.location.search).has('pick'),
+ _launchedAsInlineActivity: (window.location.search == '?pick'),
mozContactParam: null,
@@ -124,7 +124,6 @@ var ActivityHandler = {
this.launch_activity(activity, 'add-parameters');
break;
case 'pick':
- if (window.VulpesCompat) this._launchedAsInlineActivity = true;
if (!this._launchedAsInlineActivity) {
return;
}
@@ -357,18 +356,15 @@ var ActivityHandler = {
postNewSuccess: function ah_postNewSuccess(contact) {
this._currentActivity.postResult({ contact: contact });
this._currentActivity = null;
- if (window.VulpesCompat) this._launchedAsInlineActivity = false;
},
postPickSuccess: function ah_postPickSuccess(result) {
this._currentActivity.postResult(result);
this._currentActivity = null;
- if (window.VulpesCompat) this._launchedAsInlineActivity = false;
},
postCancel: function ah_postCancel() {
this._currentActivity.postError('canceled');
this._currentActivity = null;
- if (window.VulpesCompat) this._launchedAsInlineActivity = false;
}
};
diff --git a/gaia/apps/communications/contacts/js/contacts.js b/gaia/apps/communications/contacts/js/contacts.js
index 4ea36df1..64dc91a5 100755
--- a/gaia/apps/communications/contacts/js/contacts.js
+++ b/gaia/apps/communications/contacts/js/contacts.js
@@ -655,9 +655,7 @@ var Contacts = (function() {
utils.PerformanceHelper.contentInteractive();
utils.PerformanceHelper.chromeInteractive();
window.setTimeout(Contacts && Contacts.onLocalized);
- // Vulpes embeds each app in its own permission-checked frame.
- if (window.navigator.mozSetMessageHandler &&
- (window.self == window.top || window.VulpesCompat)) {
+ if (window.navigator.mozSetMessageHandler && window.self == window.top) {
LazyLoader.load(['/shared/js/contacts/import/utilities/misc.js',
'/shared/js/contacts/import/utilities/vcard_reader.js',
'/shared/js/contacts/import/utilities/vcard_parser.js'],
diff --git a/gaia/apps/communications/contacts/js/views/form.js b/gaia/apps/communications/contacts/js/views/form.js
index ef5e805c..865eead5 100755
--- a/gaia/apps/communications/contacts/js/views/form.js
+++ b/gaia/apps/communications/contacts/js/views/form.js
@@ -1038,8 +1038,6 @@ contacts.Form = (function() {
// (Bug 1041455).
if (editingContact) {
Contacts.setCurrent(contact);
- // The change event may arrive while the edit form is still visible.
- Contacts.updateContactDetail(contact.id);
}
}
);
diff --git a/gaia/apps/communications/dialer/js/call_log.js b/gaia/apps/communications/dialer/js/call_log.js
index 23416745..a738f2dc 100755
--- a/gaia/apps/communications/dialer/js/call_log.js
+++ b/gaia/apps/communications/dialer/js/call_log.js
@@ -265,7 +265,8 @@ 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, logGroupContainer);
+ this.updateListWithContactInfo(null, null, phoneNumbers,
+ logGroupContainer);
}
},
diff --git a/gaia/apps/communications/dialer/js/telephony_helper.js b/gaia/apps/communications/dialer/js/telephony_helper.js
index cdf2ce0c..6d7b7052 100755
--- a/gaia/apps/communications/dialer/js/telephony_helper.js
+++ b/gaia/apps/communications/dialer/js/telephony_helper.js
@@ -25,19 +25,6 @@ var TelephonyHelper = (function() {
return Promise.reject();
}
- // The platform adapter owns network checks on Tundra and Android.
- if (window.VulpesPlatformDial) {
- return window.VulpesPlatformDial(sanitizedNumber.split(',')[0]).then(function(obj) {
- if (window.TelephonyCall && obj instanceof TelephonyCall) {
- installHandlers(obj, sanitizedNumber, false, cardIndex,
- oncall, onconnected, ondisconnected);
- } else if (oncall) {
- oncall();
- }
- return obj;
- });
- }
-
var conn = navigator.mozMobileConnections &&
navigator.mozMobileConnections[cardIndex];
@@ -183,13 +170,11 @@ var TelephonyHelper = (function() {
call.addEventListener('connected', onconnected);
call.addEventListener('disconnected', function handleDisconnect(evt) {
- if (evt.call.disconnectedReason) {
- loadTelephonyMessages(function() {
- TelephonyMessages.handleDisconnect(
- evt.call.disconnectedReason, number, emergencyOnly
- );
- });
- }
+ loadTelephonyMessages(function() {
+ TelephonyMessages.handleDisconnect(
+ evt.call.disconnectedReason, number, emergencyOnly
+ );
+ });
ondisconnected && ondisconnected();
});
}
diff --git a/gaia/apps/gallery/js/MetadataParser.js b/gaia/apps/gallery/js/MetadataParser.js
index 87fba101..43607764 100755
--- a/gaia/apps/gallery/js/MetadataParser.js
+++ b/gaia/apps/gallery/js/MetadataParser.js
@@ -651,30 +651,25 @@ var metadataParser = (function() {
function videoMetadataParser(file, metadataCallback, errorCallback) {
var metadata = {};
- var extensions = ['webm', '3gp', 'mp4'];
- function findVideo() {
- var videofilename = file.name.replace(/\.jpg$/, '.' + extensions.shift());
- var getreq = videostorage.get(videofilename);
- getreq.onerror = function() {
- if (extensions.length) findVideo();
- else errorCallback('cannot get video file: ' + videofilename);
- };
- getreq.onsuccess = function() {
- metadata.video = videofilename;
- if (/\.webm$/.test(videofilename)) {
- metadata.rotation = 0;
+ var videofilename = file.name.replace('.jpg', '.3gp');
+ metadata.video = videofilename;
+
+ var getreq = videostorage.get(videofilename);
+ getreq.onerror = function() {
+ errorCallback('cannot get video file: ' + videofilename);
+ };
+ getreq.onsuccess = function() {
+ var videofile = getreq.result;
+ getVideoRotation(videofile, function(rotation) {
+ if (typeof rotation === 'number') {
+ metadata.rotation = rotation;
getVideoThumbnailAndSize();
- return;
}
- getVideoRotation(getreq.result, function(rotation) {
- if (typeof rotation === 'number') {
- metadata.rotation = rotation;
- getVideoThumbnailAndSize();
- } else errorCallback('Video rotation: ' + rotation);
- });
- };
- }
- findVideo();
+ else if (typeof rotation === 'string') {
+ errorCallback('Video rotation:', rotation);
+ }
+ });
+ };
function getVideoThumbnailAndSize() {
var url = URL.createObjectURL(file);
diff --git a/gaia/apps/homescreen/js/apps.js b/gaia/apps/homescreen/js/apps.js
index e7725e97..9a982135 100755
--- a/gaia/apps/homescreen/js/apps.js
+++ b/gaia/apps/homescreen/js/apps.js
@@ -408,10 +408,6 @@
return;
}
- if (HIDDEN_ROLES.includes(manifest.role)) {
- return;
- }
-
// Do not add blacklisted apps
if (BLACKLIST.includes(app.origin)) {
return;
diff --git a/gaia/apps/keyboard/js/settings/layout_dictionary_downloader.js b/gaia/apps/keyboard/js/settings/layout_dictionary_downloader.js
index b10c5e61..c1422c4f 100755
--- a/gaia/apps/keyboard/js/settings/layout_dictionary_downloader.js
+++ b/gaia/apps/keyboard/js/settings/layout_dictionary_downloader.js
@@ -9,9 +9,10 @@ var LayoutDictionaryDownloader = function(imEngineId, dictFilename) {
this._xhr = null;
};
-// Dictionaries ship with Vulpes so adding a layout also works offline.
+// Remote URLs to download the data. We require the remote server to have
+// CORS turned on since we are not asking for SystemXHR permission.
LayoutDictionaryDownloader.prototype.REMOTE_URL =
- '/js/imes/%imEngineId/dictionaries/%dictFilename';
+ 'https://fxos.cdn.mozilla.net/dictionaries/%imEngineId/%ver/%dictFilename';
// If the data schema changes in an incompatible way, we must bump the
// version-to-download here.
diff --git a/gaia/apps/marketplace/icon.png b/gaia/apps/marketplace/icon.png
deleted file mode 100644
index 9880f58a..00000000
Binary files a/gaia/apps/marketplace/icon.png and /dev/null differ
diff --git a/gaia/apps/marketplace/index.html b/gaia/apps/marketplace/index.html
deleted file mode 100644
index bee23bd1..00000000
--- a/gaia/apps/marketplace/index.html
+++ /dev/null
@@ -1,5 +0,0 @@
-Marketplace
-
-
-
-
diff --git a/gaia/apps/marketplace/manifest.webapp b/gaia/apps/marketplace/manifest.webapp
deleted file mode 100644
index 8cded036..00000000
--- a/gaia/apps/marketplace/manifest.webapp
+++ /dev/null
@@ -1,19 +0,0 @@
-{
- "name": "Marketplace",
- "description": "Vulpes OS application catalogue",
- "launch_path": "/index.html",
- "type": "certified",
- "icons": {
- "512": "/icon.png"
- },
- "permissions": {
- "browser": {},
- "webapps-manage": {}
- },
- "locales": {
- "fr": {
- "name": "Marketplace",
- "description": "Catalogue des applications Vulpes OS"
- }
- }
-}
diff --git a/gaia/apps/marketplace/marketplace.css b/gaia/apps/marketplace/marketplace.css
deleted file mode 100644
index 0125ce10..00000000
--- a/gaia/apps/marketplace/marketplace.css
+++ /dev/null
@@ -1 +0,0 @@
-html,body{width:100%;height:100%;margin:0;background:#f4f4f4;font-family:Fira Sans,sans-serif}#catalogue{position:absolute;inset:0;width:100%;height:100%;border:0}#message{padding:3rem 2rem;text-align:center;font-size:1.8rem}#message button{display:block;width:100%;padding:1rem;margin:1rem 0;font:inherit}#open[hidden],[hidden]{display:none!important}#confirm{z-index:10}#confirm section{overflow:auto}#permissions{font-size:1.5rem}
diff --git a/gaia/apps/marketplace/marketplace.js b/gaia/apps/marketplace/marketplace.js
deleted file mode 100644
index b00ac828..00000000
--- a/gaia/apps/marketplace/marketplace.js
+++ /dev/null
@@ -1,38 +0,0 @@
-(async()=>{
- const frame=document.getElementById('catalogue'),message=document.getElementById('message'),dialog=document.getElementById('confirm');
- let attempt=0,busy=false,prepared=null,installed=null,language=await VulpesCompat.call('locale.current');
- const fr=()=>/^fr(?:-|$)/i.test(language);
- const text=(f,e)=>fr()?f:e;
- 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){
- if(busy)return;const current=++attempt;busy=true;prepared=null;installed=null;document.getElementById('open').hidden=true;
- status(text('Vérification de l’application…','Checking application…'));
- try {const value=await VulpesCompat.call('marketplace.prepare',{url});if(current!==attempt)return;prepared=value;
- document.getElementById('title').textContent=prepared.name;
- document.getElementById('question').textContent=text('Installer cette application archivée ?','Install this archived application?')+' ('+(prepared.bytes/1048576).toFixed(1)+' Mio)';
- document.getElementById('permissions').textContent=text('Elle sera exécutée comme une application web, sans accès aux fonctions système. Certains anciens services peuvent ne plus fonctionner.','It will run as a web application without system access. Some old services may no longer work.');
- message.hidden=true;dialog.hidden=false;
- }catch(error){if(current!==attempt)return;status(text('Installation impossible : ','Cannot install: ')+error.message);busy=false;}
- }
- dialog.addEventListener('submit',async event=>{event.preventDefault();if(!prepared)return;const token=prepared.token;prepared=null;dialog.hidden=true;status(text('Installation…','Installing…'));
- try {installed=await VulpesCompat.call('marketplace.install',{token});status(text('Application ajoutée à l’accueil.','Application added to Home.'));document.getElementById('open').hidden=false;}
- catch(error){status(text('Installation impossible : ','Cannot install: ')+error.message);}finally{busy=false;}
- });
- const cancel=()=>{attempt++;prepared=null;busy=false;VulpesCompat.call('marketplace.cancel').catch(console.error);browse();};
- document.getElementById('cancel').onclick=cancel;
- document.getElementById('browse').onclick=cancel;
- document.getElementById('open').onclick=()=>installed && VulpesCompat.call('apps.launch',{manifestURL:installed.manifestURL});
- addEventListener('languagechange',()=>{language=navigator.language;if(!busy)browse();});
- browse();
- setInterval(async()=>{if(busy)return;try{const url=await VulpesCompat.call('marketplace.poll');if(url)await prepare(url);}catch(error){console.error(error);}},750);
-})();
diff --git a/gaia/apps/ringtones/js/tone_player.js b/gaia/apps/ringtones/js/tone_player.js
index 97358a05..aa92cd42 100755
--- a/gaia/apps/ringtones/js/tone_player.js
+++ b/gaia/apps/ringtones/js/tone_player.js
@@ -143,7 +143,7 @@ TonePlayer.prototype = {
_setExclusiveMode: function(exclusive) {
if (exclusive) {
if (!this._source) {
- this._context = new AudioContext();
+ this._context = new AudioContext('ringer');
this._source = this._context.createMediaElementSource(this._player);
this._source.connect(this._context.destination);
}
diff --git a/gaia/apps/settings/js/modules/date_time.js b/gaia/apps/settings/js/modules/date_time.js
index 300c053c..42eae938 100755
--- a/gaia/apps/settings/js/modules/date_time.js
+++ b/gaia/apps/settings/js/modules/date_time.js
@@ -139,6 +139,10 @@ define(function(require) {
*/
DateTime.prototype._init = function() {
this._mozTime = window.navigator.mozTime;
+ if (!this._mozTime) {
+ console.error('Could not get window.navigator.mozTime');
+ return;
+ }
this._boundSetTimeAutoEnabled = function(event) {
this._clockAutoEnabled = event.settingValue;
@@ -316,7 +320,9 @@ define(function(require) {
switch (type) {
case 'date':
tokens = value.split('-');
- newDate.setFullYear(Number(tokens[0]), Number(tokens[1]) - 1, Number(tokens[2]));
+ newDate.setYear(tokens[0]);
+ newDate.setMonth(tokens[1] - 1);
+ newDate.setDate(tokens[2]);
break;
case 'time':
@@ -325,8 +331,7 @@ define(function(require) {
newDate.setMinutes(parseInt(tokens[1]));
break;
}
- if (!this._mozTime) return;
- return this._mozTime.set(newDate);
+ this._mozTime.set(newDate);
};
/**
diff --git a/gaia/apps/settings/js/modules/dialog_manager.js b/gaia/apps/settings/js/modules/dialog_manager.js
index 1692d23d..bc59abdc 100755
--- a/gaia/apps/settings/js/modules/dialog_manager.js
+++ b/gaia/apps/settings/js/modules/dialog_manager.js
@@ -99,35 +99,37 @@ define(function(require) {
_transit: function dm__transit(method, dialog, options) {
var promise = new Promise(function(resolve) {
var panel = dialog.panel;
- var completed = false;
- var fallback;
- function finish(event) {
- if (event && (event.target !== panel || event.propertyName !== 'visibility')) return;
- if (completed) return;
- completed = true;
- clearTimeout(fallback);
- panel.removeEventListener('transitionend', finish);
- panel.removeEventListener('transitioncancel', finish);
- if (method === 'close') panel.hidden = true;
- resolve();
- }
- if (method === 'open') panel.hidden = false;
- setTimeout(function() {
- panel.addEventListener('transitionend', finish);
- panel.addEventListener('transitioncancel', finish);
- panel.classList.toggle('current', method === 'open');
- var style = window.getComputedStyle(panel);
- function milliseconds(value) {
- return parseFloat(value) * (value.trim().endsWith('ms') ? 1 : 1000) || 0;
+
+ panel.addEventListener('transitionend', function paintWait(evt) {
+ if ((method === 'close' || method === 'open') &&
+ evt.propertyName === 'visibility') {
+ // After transition, we have to `hide` the panel, otherwise
+ // the panel would still exist on the layer and would block
+ // the scrolling event.
+ if (method === 'close') {
+ panel.hidden = true;
+ }
+ panel.removeEventListener('transitionend', paintWait);
+ resolve();
+ }
+ });
+
+ // Before transition, we have to `show` the panel, otherwise
+ // the panel before applying transition class.
+ if (method === 'open') {
+ panel.hidden = false;
+ }
+
+ // We need to apply class later otherwise Gecko can't apply
+ // this transition and 150ms is an approximate number after doing
+ // several rounds of manual tests.
+ setTimeout(function() {
+ if (method === 'open') {
+ // Let's unhide the panel first
+ panel.classList.add('current');
+ } else {
+ panel.classList.remove('current');
}
- var durations = style.transitionDuration.split(',').map(milliseconds);
- var delays = style.transitionDelay.split(',').map(milliseconds);
- var duration = Math.max.apply(Math, durations.map(function(value, index) {
- return value + delays[index % delays.length];
- }));
- // Reduced motion, display changes or interrupted transitions may
- // omit the visibility event. Always release the dialog queue.
- fallback = setTimeout(finish, Math.max(0, duration) + 100);
}, 150);
});
return promise;
diff --git a/gaia/apps/settings/js/modules/dialog_service.js b/gaia/apps/settings/js/modules/dialog_service.js
index 8f96465c..a9188715 100755
--- a/gaia/apps/settings/js/modules/dialog_service.js
+++ b/gaia/apps/settings/js/modules/dialog_service.js
@@ -190,30 +190,29 @@ define(function(require) {
if ('#' + panelId === currentPanel) {
defer.reject('You are showing the same panel #' + panelId);
} else {
- var opening;
- var closing = false;
- function close(type) {
- if (closing) return;
- closing = true;
- // A tap during the opening animation must not run hide/show
- // concurrently, or a queued dialog can inherit stale form values.
- Promise.resolve(opening).then(function() {
- return DialogManager.close(dialog, type, options);
- }).then(function(result) {
+ options.onWrapSubmit = function() {
+ DialogManager.close(dialog, 'submit', options)
+ .then(function(result) {
+ defer.resolve({
+ type: 'submit',
+ value: result
+ });
self._navigating = false;
- defer.resolve({type: type, value: result});
self._execPendingRequest();
- }, function(error) {
- // Panel validation rejects to keep the form open. Allow a
- // correction or cancellation without rejecting the show promise.
- closing = false;
- dialogDOM.dispatchEvent(new CustomEvent('dialogvalidationfailed', {
- detail: error
- }));
});
- }
- options.onWrapSubmit = function() { close('submit'); };
- options.onWrapCancel = function() { close('cancel'); };
+ };
+
+ options.onWrapCancel = function() {
+ DialogManager.close(dialog, 'cancel', options)
+ .then(function(result) {
+ defer.resolve({
+ type: 'cancel',
+ value: result
+ });
+ self._navigating = false;
+ self._execPendingRequest();
+ });
+ };
switch (options.type) {
case 'alert':
@@ -230,17 +229,7 @@ define(function(require) {
break;
}
this._navigating = true;
- opening = DialogManager.open(dialog, options);
- opening.catch(function(error) {
- // A missing host API must not lock every later Settings dialog.
- dialogDOM.classList.remove('current');
- dialogDOM.hidden = true;
- var overlay = document.querySelector('.settings-dialog-overlay');
- if (overlay) overlay.hidden = true;
- self._navigating = false;
- defer.reject(error);
- self._execPendingRequest();
- });
+ DialogManager.open(dialog, options);
}
}
diff --git a/gaia/apps/settings/js/modules/page_transitions.js b/gaia/apps/settings/js/modules/page_transitions.js
index 59f84c11..11951804 100755
--- a/gaia/apps/settings/js/modules/page_transitions.js
+++ b/gaia/apps/settings/js/modules/page_transitions.js
@@ -43,39 +43,7 @@ define(function() {
return;
}
- // A hidden or newly inserted panel may not animate. Complete navigation
- // in that case too, otherwise its onShow hook never runs.
- var completed = false;
- var fallback;
- function paintWait(event) {
- if (event && event.target !== newPanel) {
- return;
- }
- if (completed) {
- return;
- }
- completed = true;
- clearTimeout(fallback);
- newPanel.removeEventListener('transitionend', paintWait);
- newPanel.removeEventListener('transitioncancel', paintWait);
- _sendPanelReady(oldPanel && '#' + oldPanel.id, '#' + newPanel.id);
- if (callback) {
- callback();
- }
- }
- newPanel.addEventListener('transitionend', paintWait);
- newPanel.addEventListener('transitioncancel', paintWait);
newPanel.className = 'current';
- var style = window.getComputedStyle(newPanel);
- var seconds = function(value) {
- return parseFloat(value) * (value.trim().endsWith('ms') ? 1 : 1000) || 0;
- };
- var durations = style.transitionDuration.split(',').map(seconds);
- var delays = style.transitionDelay.split(',').map(seconds);
- var duration = Math.max.apply(Math, durations.map(function(value, index) {
- return value + delays[index % delays.length];
- }));
- fallback = setTimeout(paintWait, Math.max(0, duration) + 100);
/**
* Most browsers now scroll content into view taking CSS transforms into
@@ -89,6 +57,22 @@ define(function() {
window.scrollTo(0, 0);
}
+ newPanel.addEventListener('transitionend', function paintWait() {
+ newPanel.removeEventListener('transitionend', paintWait);
+ if (oldPanel) {
+ _sendPanelReady('#' + oldPanel.id, '#' + newPanel.id);
+
+ if (oldPanel.className === 'current') {
+ return;
+ }
+ } else {
+ _sendPanelReady(null, '#' + newPanel.id);
+ }
+
+ if (callback) {
+ callback();
+ }
+ });
},
/**
diff --git a/gaia/apps/settings/js/modules/panel_cache.js b/gaia/apps/settings/js/modules/panel_cache.js
index df481e13..a5b558af 100755
--- a/gaia/apps/settings/js/modules/panel_cache.js
+++ b/gaia/apps/settings/js/modules/panel_cache.js
@@ -86,16 +86,7 @@ define(function(require) {
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;
- }
+ var panel = panelFunc();
_panelCache[panelId] = panel;
if (callback) {
callback(panel);
@@ -106,8 +97,6 @@ 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 e9219b1b..4b81f1cf 100755
--- a/gaia/apps/settings/js/modules/settings_service.js
+++ b/gaia/apps/settings/js/modules/settings_service.js
@@ -187,12 +187,7 @@ define(function(require) {
}
_loadPanel(panelId, function() {
- PanelCache.get(panelId, function(panel, loadError) {
- if (loadError || !panel) {
- console.error('Settings panel ' + panelId + ':', loadError);
- callback(loadError || new Error('Missing settings panel: ' + panelId));
- return;
- }
+ PanelCache.get(panelId, function(panel) {
var newPanelElement = document.getElementById(panelId);
var currentPanelId =
_currentNavigation && _currentNavigation.panelId;
@@ -251,17 +246,6 @@ define(function(require) {
// directly
Settings._currentPanel = '#' + panelId;
callback();
- }).catch(function(error) {
- console.error('Settings panel ' + panelId + ':', error);
- newPanelElement.classList.remove('current');
- if (currentPanelElement) currentPanelElement.classList.add('current');
- // Release the navigation lock even when a hardware query fails.
- callback(error);
- if (window.VulpesGaiaUI) {
- window.VulpesGaiaUI.notice(document.documentElement.lang.indexOf('fr') === 0 ?
- 'Ce panneau n’a pas pu être chargé. Vous pouvez revenir aux autres paramètres.' :
- 'This panel could not be loaded. You can return to the other settings.');
- }
});
});
});
diff --git a/gaia/apps/settings/js/panels/app_permissions_list/app_permissions_list.js b/gaia/apps/settings/js/panels/app_permissions_list/app_permissions_list.js
index 7e01cc98..e5f3b161 100755
--- a/gaia/apps/settings/js/panels/app_permissions_list/app_permissions_list.js
+++ b/gaia/apps/settings/js/panels/app_permissions_list/app_permissions_list.js
@@ -103,10 +103,9 @@ define(function(require) {
* @access public
*/
_onAppChoose: function pl__onAppChoose(evt) {
- var target = evt.target.closest('[data-app-index]');
- if (target && this._listRoot.contains(target)) {
+ if (evt.target.dataset && evt.target.dataset.appIndex) {
SettingsService.navigate('appPermissions-details', {
- app: this._apps[target.dataset.appIndex],
+ app: this._apps[evt.target.dataset.appIndex],
permissionsTable: this._permissionTable
});
}
@@ -263,7 +262,7 @@ define(function(require) {
* @return {Bool}
*/
_isExplicitPerm: function pl_isExplicitPerm(perm) {
- return mozPerms && mozPerms.isExplicit(perm, this._currentApp.manifestURL,
+ return mozPerms.isExplicit(perm, this._currentApp.manifestURL,
this._currentApp.origin, false);
},
diff --git a/gaia/apps/settings/js/panels/help/support.js b/gaia/apps/settings/js/panels/help/support.js
index 01c77d96..24a2e565 100755
--- a/gaia/apps/settings/js/panels/help/support.js
+++ b/gaia/apps/settings/js/panels/help/support.js
@@ -21,7 +21,7 @@ define(function(require) {
* refer to the JSON File Data.
*/
this._callSupportInfo = null;
- var url = 'https://vulpes-os.org/';
+ var url = 'http://support.mozilla.org/products/firefox-os';
this._elements.userGuide.addEventListener('click', () => {
this._elements.userGuide.blur();
var activity = new window.MozActivity({
@@ -55,10 +55,7 @@ define(function(require) {
.then(function loadSupportInfo(data) {
this._supportInfo = data;
callback(this._supportInfo);
- }.bind(this)).catch(function(error) {
- console.warn('Support information:', error);
- callback({onlinesupport: {title: 'Vulpes OS', href: 'https://vulpes-os.org/'}, callsupport: []});
- });
+ }.bind(this));
},
/**
* create support url items
@@ -106,7 +103,7 @@ define(function(require) {
_renderSupportInfo: function support_renderSupportInfo(result) {
var onlineSupportInfo = null;
var onlineSupportTitle = result['support.onlinesupport.title'];
- if (typeof onlineSupportTitle === 'string' && onlineSupportTitle !== '') {
+ if (onlineSupportTitle !== '') {
onlineSupportInfo = { title: onlineSupportTitle };
onlineSupportInfo.href = result['support.onlinesupport.href'];
this._setOnlineSupportInfo(onlineSupportInfo);
@@ -117,7 +114,7 @@ define(function(require) {
// Check to see if we have a title for the first support number.
var callSupport1Title = result['support.callsupport1.title'];
// If we have a title we'll go ahead and load the href for it too.
- if (typeof callSupport1Title === 'string' && callSupport1Title !== '') {
+ if (callSupport1Title !== '') {
this._callSupportInfo = [
{
'title': callSupport1Title,
@@ -128,7 +125,7 @@ define(function(require) {
// Now check to see if we have a title for the second
// support number. If we do, we'll load it's href as well.
var callSupport2Title = result['support.callsupport2.title'];
- if (typeof callSupport2Title === 'string' && callSupport2Title !== '') {
+ if (callSupport2Title !== '') {
this._callSupportInfo.push({
'title': callSupport2Title,
'href': result['support.callsupport2.href']
diff --git a/gaia/apps/settings/js/panels/screen_lock_passcode/panel.js b/gaia/apps/settings/js/panels/screen_lock_passcode/panel.js
index d87a0810..b13186d2 100755
--- a/gaia/apps/settings/js/panels/screen_lock_passcode/panel.js
+++ b/gaia/apps/settings/js/panels/screen_lock_passcode/panel.js
@@ -66,11 +66,8 @@ define(function(require) {
},
_bindEvents: function() {
- this._elements.passcodeInput.type = 'password';
- this._elements.passcodeInput.inputMode = 'numeric';
- this._elements.passcodeInput.maxLength = 8;
- this._elements.passcodeInput.oninput =
- this._onPasscodeInput.bind(this);
+ this._elements.passcodeInput.onkeypress =
+ this._onPasscodeInputKeypress.bind(this);
this._elements.passcodeContainer.onclick =
this._onPasscodeContainerClick.bind(this);
},
@@ -80,14 +77,37 @@ define(function(require) {
this._elements.passcodeInput.focus();
},
- _onPasscodeInput: function() {
- // Android IMEs commit text without legacy keypress/charCode events.
- var value = this._elements.passcodeInput.value.replace(/[^0-9]/g, '').slice(0, 8);
- this._hideErrorMessage();
- if (this._elements.panel.dataset.passcodeStatus === 'success') {
- this._resetPasscodeStatus();
+ _onPasscodeInputKeypress: function(evt) {
+ // key code for key strokes from the keypad are 0 (numbers) and 8
+ // (backspace). Filter out the events that are not from the keypad.
+ var keyCode = evt.keyCode;
+ if (keyCode !== 0 && keyCode !== 8) {
+ return;
}
- this._passcodeBuffer = value;
+
+ evt.preventDefault();
+
+ if (this._passcodeBuffer === '') {
+ this._hideErrorMessage();
+ }
+
+ var code = evt.charCode;
+ if (code !== 0 && (code < 0x30 || code > 0x39)) {
+ return;
+ }
+
+ var key = String.fromCharCode(code);
+ if (evt.charCode === 0) {
+ if (this._passcodeBuffer.length > 0) {
+ this._passcodeBuffer = this._getPasscodeBuffer('all');
+ if (this._elements.panel.dataset.passcodeStatus === 'success') {
+ this._resetPasscodeStatus();
+ }
+ }
+ } else if (this._passcodeBuffer.length < 8) {
+ this._passcodeBuffer += key;
+ }
+
this._updatePassCodeUI();
this._processPasscode();
},
@@ -114,7 +134,6 @@ define(function(require) {
},
_updatePassCodeUI: function() {
- this._elements.passcodeInput.value = this._passcodeBuffer;
for (var i = 0; i < 8; i++) {
if (i < this._passcodeBuffer.length) {
this._elements.passcodeDigits[i].dataset.dot = true;
diff --git a/gaia/apps/settings/js/panels/simcard_manager/simcard_manager.js b/gaia/apps/settings/js/panels/simcard_manager/simcard_manager.js
index 261320af..3958d8b4 100755
--- a/gaia/apps/settings/js/panels/simcard_manager/simcard_manager.js
+++ b/gaia/apps/settings/js/panels/simcard_manager/simcard_manager.js
@@ -304,7 +304,7 @@ define(function(require) {
*/
_updateSimSettingsUI: function scm__updateSimSettingsUI() {
var firstCardInfo = this._simcards[0].getInfo();
- var secondCardInfo = (this._simcards[1] ? this._simcards[1].getInfo() : {absent: true});
+ var secondCardInfo = this._simcards[1].getInfo();
var hidden = firstCardInfo.absent && secondCardInfo.absent ||
this._isAirplaneMode;
@@ -322,7 +322,7 @@ define(function(require) {
*/
_updateSelectOptionsUI: function scm__updateSelectOptionsUI() {
var firstCardInfo = this._simcards[0].getInfo();
- var secondCardInfo = (this._simcards[1] ? this._simcards[1].getInfo() : {absent: true});
+ var secondCardInfo = this._simcards[1].getInfo();
// two cards all are not absent, we have to update separately
if (!firstCardInfo.absent && !secondCardInfo.absent) {
diff --git a/gaia/apps/settings/js/panels/wifi_status/panel.js b/gaia/apps/settings/js/panels/wifi_status/panel.js
index aa325547..e992f3f6 100755
--- a/gaia/apps/settings/js/panels/wifi_status/panel.js
+++ b/gaia/apps/settings/js/panels/wifi_status/panel.js
@@ -41,8 +41,7 @@ define(function(require) {
var info = wifiManager.connectionInformation || {};
elements.ip.textContent = info.ipAddress || '';
document.l10n.setAttributes(elements.speed,
- Number.isFinite(info.linkSpeed) ? 'linkSpeedMbs' : 'unavailable',
- Number.isFinite(info.linkSpeed) ? { linkSpeed: info.linkSpeed } : {});
+ 'linkSpeedMbs', { linkSpeed: info.linkSpeed });
}
});
};
diff --git a/gaia/apps/settings/js/startup.js b/gaia/apps/settings/js/startup.js
index 18478b09..ae98e120 100755
--- a/gaia/apps/settings/js/startup.js
+++ b/gaia/apps/settings/js/startup.js
@@ -408,13 +408,14 @@
var appStarter = AppStarter();
- // 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') {
+ if (document.readyState !== 'loading') {
appStarter.start();
} else {
- document.addEventListener('DOMContentLoaded', function() {
- appStarter.start();
- }, { once: true });
+ document.addEventListener('readystatechange', function readyStateChange() {
+ if (document.readyState == 'interactive') {
+ document.removeEventListener('readystatechange', readyStateChange);
+ appStarter.start();
+ }
+ });
}
})();
diff --git a/gaia/apps/system/js/system_banner.js b/gaia/apps/system/js/system_banner.js
index c88f6b55..943eadaf 100755
--- a/gaia/apps/system/js/system_banner.js
+++ b/gaia/apps/system/js/system_banner.js
@@ -10,9 +10,7 @@
* application installation, crash reporter, and low storage notices.
* @class SystemBanner
*/
- function SystemBanner() {
- this._generation = 0;
- }
+ function SystemBanner() {}
/**
* Amount of time before banner is automatically dismissed.
@@ -79,18 +77,10 @@
* { label: l10nAttrs, callback: ..., dismiss: ... }
*/
show: function(messages, buttonParams) {
- var generation = ++this._generation;
- clearTimeout(this._dismissTimer);
- if (this._cancelRemoval) {
- this._cancelRemoval();
- }
this._clicked = false;
this._showing = true;
return this.getBanner().then(banner => {
- if (generation !== this._generation || !this._showing) {
- return;
- }
// Clear the button and message containers.
var messageContainer = banner.querySelector('.messages');
var buttonContainer = banner.querySelector('.buttons');
@@ -120,16 +110,13 @@
// Hide banner and invoke button dismiss() on click or timeout.
var hideBanner = () => {
- if (generation !== this._generation || !this._showing) {
- return;
- }
this.hide();
if (!this._clicked) {
buttonParams && buttonParams.dismiss && buttonParams.dismiss();
}
};
banner.onclick = hideBanner;
- this._dismissTimer = setTimeout(hideBanner, SystemBanner.TIMEOUT);
+ setTimeout(hideBanner, SystemBanner.TIMEOUT);
document.getElementById('screen').appendChild(banner);
banner.show();
@@ -144,37 +131,15 @@
return;
}
this._showing = false;
- clearTimeout(this._dismissTimer);
- var generation = this._generation;
return this.getBanner().then(banner => {
- if (generation !== this._generation || this._showing) {
- return;
- }
- // Animation events inside a modern shadow root do not reach the host.
- var animation = banner.shadowRoot &&
- banner.shadowRoot.querySelector('.bread') || banner;
- var cleanup = () => {
- clearTimeout(this._removeTimer);
- animation.removeEventListener('animationend', removeBanner);
- animation.removeEventListener('animationcancel', removeBanner);
- this._cancelRemoval = null;
- };
- var removeBanner = event => {
- if (event && event.target !== animation) {
- return;
- }
- cleanup();
- if (!this._showing && generation === this._generation) {
+ banner.addEventListener('animationend', function removeBanner() {
+ banner.removeEventListener('animationend', removeBanner);
+ // Only remove banner if we haven't called show() in the meantime.
+ if (!this._showing) {
banner.parentNode && banner.parentNode.removeChild(banner);
}
- };
- this._cancelRemoval = cleanup;
- animation.addEventListener('animationend', removeBanner);
- animation.addEventListener('animationcancel', removeBanner);
- // Gaia's exit animation lasts 600 ms. Also dismiss if it is disabled
- // or no animation event is produced.
- this._removeTimer = setTimeout(removeBanner, 700);
+ }.bind(this));
banner.hide();
});
}
diff --git a/gaia/apps/verticalhome/js/stores/item.js b/gaia/apps/verticalhome/js/stores/item.js
index 5da09703..e5fd249c 100755
--- a/gaia/apps/verticalhome/js/stores/item.js
+++ b/gaia/apps/verticalhome/js/stores/item.js
@@ -439,8 +439,7 @@
*/
populate: function(callback) {
this.initSources(function(entries) {
- // The first render waits for this write; deferring it waits for that render.
- this.save(entries, callback);
+ this.deferredSave(entries, callback);
}.bind(this));
},
diff --git a/gaia/apps/video/js/video.js b/gaia/apps/video/js/video.js
index ab1cce7c..ebeaf7a7 100755
--- a/gaia/apps/video/js/video.js
+++ b/gaia/apps/video/js/video.js
@@ -102,7 +102,7 @@ var currentLayoutMode;
var pendingUpdateTitleText = false;
// Videos recorded by our own camera have filenames of this form
-var FROMCAMERA = /DCIM\/\d{3}MZLLA\/VID_\d{4}\.(?:3gp|mp4|webm)$/;
+var FROMCAMERA = /DCIM\/\d{3}MZLLA\/VID_\d{4}\.3gp$/;
// We have a single instance of the loading checker because it is used
// across functions
@@ -664,7 +664,7 @@ function deleteFile(filename) {
// If we're deleting a video file recorded by our camera,
// we also need to delete the poster image associated with
// that video.
- var postername = filename.replace(/\.(?:3gp|mp4|webm)$/, '.jpg');
+ var postername = filename.replace('.3gp', '.jpg');
navigator.getDeviceStorage('pictures'). delete(postername);
}
diff --git a/gaia/apps/wallpaper/style/wallpaper.css b/gaia/apps/wallpaper/style/wallpaper.css
index 51e131ed..0a581104 100755
--- a/gaia/apps/wallpaper/style/wallpaper.css
+++ b/gaia/apps/wallpaper/style/wallpaper.css
@@ -70,6 +70,3 @@ html[dir="rtl"] .wallpaper {
margin: 0 0.5rem;
}
-
-/* Keep preview tiles aligned when source wallpapers have different ratios. */
-#wallpapers .wallpaper { aspect-ratio: 2 / 3; object-fit: cover; display: block; }
diff --git a/gaia/shared/js/download/download_helper.js b/gaia/shared/js/download/download_helper.js
index 58427d5e..70ff7080 100755
--- a/gaia/shared/js/download/download_helper.js
+++ b/gaia/shared/js/download/download_helper.js
@@ -113,10 +113,6 @@ var DownloadHelper = (function() {
*/
function getBlob(download) {
var req = new Request();
- if (download.vulpesDownload) {
- download.getBlob().then(function(blob) { req.done(blob); }, function(error) { req.failed(error); });
- return req;
- }
var storage = getVolume(download.storageName);
var storeAvailableReq = storage.available();
@@ -398,10 +394,6 @@ var DownloadHelper = (function() {
*/
function remove(download) {
var req = new Request();
- if (download.vulpesDownload) {
- navigator.mozDownloadManager.remove(download).then(function() { req.done(download); }, function(error) { req.failed(error); });
- return req;
- }
var incompleteDownload = download.state !== 'succeeded';
// If is not done, use download manager to remove it,
// otherwise, deal with the datastore.
diff --git a/host/Downloads.sys.mjs b/host/Downloads.sys.mjs
deleted file mode 100644
index 8e62a4c0..00000000
--- a/host/Downloads.sys.mjs
+++ /dev/null
@@ -1,30 +0,0 @@
-// SPDX-License-Identifier: MPL-2.0
-// Download identities belong to Gecko; paths supplied by content are never used.
-export class GaiaDownloads {
- constructor() {this.ids=new WeakMap();this.items=new Map();}
- async request(app, params={}) {
- if(!['settings','system'].includes(app.id))throw Error('PERMISSION_DENIED');
- if(!['list','pause','resume','remove','blob'].includes(params.operation))throw Error('INVALID_VALUE');
- const {Downloads}=ChromeUtils.importESModule('resource://gre/modules/Downloads.sys.mjs');
- const list=await Downloads.getList(Downloads.PUBLIC);
- const entries=await list.getAll();
- this.items.clear();
- const rows=entries.map(item=>{
- if(!this.ids.has(item))this.ids.set(item,Services.uuid.generateUUID().toString());
- const id=this.ids.get(item);this.items.set(id,item);
- return {id,url:item.source.url,path:item.target.path,startTime:+item.startTime,
- contentType:item.contentType||'application/octet-stream',currentBytes:item.currentBytes,totalBytes:item.totalBytes,
- state:item.succeeded?'succeeded':item.stopped?'stopped':'downloading'};
- });
- if(params.operation==='list')return rows;
- const item=this.items.get(params.id);if(!item)throw Error('DOWNLOAD_NOT_FOUND');
- if(params.operation==='blob') {
- if(!item.succeeded)throw Error('DOWNLOAD_NOT_COMPLETE');
- return File.createFromFileName(item.target.path);
- }
- if(params.operation==='pause')await item.cancel();
- if(params.operation==='resume')await item.start();
- if(params.operation==='remove') {await item.cancel();await item.finalize(true);if(item.succeeded)await IOUtils.remove(item.target.path,{ignoreAbsent:true});await list.remove(item);}
- return true;
- }
-}
diff --git a/host/Input.sys.mjs b/host/Input.sys.mjs
index d4b8fc75..6c73c982 100644
--- a/host/Input.sys.mjs
+++ b/host/Input.sys.mjs
@@ -1 +1,36 @@
-export {Input} from 'resource://vulpes/services/input.mjs';
+// Only the bundled keyboard can edit the field reported by a content actor.
+export class Input {
+ constructor(runtime) { this.runtime=runtime; this.focus=null; this.sequence=0; }
+ changed(actor, state) {
+ const app=this.runtime.identity(actor);
+ if (!app || app.id==='keyboard') return;
+ if (!state) { if(this.focus?.actor===actor) this.hide(); return; }
+ this.focus={actor,state:{...state,id:++this.sequence}};
+ this.runtime.broadcast('input-focus',this.focus.state,'system');
+ this.runtime.broadcast('input-focus',this.focus.state,'keyboard');
+ }
+ hide() {
+ this.focus=null;
+ this.runtime.broadcast('input-focus',null,'system');
+ this.runtime.broadcast('input-focus',null,'keyboard');
+ }
+ cleanup(actor) { if(this.focus?.actor===actor) this.hide(); }
+ async request(actor, p) {
+ const app=this.runtime.identity(actor);
+ if (!app || !['keyboard','system'].includes(app.id)) throw Error('PERMISSION_DENIED');
+ if (p.operation==='hide') { this.hide();return null; }
+ if(app.id!=='keyboard') throw Error('PERMISSION_DENIED');
+ if(p.operation==='state') return this.focus?.state || null;
+ if(p.operation==='height') {
+ if(!Number.isFinite(p.height) || p.height<0 || p.height>600) throw Error('INVALID_VALUE');
+ this.runtime.broadcast('input-height',{height:p.height},'system');return null;
+ }
+ if(!this.focus || p.id!==this.focus.state.id) throw Error('INPUT_CONTEXT_EXPIRED');
+ const context=this.focus;
+ const result=await context.actor.sendQuery('Vulpes:Edit',p);
+ // An application can close or focus another field while the edit is pending.
+ if(this.focus!==context) throw Error('INPUT_CONTEXT_EXPIRED');
+ Object.assign(context.state,result);
+ return context.state;
+ }
+}
diff --git a/host/Messages.sys.mjs b/host/Messages.sys.mjs
index 6f6730e2..02a88f0b 100644
--- a/host/Messages.sys.mjs
+++ b/host/Messages.sys.mjs
@@ -1,11 +1,10 @@
// Desktop message store. Radio operations fail explicitly; no synthetic SMS.
export class Messages {
- constructor(storage, radio = null, notify = () => {}, radioStatus = null, mms = null) {
+ constructor(storage, radio = null, notify = () => {}, radioStatus = null) {
this.storage = storage;
this.radio = radio;
this.notify = notify;
this.radioStatus = radioStatus;
- this.mms = mms;
this.queue = Promise.resolve();
}
receive(incoming) {
@@ -15,8 +14,7 @@ export class Messages {
if (existing) return existing;
const id=Math.max(Date.now(),...records.map(m=>Number(m.id)+1 || 0));
const record={id,threadId:records.find(m=>(m.sender||m.receiver)===incoming.sender)?.threadId || id+1,
- receipt:incoming.receipt,type:incoming.type || 'sms',sender:incoming.sender,receiver:null,
- ...(incoming.type==='mms' ? {receivers:incoming.receivers || [],subject:incoming.subject || '',smil:incoming.smil || '',attachments:incoming.attachments || [],deliveryInfo:[]} : {}),
+ receipt:incoming.receipt,type:'sms',sender:incoming.sender,receiver:null,
body:incoming.body,timestamp:incoming.timestamp,delivery:'received',read:false};
records.push(record);
await this.storage.run('vulpes-private-messages','put',[records,'records']);
@@ -26,16 +24,14 @@ export class Messages {
return work;
}
refreshOutgoing() {
- if (!this.radioStatus && !this.mms) return Promise.resolve();
+ if (!this.radioStatus) return Promise.resolve();
const work=this.queue.then(async()=>{
const records=(await this.storage.run('vulpes-private-messages','get',['records'])).value || [];
for (const message of records.filter(m=>m.delivery==='sending' && m.radioPath)) {
- const result=await (message.type==='mms' ? this.mms.status(message.radioPath) : this.radioStatus(message.radioPath));
+ const result=await this.radioStatus(message.radioPath);
if (!['sent','failed'].includes(result.state)) continue;
message.delivery=result.state==='sent'?'sent':'error';
message.deliveryStatus=result.state==='sent'?'not-applicable':'error';
- if(result.error) message.error=result.error;
- if(message.type==='mms') message.deliveryInfo=message.receivers.map(receiver=>({receiver,deliveryStatus:message.deliveryStatus,readStatus:'not-applicable'}));
await this.storage.run('vulpes-private-messages','put',[records,'records']);
this.notify(result.state,structuredClone(message));
}
@@ -108,34 +104,7 @@ export class Messages {
this.notify('sent', structuredClone(record));
return record;
}
- if (operation === 'sendMMS' && this.mms) {
- const payload=args[0];
- this.mms.validate(payload);
- const receivers=payload.receivers.slice();
- const participants=receivers.slice().sort().join(',');
- const existing=records.find(m=>(m.type==='mms' && m.delivery!=='received' ? m.receivers.slice().sort().join(',') : m.receiver || m.sender)===participants);
- const record={id:nextId++,threadId:existing?.threadId || nextId++,type:'mms',sender:null,
- receivers,subject:payload.subject || '',smil:payload.smil || '',attachments:payload.attachments || [],body:payload.subject || '',
- timestamp:Date.now(),delivery:'sending',deliveryStatus:'pending',read:true,
- deliveryInfo:receivers.map(receiver=>({receiver,deliveryStatus:'pending',readStatus:'not-applicable'}))};
- records.push(record);
- await this.storage.run('vulpes-private-messages','put',[records,'records']);
- this.notify('sending',structuredClone(record));
- try {
- const submitted=await this.mms.send(payload);
- record.radioPath=submitted.path;
- await this.storage.run('vulpes-private-messages','put',[records,'records']);
- return record;
- } catch(error) {
- record.delivery='error';record.deliveryStatus='error';record.error=error.message;
- record.deliveryInfo=receivers.map(receiver=>({receiver,deliveryStatus:'error',readStatus:'not-applicable'}));
- await this.storage.run('vulpes-private-messages','put',[records,'records']);
- this.notify('failed',structuredClone(record));throw error;
- }
- }
- if (['sendMMS', 'retrieveMMS'].includes(operation))
- throw new Error('NotSupportedError');
- if (['send', 'getSmscAddress', 'setSmscAddress'].includes(operation))
+ if (['send', 'sendMMS', 'retrieveMMS', 'getSmscAddress', 'setSmscAddress'].includes(operation))
throw new Error('RadioDisabledError');
if (operation === 'getThreads') {
const threads = new Map();
@@ -146,7 +115,7 @@ export class Messages {
id: message.threadId,
body: message.body,
timestamp: message.timestamp,
- participants: message.delivery==='received' ? [message.sender] : message.type==='mms' ? message.receivers : [message.receiver],
+ participants: [message.sender || message.receiver],
unreadCount: records.filter((m) => m.threadId === message.threadId && !m.read).length,
lastMessageType: message.type,
});
diff --git a/host/NativeCamera.sys.mjs b/host/NativeCamera.sys.mjs
index 4afb80f1..2ae36500 100644
--- a/host/NativeCamera.sys.mjs
+++ b/host/NativeCamera.sys.mjs
@@ -17,9 +17,8 @@ export class NativeCamera {
return await Promise.race([
(async () => {
const pipe=session.process.stdout;
- const header=new DataView(await pipe.read(metadata ? 4 : 8));
- const sequence=metadata ? null : header.getUint32(0,true);
- const length=header.getUint32(metadata ? 0 : 4,true);
+ const sequence=metadata ? null : await pipe.readUint32();
+ const length=await pipe.readUint32();
if(metadata) {
if(length>65536) throw Error('CAMERA_INVALID_FORMAT');
return pipe.readJSON(length);
diff --git a/host/Platform.sys.mjs b/host/Platform.sys.mjs
index 4b8503cc..84fe5cfb 100644
--- a/host/Platform.sys.mjs
+++ b/host/Platform.sys.mjs
@@ -1,12 +1,9 @@
-import {receivedMms} from 'resource://vulpes/services/mms.mjs';
import { authorizePlatform } from 'resource://vulpes/services/platform.mjs';
import {
tundraCapabilities,
- tundraBluetooth,
tundraCallAudio,
tundraHardware,
tundraRadio,
- tundraMms,
tundraSnapshot,
tundraWifi,
} from 'resource://vulpes/host/Tundra.sys.mjs';
@@ -51,25 +48,6 @@ export class Platform {
}
async init() {
this.radioQueue = Promise.resolve();
- if (Services.env.get('VULPES_TUNDRA') === '1') {
- let shown = null;
- const pollBluetooth = async () => {
- try {
- const state = await tundraBluetooth({operation:'status'});
- const pending = state.pairing?.[0];
- const app = this.runtime.apps.find(a=>a.id==='bluetooth');
- if (pending && app && pending.id!==shown) {
- shown=pending.id;
- this.runtime.broadcast('launch',{manifestURL:app.manifestURL,origin:app.origin,
- url:app.origin+'/onpair.html',timestamp:Date.now()},'system');
- }
- if (!pending) shown=null;
- } catch (_) {}
- setTimeout(pollBluetooth,1500);
- };
- pollBluetooth();
- }
-
this.notificationPath = PathUtils.join(PathUtils.profileDir,'vulpes-notifications.json');
let notifications = [];
try { notifications = await IOUtils.readJSON(this.notificationPath); } catch (_) {}
@@ -102,16 +80,6 @@ export class Platform {
await tundraRadio('ackMessages',{ids:[row.receipt]});
}
} catch(error) {console.error('SMS inbox:',error.message);}
- try {
- const rows=await tundraMms('incoming');
- const app=this.runtime.apps.find(a=>a.id==='sms');
- for(const row of rows) {
- const message=await this.runtime.messages.receive(receivedMms(row));
- await this.gaiaNotification(app,{operation:'show',title:message.sender,body:message.body || 'MMS',tag:'sms-'+message.id,data:{threadId:message.threadId}});
- this.runtime.broadcast('sms-received',message,'sms');
- await tundraMms('ack',{ids:[row.receipt]});
- }
- } catch(error) {console.error('MMS inbox:',error.message);}
setTimeout(inbox,2000);
};
inbox();
@@ -168,39 +136,48 @@ export class Platform {
!request.secure ||
request.sharingScreen ||
request.sharingAudio ||
- !(request.videoInputDevices?.length || request.audioInputDevices?.length)
+ request.audioInputDevices?.length ||
+ !request.videoInputDevices?.length
) {
deny();
return;
}
const principal = global.documentPrincipal;
- const video = request.videoInputDevices?.[0], audio = request.audioInputDevices?.[0];
- for (const [device, kind, message] of [
- [video, 'camera', 'Autoriser Photo à utiliser la caméra ? / Allow Camera to use your camera?'],
- [audio, 'microphone', 'Autoriser Photo à enregistrer le son ? / Allow Camera to record audio?']
- ]) {
- if (!device) continue;
- let permission = Services.perms.testExactPermissionFromPrincipal(principal, kind);
- if (permission === Ci.nsIPermissionManager.UNKNOWN_ACTION) {
- permission = Services.prompt.confirm(browser.ownerDocument.defaultView, 'Vulpes — Photo', message)
- ? Ci.nsIPermissionManager.ALLOW_ACTION : Ci.nsIPermissionManager.DENY_ACTION;
- Services.perms.addFromPrincipal(principal, kind, permission);
- }
- if (permission !== Ci.nsIPermissionManager.ALLOW_ACTION) { deny(); return; }
+ let permission = Services.perms.testExactPermissionFromPrincipal(principal, 'camera');
+ if (permission === Ci.nsIPermissionManager.UNKNOWN_ACTION) {
+ const allowed = Services.prompt.confirm(
+ browser.ownerDocument.defaultView,
+ 'Vulpes — Photo',
+ 'Autoriser Photo à utiliser la webcam ? / Allow Camera to use your webcam?',
+ );
+ permission = allowed
+ ? Ci.nsIPermissionManager.ALLOW_ACTION
+ : Ci.nsIPermissionManager.DENY_ACTION;
+ Services.perms.addFromPrincipal(principal, 'camera', permission);
}
- if (!(await actor.checkOSPermission(!!video, !!audio, false))) { deny(); return; }
+ if (permission !== Ci.nsIPermissionManager.ALLOW_ACTION) {
+ deny();
+ return;
+ }
+ if (!(await actor.checkOSPermission(true, false, false))) {
+ deny();
+ return;
+ }
+ // Gecko consumes this one-shot grant when allocating the real device.
+ // Use its top-level principal, as Firefox's WebRTC permission handler does.
const topPrincipal = browser.browsingContext.currentWindowGlobal.documentPrincipal;
- const devices = [];
- for (const [device, kind] of [[video, 'MediaManagerVideo'], [audio, 'MediaManagerAudio']]) {
- if (!device) continue;
- Services.perms.addFromPrincipal(topPrincipal, kind, Ci.nsIPermissionManager.ALLOW_ACTION, Ci.nsIPermissionManager.EXPIRE_SESSION);
- actor.activateDevicePerm(request.windowID, device.mediaSource, device.rawId);
- devices.push(device.deviceIndex);
- }
+ Services.perms.addFromPrincipal(
+ topPrincipal,
+ 'MediaManagerVideo',
+ Ci.nsIPermissionManager.ALLOW_ACTION,
+ Ci.nsIPermissionManager.EXPIRE_SESSION,
+ );
+ const device = request.videoInputDevices[0];
+ actor.activateDevicePerm(request.windowID, device.mediaSource, device.rawId);
actor.sendAsyncMessage('webrtc:Allow', {
callID: request.callID,
windowID: request.windowID,
- devices,
+ devices: [request.videoInputDevices[0].deviceIndex],
});
}
async request(actor, app, operation, params) {
@@ -216,9 +193,6 @@ export class Platform {
if (operation === 'snapshot') return tundraSnapshot();
if (operation === 'power') return tundraHardware({operation:'power', action:params.action});
if (operation === 'wifi') return tundraWifi(params);
- if (operation === 'deviceSettings') return tundraRadio('deviceSettings', params);
- if (operation === 'mobileSettings') return tundraRadio('mobileSettings', params);
- if (operation === 'bluetooth') return tundraBluetooth(params);
if (['callHistory','ackCallHistory'].includes(operation)) return this.radio(operation, params);
if (operation === 'dial') return this.radio('dial', params);
if (operation === 'hangup') return tundraRadio('hangup', params);
diff --git a/host/Runtime.sys.mjs b/host/Runtime.sys.mjs
index 381f999c..0b11845d 100644
--- a/host/Runtime.sys.mjs
+++ b/host/Runtime.sys.mjs
@@ -1,9 +1,6 @@
-import {mmsPayload} from 'resource://vulpes/services/mms.mjs';
-import { setInterval, setTimeout, clearTimeout } from 'resource://gre/modules/Timer.sys.mjs';
+import { setInterval } from 'resource://gre/modules/Timer.sys.mjs';
import {Input} from 'resource://vulpes/host/Input.sys.mjs';
import { ScreenPower } from 'resource://vulpes/services/screen-power.mjs';
-import { Packages } from 'resource://vulpes/services/packages.mjs';
-import { Activities, isGaiaActivity } from 'resource://vulpes/services/activities.mjs';
import { SettingsService } from 'resource://vulpes/services/settings.mjs';
import { DataStoreService } from 'resource://vulpes/services/datastores.mjs';
import { DataStoresIndexedDB } from 'resource://vulpes/adapters/datastores-indexeddb.mjs';
@@ -14,7 +11,7 @@ import { Messages } from 'resource://vulpes/host/Messages.sys.mjs';
import { Alarms } from 'resource://vulpes/host/Alarms.sys.mjs';
import { Platform } from 'resource://vulpes/host/Platform.sys.mjs';
import { Media } from 'resource://vulpes/host/Media.sys.mjs';
-import { tundraCallAudio, tundraHardware, tundraRadio, tundraMms } from 'resource://vulpes/host/Tundra.sys.mjs';
+import { tundraCallAudio, tundraHardware, tundraRadio } from 'resource://vulpes/host/Tundra.sys.mjs';
Cu.importGlobalProperties(['indexedDB']);
// This is the only privileged owner of application identity and settings data.
@@ -32,7 +29,6 @@ export const Runtime = {
wakeLocks: new Map(),
idleListeners: new Map(),
cleanup(actor) {
- this.activities?.cleanup(actor);
this.input?.cleanup(actor);
this.platform?.nativeCamera.cleanup(actor);
this.connections?.cleanup(actor);
@@ -49,22 +45,6 @@ export const Runtime = {
this.input = new Input(this);
const root = Services.env.get('VULPES_WORKSPACE_ROOT');
this.apps = await IOUtils.readJSON(PathUtils.join(root, 'assets', 'registry.json'));
- const storeURL = 'http://system.localhost:8765/_vulpes/packages/';
- const store = async (operation, item) => {
- const token = await IOUtils.readUTF8(PathUtils.join(root,'profiles','installed-apps','.token'));
- const response = await fetch(storeURL+operation,{method:'POST',headers:{'X-Vulpes-Install':token,'Content-Type':'application/json'},body:JSON.stringify(item)});
- if(!response.ok)throw Error('PACKAGE_STORAGE_FAILED');
- return response.json();
- };
- this.refreshInstalled = async () => {
- const response=await fetch(storeURL+'list');if(!response.ok)throw Error('PACKAGE_STORAGE_FAILED');
- const installed=await response.json();
- this.apps.splice(0,this.apps.length,...this.apps.filter(a=>!a.installed),...installed);
- return installed;
- };
- this.packages = new Packages({schedule:setTimeout,unschedule:clearTimeout,save:item=>store('save',item),changed:()=>this.refreshInstalled(),uuid:()=>Services.uuid.generateUUID().toString()});
- this.removePackage = async id => {const app=this.apps.find(a=>a.id===id && a.installed);if(!app)throw Error('NOT_REMOVABLE');
- await store('remove',{id});await this.refreshInstalled();this.broadcast('apps-uninstalled',app);};
// Installed Gaia audio apps must be able to ring when an alarm fires. This
// permission is scoped to their origins in this profile, never web pages.
for (const app of this.apps)
@@ -111,12 +91,6 @@ export const Runtime = {
this.values['vulpes.gaia-restoration.v1'] = true;
}
Services.prefs.setStringPref('intl.accept_languages', this.values['language.current']);
- this.activities = new Activities({identity:actor=>this.identity(actor),apps:()=>this.apps,
- choose:data=>this.broadcast('activity-choice',data,'system'),
- uuid:()=>Services.uuid.generateUUID().toString(),
- notify:(actor,type,data)=>actor.sendAsyncMessage('Vulpes:Event',{type,data}),
- launch:(target,path,{reload=false,entryPoint}={})=>this.broadcast(reload?'activity-launch':'launch',{manifestURL:target.manifestURL,origin:target.origin,
- entryPoint,url:new URL(path || target.manifest.launch_path || '/index.html',target.origin).href,timestamp:Date.now()},'system')});
this.writeQueue = Promise.resolve();
this.settings = new SettingsService({
get: async (key) => this.values[key],
@@ -125,19 +99,6 @@ export const Runtime = {
const values = { ...this.values, ...Object.fromEntries(entries) };
await IOUtils.writeJSON(path, values, { tmpPath: path + '.tmp' });
this.values = values;
- const prefMap={
- 'ui.click_hold_context_menus.delay':'ui.click_hold_context_menus.delay',
- 'accessibility.monoaudio.enable':'accessibility.monoaudio.enable',
- 'geolocation.enabled':'geo.enabled',
- 'privacy.donottrackheader.enabled':'privacy.donottrackheader.enabled',
- 'debug.paint-flashing.enabled':'nglayout.debug.paint_flashing',
- 'apz.overscroll.enabled':'apz.overscroll.enabled'
- };
- for(const [key,value] of entries) {
- const pref=prefMap[key];if(!pref)continue;
- if(typeof value==='boolean')Services.prefs.setBoolPref(pref,value);
- else if(key==='ui.click_hold_context_menus.delay' && [400,800,1600].includes(value))Services.prefs.setIntPref(pref,value);
- }
if (entries.some(([key]) => key === 'language.current'))
Services.prefs.setStringPref('intl.accept_languages', values['language.current']);
if (Services.env.get('VULPES_TUNDRA') === '1') {
@@ -154,7 +115,7 @@ export const Runtime = {
this.writeQueue = write.catch(() => {});
return write;
},
- }, {maxBytes:32 * 1024 * 1024});
+ });
const storage = new DataStoresIndexedDB(indexedDB, IDBKeyRange, () =>
Services.uuid.generateUUID().toString(),
);
@@ -171,8 +132,6 @@ export const Runtime = {
: null,
(type, message) => this.broadcast('sms-' + type, message, 'sms'),
path => tundraRadio('messageState', {path}),
- Services.env.get('VULPES_TUNDRA_RADIO') === '1' ? {validate:mmsPayload,
- send:message=>tundraMms('send',mmsPayload(message)), status:path=>tundraMms('state',{path})} : null,
);
this.media = new Media((data) => this.broadcast('mediachange', data));
this.platform = new Platform(this);
@@ -243,16 +202,6 @@ export const Runtime = {
this.actors.add(actor);
const { method, params } = request;
if (typeof method !== 'string') return { error: { code: 'INVALID_REQUEST' } };
- if(method.startsWith('marketplace.')) {
- if(app.id!=='marketplace')return {error:{code:'PERMISSION_DENIED'}};
- try {
- if(method==='marketplace.poll'){const url=this.marketplaceDownload;this.marketplaceDownload=null;return {result:url||null};}
- if(method==='marketplace.cancel'){this.packages.cancel();return {result:true};}
- if(method==='marketplace.prepare')return {result:await this.packages.prepare(params.url)};
- if(method==='marketplace.install') {const id=await this.packages.install(params.token);const installed=this.apps.find(a=>a.id===id);this.broadcast('apps-installed',installed);return {result:installed};}
- } catch(error) {return {error:{code:error.message}};}
- return {error:{code:'METHOD_NOT_SUPPORTED'}};
- }
if(method==='hardware.vibrate') {
if(app.id!=='system' || Services.env.get('VULPES_TUNDRA')!=='1') return {error:{code:'NOT_SUPPORTED'}};
const pattern=params?.pattern;
@@ -318,20 +267,10 @@ export const Runtime = {
}
}
if (typeof method !== 'string') return { error: { code: 'INVALID_REQUEST' } };
- if (['activities.subscribe','activities.finish','activities.cancel','activities.select'].includes(method) || (method === 'activities.start' && isGaiaActivity(params))) {
- try {
- const result = method === 'activities.select' ? this.activities.select(actor,params)
- : method === 'activities.cancel' ? this.activities.cancel(actor)
- : method === 'activities.subscribe' ? this.activities.subscribe(actor)
- : method === 'activities.finish' ? this.activities.finish(actor,params)
- : await this.activities.start(actor,params);
- return {result:result ?? null};
- } catch(error) {return {error:{code:error.message}};}
- }
if (method === 'activities.start') {
if (params?.name === 'record' && params.data?.type === 'photos') {
- // Gallery and the lockscreen open Gaia Camera without expecting a file result.
- if (['gallery', 'system'].includes(app.id)) {
+ // Gallery's Photo button opens the shared Gaia UI; it expects no result file.
+ if (app.id === 'gallery') {
const camera = this.apps.find((a) => a.id === 'camera');
if (!camera) return { error: { code: 'ACTIVITY_NOT_SUPPORTED' } };
this.broadcast(
@@ -377,28 +316,10 @@ export const Runtime = {
return { error: { code: error.message || 'VIEW_ERROR' } };
}
}
- if (method === 'storage.stats') {
- try {const {storageStats}=ChromeUtils.importESModule('resource://vulpes/host/StorageStats.sys.mjs');return {result:await storageStats(app)};}
- catch(error) {return {error:{code:error.message || 'STORAGE_ERROR'}};}
- }
- if (method === 'downloads.request') {
- try {
- const {GaiaDownloads}=ChromeUtils.importESModule('resource://vulpes/host/Downloads.sys.mjs');
- this.downloads ||= new GaiaDownloads();
- return {result:await this.downloads.request(app,params)};
- } catch(error) {return {error:{code:error.message || 'DOWNLOAD_ERROR'}};}
- }
- if (method === 'appearance.current') return { result: this.values['vulpes.theme'] || 'light' };
- if (method === 'locale.current') return { result: this.values['language.current'] || 'en-US' };
if (method === 'settings.snapshot')
return this.grants(app).includes('settings.read')
? { result: { ...this.values } }
: { error: { code: 'PERMISSION_DENIED' } };
- if (method === 'settings.clockChanged') {
- if(app.id !== 'settings' || Services.env.get('VULPES_TUNDRA') !== '1') return {error:{code:'PERMISSION_DENIED'}};
- Services.obs.notifyObservers(null, 'system-timezone-changed');
- return {result:true};
- }
if (method.startsWith('settings.'))
return this.settings.bindSession(app.id, this.grants(app))(request);
if (method === 'desktop.idle') {
@@ -520,10 +441,6 @@ export const Runtime = {
if (method === 'apps.self') return { result: app };
if (method === 'apps.list')
return manage ? { result: this.apps } : { error: { code: 'PERMISSION_DENIED' } };
- if(method==='apps.uninstall') {
- if(!manage)return {error:{code:'PERMISSION_DENIED'}};
- try {await this.removePackage(params.id);return {result:true};}catch(error){return {error:{code:error.message}};}
- }
if (method === 'apps.launch') {
const target = this.apps.find((a) => a.manifestURL === params?.manifestURL);
if (!target || (!manage && app.id !== target.id))
@@ -552,7 +469,7 @@ export const Runtime = {
try {
const app = this.identity(actor);
if (!app || (target && app.id !== target)) continue;
- if (type === 'settingchange' && !['language.current', 'vulpes.theme'].includes(data.key) && !this.grants(app).includes('settings.read')) continue;
+ if (type === 'settingchange' && !this.grants(app).includes('settings.read')) continue;
if (
type === 'mediachange' &&
!app.manifest.permissions?.['device-storage:' + data.type]?.access?.includes('read')
diff --git a/host/SettingsAudit.sys.mjs b/host/SettingsAudit.sys.mjs
deleted file mode 100644
index 952b0144..00000000
--- a/host/SettingsAudit.sys.mjs
+++ /dev/null
@@ -1,133 +0,0 @@
-// SPDX-License-Identifier: MPL-2.0
-// Settings navigation and bounded readback tests, enabled by a private marker.
-export async function auditSettings({evaluate,wait,sleep,save}) {
- const context=new Set(['wifi-status','wifi-auth','wifi-joinHidden','wifi-wps','wifi-enterCertificateNickname','call-cf-not-reachable-settings','call-cf-no-reply-settings','call-cf-mobile-busy-settings','call-cf-unconditional-settings','call-voiceMailSettings','hotspot-wifiSettings','screenLock-passcode','apn-editor','apn-list','appPermissions-details','addon-details','homescreen-details','frame','simpin-dialog','call-fdnList-add','call-barring-passcode-change','settings-alert-dialog','settings-confirm-dialog','settings-prompt-dialog','ums-partial-warning','template-messaging','full-developer-mode-warning','full-developer-mode-final-warning']);
- await evaluate('system','ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});await VulpesCompat.call("apps.launch",{manifestURL:"http://settings.localhost:8765/manifest.webapp"});return true;');
- await wait('settings','return document.readyState==="complete" && typeof require==="function";');
- const ids=await evaluate('settings','return Array.from(document.querySelectorAll("section[id]"),e=>e.id);');
- const report={scope:'navigation only; no destructive actions, SIM PIN attempts or subscriptions',panels:{}};
- for(const id of ids) {
- if(context.has(id)){report.panels[id]={contextRequired:true};continue;}
- try {
- await evaluate('settings',`window.__vulpesDiagnostics=[];require(['modules/settings_service'],s=>s.navigate(${JSON.stringify(id)},${JSON.stringify(id==='messaging-details'?{cardIndex:0}:{})}));return true;`);
- await wait('settings',`return document.querySelector('section.current')?.id===${JSON.stringify(id)};`,25000);
- // Slow carrier queries continue after the panel appears. Wait for those
- // controls before leaving, rather than flooding the radio worker while
- // sweeping every panel in a few milliseconds.
- if(id==='call')await wait('settings',"return !document.querySelector('#ril-callerId').disabled;",30000);
- if(id==='call-forwarding')await wait('settings',"return !!document.querySelector('#call-forwarding .callForwardingUnconditional[aria-disabled=false]');",30000);
- await sleep(450);
- report.panels[id]=await evaluate('settings',`const p=document.getElementById(${JSON.stringify(id)});return {opened:true,title:p.querySelector('h1')?.textContent,unavailable:!!p.querySelector('[data-vulpes-unavailable]'),links:Array.from(p.querySelectorAll('a[href^="#"]'),e=>e.getAttribute('href')),controls:Array.from(p.querySelectorAll('input,select,gaia-switch,gaia-checkbox'),e=>({name:e.name||e.id||e.getAttribute('name'),disabled:!!e.disabled,hidden:!e.checkVisibility()})),errors:window.__vulpesDiagnostics};`);
- } catch(error) {
- report.panels[id]={opened:false,error:String(error),errors:await evaluate('settings','return window.__vulpesDiagnostics;')};
- await evaluate('settings','location.reload();return true;');await sleep(1500);
- await wait('settings','return document.readyState==="complete" && typeof require==="function";');
- }
- await save(report);
- }
- report.dialogs={};
- for(const [id,method,options] of [
- ['settings-confirm-dialog','confirm',{}],['settings-prompt-dialog','prompt',{}],['settings-alert-dialog','alert',{}],
- ['wifi-auth','show',{network:{ssid:'Audit — not connected',security:['WPA-PSK']},security:'WPA-PSK',sl:3}],
- ['wifi-status','show',{network:{ssid:'Audit — not connected',security:[]},security:'',sl:3}],
- ['wifi-joinHidden','show',{}],['screenLock-passcode','show',{mode:'create'}]
- ]) {
- try {
- await evaluate('settings',`window.__dialogResult=null;window.__vulpesDiagnostics=[];require(['modules/dialog_service'],d=>d.${method}(${JSON.stringify(method==='show'?id:'cancel')},${JSON.stringify(options)}).then(r=>window.__dialogResult=r,e=>window.__dialogResult={error:String(e)}));return true;`);
- await wait('settings',`return document.getElementById(${JSON.stringify(id)}).classList.contains('current');`,3000);
- await evaluate('settings',`const p=document.getElementById(${JSON.stringify(id)});const button=p.querySelector('button[type=reset]')||p.querySelector('button[type=submit]');if(button)button.click();else p.querySelector('gaia-header')?.dispatchEvent(new CustomEvent('action'));return true;`);
- await wait('settings',`return !!window.__dialogResult;`,3000);
- report.dialogs[id]=await evaluate('settings',`return {result:window.__dialogResult,errors:window.__vulpesDiagnostics,closed:!document.getElementById(${JSON.stringify(id)}).classList.contains('current')};`);
- } catch(error) {
- report.dialogs[id]={error:String(error),errors:await evaluate('settings','return window.__vulpesDiagnostics;')};
- await evaluate('settings','location.reload();return true;');await sleep(1500);
- await wait('settings','return document.readyState==="complete" && typeof require==="function";');
- }
- await save(report);
- }
- report.effects={};
- for(const [name,code] of Object.entries({
- storage:'return await VulpesCompat.call("storage.stats",{});',
- modemReadback:'const read=await VulpesCompat.call("platform.mobileSettings",{action:"network"});if(typeof read.enabled!=="boolean")throw Error("Unknown data state");const after=await VulpesCompat.call("platform.mobileSettings",{action:"dataEnabled",enabled:read.enabled});return {same:read.enabled===after.enabled,registered:after.status,technology:after.technology};',
- clockReadback:'const read=await VulpesCompat.call("platform.deviceSettings",{action:"clock"});const after=await VulpesCompat.call("platform.deviceSettings",{action:"setTimezone",timezone:read.timezone});return {same:read.timezone===after.timezone,timezone:after.timezone,automaticAvailable:after.automaticAvailable};'
- })) {
- try {report.effects[name]=await evaluate('settings',code);}
- catch(error){report.effects[name]={error:String(error)};}
- }
- report.networkUi={};
- try {
- await evaluate('settings',"window.__vulpesDiagnostics=[];require(['modules/settings_service'],s=>s.navigate('operator-settings'));return true;");
- await wait('settings',"return document.querySelector('#operator-settings.current select:not([disabled])')?.options.length>0;",7000);
- report.networkUi.radio=await evaluate('settings',"const s=document.querySelector('#operator-settings select');s.dispatchEvent(new Event('change',{bubbles:true}));return {mode:s.value,choices:Array.from(s.options,o=>o.value)};");
- await wait('settings',"return !document.querySelector('#operator-settings select').disabled;",7000);
- report.networkUi.radio.same=await evaluate('settings',`return document.querySelector('#operator-settings select').value===${JSON.stringify(report.networkUi.radio.mode)};`);
- for(const type of ['default','mms']) {
- await evaluate('settings',`require(['modules/settings_service'],s=>s.navigate('apn-settings'));return true;`);
- await wait('settings',"return !!document.querySelector('#apn-settings.current [data-apn-type]');",4000);
- await evaluate('settings',`document.querySelector('#apn-settings [data-apn-type=${type}]').click();return true;`);
- await wait('settings',"return !!document.querySelector('#apn-list.current label.name');",7000);
- await evaluate('settings',"document.querySelector('#apn-list label.name').dispatchEvent(new MouseEvent('mouseup',{bubbles:true}));return true;");
- await wait('settings',"return !!document.querySelector('#apn-editor.current .apn:not([disabled])');",7000);
- report.networkUi[type]=await evaluate('settings',"const p=document.querySelector('#apn-editor');return {opened:true,apnPopulated:!!p.querySelector('.apn').value,passwordBlank:p.querySelector('.password').value==='',protocol:p.querySelector('.protocol').value,authentication:p.querySelector('.authtype').value,mmscEditable:!p.querySelector('.mmsc').disabled};");
- // Submit the current APN unchanged, through Gaia. Never change operator
- // credentials, activate a bearer or expose any APN/user/password in logs.
- await evaluate('settings',"const p=document.querySelector('#apn-editor');p.querySelector('.apn').dispatchEvent(new Event('input',{bubbles:true}));p.querySelector('.ok').click();return true;");
- await wait('settings',"return !!document.querySelector('#apn-list.current label.name');",7000);
- report.networkUi[type].savedUnchanged=true;
- }
- report.networkUi.errors=await evaluate('settings','return window.__vulpesDiagnostics;');
- } catch(error) {report.networkUi.error=String(error);report.networkUi.errors=await evaluate('settings','return window.__vulpesDiagnostics;');}
- report.supplementaryUi={};
- try {
- await evaluate('settings',"window.__vulpesDiagnostics=[];require(['modules/settings_service'],s=>s.navigate('call'));return true;");
- await wait('settings',"return !!document.querySelector('#call.current #ril-callerId:not([disabled])');",30000);
- report.supplementaryUi.calls=await evaluate('settings',"return {waitingEnabled:!document.querySelector('#menuItem-callWaiting gaia-switch').disabled,callerEnabled:!document.querySelector('#ril-callerId').disabled};");
- await evaluate('settings',"document.querySelector('#ril-callerId').dispatchEvent(new Event('change',{bubbles:true}));return true;");
- await wait('settings',"return !document.querySelector('#ril-callerId').disabled;",50000);
- report.supplementaryUi.callerSavedUnchanged=true;
- await evaluate('settings',"require(['modules/settings_service'],s=>s.navigate('call-forwarding'));return true;");
- await wait('settings',"return !!document.querySelector('#call-forwarding.current .callForwardingUnconditional[aria-disabled=false]');",30000);
- report.supplementaryUi.dialogs={};
- for(const [selector,id] of [
- ['.callForwardingUnconditional','call-cf-unconditional-settings'],['.callForwardingMobileBusy','call-cf-mobile-busy-settings'],
- ['.callForwardingNoReply','call-cf-no-reply-settings'],['.callForwardingNotReachable','call-cf-not-reachable-settings']
- ]) {
- await evaluate('settings',`document.querySelector('#call-forwarding ${selector}').click();return true;`);
- await wait('settings',`return document.getElementById('${id}').classList.contains('current');`,4000);
- await evaluate('settings',`document.querySelector('#${id} button[type=reset]').click();return true;`);
- await wait('settings',"return document.getElementById('call-forwarding').classList.contains('current');",4000);
- report.supplementaryUi.dialogs[id]={opened:true,cancelled:true};
- }
- report.supplementaryUi.errors=await evaluate('settings','return window.__vulpesDiagnostics;');
- } catch(error) {report.supplementaryUi.error=String(error);report.supplementaryUi.errors=await evaluate('settings','return window.__vulpesDiagnostics;');}
- report.hotspotUi={};
- try {
- await evaluate('settings',"window.__vulpesDiagnostics=[];require(['modules/settings_service'],s=>s.navigate('hotspot'));return true;");
- await wait('settings',"return !!document.querySelector('#hotspot.current #tethering-wifi-enabled:not([disabled])');",10000);
- await evaluate('settings',"document.querySelector('#hotspot-settings-section a').click();return true;");
- await wait('settings',"return document.getElementById('hotspot-wifiSettings').classList.contains('current');",4000);
- report.hotspotUi=await evaluate('settings',"const p=document.getElementById('hotspot-wifiSettings');return {opened:true,securityChoices:p.querySelector('select').options.length,passwordMasked:p.querySelector('input[name=password]').type==='password'};");
- await evaluate('settings',"document.querySelector('#hotspot-wifiSettings button[type=reset]').click();return true;");
- await wait('settings',"return document.getElementById('hotspot').classList.contains('current') && !document.getElementById('hotspot-wifiSettings').classList.contains('current') && !document.querySelector('#tethering-wifi-enabled').disabled;",4000);
- report.hotspotUi.cancelled=true;
- const unconfigured=await evaluate('settings',"const s=await VulpesCompat.call('platform.wifi',{operation:'hotspotStatus'});return !s.configured && !s.enabled;");
- if(unconfigured) {
- await evaluate('settings',"document.querySelector('#hotspot-settings-section a').click();return true;");
- await wait('settings',"return document.getElementById('hotspot-wifiSettings').classList.contains('current');",4000);
- await evaluate('settings',"const p=document.getElementById('hotspot-wifiSettings');p.querySelector('input[type=text]').value='Vulpes-qualification';p.querySelector('select').value='wpa2-psk';p.querySelector('input[name=password]').value=crypto.randomUUID();p.querySelector('button[type=submit]').click();return true;");
- await wait('settings',"return !!document.querySelector('#hotspot.current #tethering-wifi-enabled:not([disabled])') && !document.getElementById('hotspot-wifiSettings').classList.contains('current');",10000);
- report.hotspotUi.configuredByAudit=true;
- for(const enabled of [true,false]) {
- await evaluate('settings',`const c=document.querySelector('#tethering-wifi-enabled');c.checked=${enabled};c.dispatchEvent(new Event('change',{bubbles:true}));return true;`);
- await wait('settings',`const c=document.querySelector('#tethering-wifi-enabled');return !c.disabled && c.checked===${enabled};`,90000);
- const actual=await evaluate('settings',"return (await VulpesCompat.call('platform.wifi',{operation:'hotspotStatus'})).enabled;");
- if(actual!==enabled)throw Error('Hotspot UI/readback mismatch');
- report.hotspotUi[enabled?'started':'stopped']=true;
- }
- }
- report.hotspotUi.errors=await evaluate('settings','return window.__vulpesDiagnostics;');
- } catch(error) {report.hotspotUi.error=String(error);report.hotspotUi.errors=await evaluate('settings','return window.__vulpesDiagnostics;');report.hotspotUi.formState=await evaluate('settings',"const p=document.getElementById('hotspot-wifiSettings');return {current:p.classList.contains('current'),message:p.querySelector('[role=status]')?.textContent,submitDisabled:p.querySelector('button[type=submit]')?.disabled};");}
- report.scope='panel navigation plus storage read and idempotent data/timezone/radio/APN write/readback; temporary hotspot start/stop when initially unconfigured; no destructive actions, PIN attempts or subscriptions';
- await evaluate('settings','require(["modules/settings_service"],s=>s.navigate("root"));return true;');
- return report;
-}
diff --git a/host/SettingsFollowupAudit.sys.mjs b/host/SettingsFollowupAudit.sys.mjs
deleted file mode 100644
index 38357bd5..00000000
--- a/host/SettingsFollowupAudit.sys.mjs
+++ /dev/null
@@ -1,56 +0,0 @@
-// Private qualification entry point, invoked only by a test marker/profile.
-export async function auditFollowup({evaluate,wait,sleep,save,tap}) {
- const report={checks:{},errors:{},mediaRetained:false};
- const launch=async app=>{
- await evaluate('system',`ScreenManager.turnScreenOn(true);if(Service.query('locked'))await Service.request('unlock',{forcibly:true});await VulpesCompat.call('apps.launch',{manifestURL:'http://${app}.localhost:8765/manifest.webapp'});return true;`);
- await wait(app,'return document.readyState==="complete" && !document.hidden;');
- await wait('system',`return Service.query('getActiveApp')?.origin==='http://${app}.localhost:8765';`);
- };
- await wait('system',`return document.body?.getAttribute('ready-state')==='fullyLoaded';`,30000);
- await sleep(1000);
- try {
- await launch('settings');await wait('settings','return typeof require==="function";');
- await evaluate('settings',`const s=await new Promise(r=>require(['modules/settings_service'],r));await s.navigate('dateTime');return true;`);
- await sleep(800);
- // Test the actual Gaia picker without changing the user's system clock.
- await evaluate('settings',`if(!navigator.mozTime){window.__testClockInstalled=true;Object.defineProperty(navigator,'mozTime',{value:{},configurable:true});}window.__dateModule=await new Promise(r=>require(['modules/date_time'],r));window.__oldMozTime=__dateModule._mozTime;window.__pickedTime=null;__dateModule._mozTime={set:d=>{window.__pickedTime=+d;return Promise.resolve();}};document.querySelector('#dateTime .time-manual').classList.remove('disabled');for(const e of document.querySelectorAll('#dateTime input'))e.disabled=false;return true;`);
- for(const type of ['date','time']) {
- await evaluate('settings',`const e=document.querySelector('#dateTime .${type}-picker');e.disabled=false;e.click();return true;`);
- await wait('system',`const p=Service.query('getActiveApp')?.valueSelector;return !!(p?.isShown && p._${type}Picker);`);
- report.checks[type+'Picker']=await evaluate('system',`const p=Service.query('getActiveApp').valueSelector;const e=p.elements.${type==='date'?'spinDate':'time'}PickerPopup;return {visible:e.checkVisibility(),height:e.getBoundingClientRect().height};`);
- await evaluate('system',`Service.query('getActiveApp').valueSelector.confirm();return true;`);
- await wait('settings','return Number.isFinite(window.__pickedTime);');
- report.checks[type+'Commit']=true;
- await evaluate('settings','window.__pickedTime=null;return true;');
- }
- await evaluate('settings',`__dateModule._mozTime=__oldMozTime;const s=await new Promise(r=>require(['modules/settings_service'],r));await s.navigate('keyboard');return true;`);
- await launch('keyboard');
- await wait('keyboard','return !!document.querySelector("#general gaia-header");');
- await evaluate('keyboard',`document.querySelector('#general gaia-header').dispatchEvent(new CustomEvent('action'));return true;`);
- await wait('system',`return Service.query('getActiveApp')?.origin==='http://settings.localhost:8765';`);
- report.checks.keyboardBack=true;
- } catch(error){report.errors.settings=String(error);}
- finally {
- await evaluate('settings','if(window.__dateModule)__dateModule._mozTime=window.__oldMozTime;if(window.__testClockInstalled)delete navigator.mozTime;return true;').catch(()=>{});
- }
- await save(report);
- try {
- await launch('camera');
- await wait('camera','return !!window.app?.camera?.mozCamera && !app.camera.isBusy;');
- await evaluate('camera',`window.__vulpesDiagnostics=[];window.__lastVideo=null;app.once('newmedia',v=>{window.__lastVideo=v;});app.settings.mode.select('video');return true;`);
- await wait('camera','return app.camera.mode==="video" && !app.camera.isBusy;');
- await tap('camera','.js-capture');
- await wait('camera','return app.camera.get("recording")==="started";');
- await sleep(3000);
- await tap('camera','.js-capture');
- await wait('camera','return !!window.__lastVideo?.blob;',20000);
- report.checks.video=await evaluate('camera',`const v=window.__lastVideo;const el=document.createElement('video');el.muted=true;el.src=URL.createObjectURL(v.blob);try{await new Promise((ok,fail)=>{el.onloadeddata=ok;el.onerror=()=>fail(Error('Recorded video cannot be decoded'));});return {bytes:v.blob.size,mime:v.blob.type,path:v.filepath,width:el.videoWidth,height:el.videoHeight,poster:v.poster.blob.size};}finally{URL.revokeObjectURL(el.src);el.remove();}`);
- report.errors.camera=await evaluate('camera','return __vulpesDiagnostics.filter(x=>x.kind==="error" || x.kind==="rejection");');
- } catch(error){report.errors.video=String(error);report.errors.camera=await evaluate('camera','return window.__vulpesDiagnostics;').catch(()=>[]);}
- finally {
- await evaluate('camera',`await app.camera.mozCamera?.vulpesFinishRecording?.();if(window.__lastVideo){await navigator.getDeviceStorage('videos').delete(__lastVideo.filepath);await navigator.getDeviceStorage('pictures').delete(__lastVideo.poster.filepath);}app.settings.mode.select('picture');return true;`).catch(()=>{});
- await evaluate('system',"dispatchEvent(new Event('home'));return true;").catch(()=>{});
- }
- report.passed=!Object.values(report.errors).some(x=>Array.isArray(x)?x.length:!!x) && !!report.checks.video;
- await save(report);return report;
-}
diff --git a/host/StorageStats.sys.mjs b/host/StorageStats.sys.mjs
deleted file mode 100644
index 0c07bd25..00000000
--- a/host/StorageStats.sys.mjs
+++ /dev/null
@@ -1,28 +0,0 @@
-// SPDX-License-Identifier: MPL-2.0
-// Only byte counts for Vulpes-owned storage cross the application boundary.
-let pending, cached;
-export async function storageStats(app) {
- if(app.id!=='settings')throw Error('PERMISSION_DENIED');
- if(cached && Date.now()-cached.at<5000)return cached.value;
- if(pending)return pending;
- pending=(async()=>{
- async function bytes(path,skip='') {
- if(path===skip || !await IOUtils.exists(path))return 0;
- const file=Cc['@mozilla.org/file/local;1'].createInstance(Ci.nsIFile);file.initWithPath(path);
- if(file.isSymlink())return 0;
- const info=await IOUtils.stat(path);
- if(info.type==='regular')return info.size;
- if(info.type!=='directory')return 0;
- let total=0;
- for(const child of await IOUtils.getChildren(path)) {
- try {total+=await bytes(child,skip);} catch(error) {if(error.name!=='NotFoundError')throw error;}
- }
- return total;
- }
- const profile=PathUtils.profileDir,media=PathUtils.join(profile,'vulpes-media');
- const file=Cc['@mozilla.org/file/local;1'].createInstance(Ci.nsIFile);file.initWithPath(profile);
- const value={free:file.diskSpaceAvailable,apps:await bytes(profile,media),sdcard:await bytes(media),scope:'vulpes-profile'};
- cached={at:Date.now(),value};return value;
- })().finally(()=>{pending=null;});
- return pending;
-}
diff --git a/host/Tundra.sys.mjs b/host/Tundra.sys.mjs
index 3c5f29e9..a6667461 100644
--- a/host/Tundra.sys.mjs
+++ b/host/Tundra.sys.mjs
@@ -42,7 +42,6 @@ export function tundraCapabilities(release) {
dial: radio,
callControl: radio,
sms: radio,
- mms: radio,
alarms: false,
hardwareStatus: true,
wifiControl: Services.env?.get('VULPES_TUNDRA_WIFI') === '1',
@@ -51,7 +50,7 @@ export function tundraCapabilities(release) {
export async function tundraRadio(operation, params = {}) {
if (Services.env.get('VULPES_TUNDRA_RADIO') !== '1') throw Error('RADIO_UNAVAILABLE');
- if (!['deviceSettings', 'mobileSettings', 'status', 'calls', 'dial', 'hangup', 'answer', 'sms', 'incomingMessages', 'ackMessages', 'messageState'].includes(operation)) throw Error('RADIO_INVALID_REQUEST');
+ if (!['status', 'calls', 'dial', 'hangup', 'answer', 'sms', 'incomingMessages', 'ackMessages', 'messageState'].includes(operation)) throw Error('RADIO_INVALID_REQUEST');
const { Subprocess } = ChromeUtils.importESModule('resource://gre/modules/Subprocess.sys.mjs');
const process = await Subprocess.call({
command: '/usr/bin/python3',
@@ -79,8 +78,8 @@ export async function tundraRadio(operation, params = {}) {
throw Error('RADIO_INVALID_REPLY');
}
if (reply?.ok === false) {
- const errors = ['RADIO_NOT_REGISTERED', 'RADIO_BUSY', 'RADIO_NOT_READY', 'RADIO_TIMEOUT',
- 'RADIO_REQUEST_FAILED', 'RADIO_READBACK_FAILED', 'INVALID_REQUEST', 'APN_IN_USE', 'APN_NOT_FOUND', 'APN_WRITE_FAILED', 'APN_ROLLBACK_FAILED', 'CLOCK_REQUEST_FAILED', 'CLOCK_UNAVAILABLE', 'INVALID_NUMBER', 'INVALID_SMS', 'INVALID_CALL'];
+ const errors = ['RADIO_NOT_REGISTERED', 'RADIO_BUSY', 'RADIO_NOT_READY',
+ 'RADIO_REQUEST_FAILED', 'INVALID_NUMBER', 'INVALID_SMS', 'INVALID_CALL'];
throw Error(errors.includes(reply.error) ? reply.error : 'RADIO_REQUEST_FAILED');
}
if (reply?.ok !== true) throw Error('RADIO_INVALID_REPLY');
@@ -93,11 +92,11 @@ const WIFI_SCAN_CACHE_MS = 5000;
export async function tundraWifi(params) {
if (Services.env.get('VULPES_TUNDRA_WIFI') !== '1') throw Error('NOT_SUPPORTED');
- if (!['scan', 'connect', 'connectHidden', 'disconnect', 'status', 'setEnabled', 'known', 'forget','hotspotStatus','hotspotSave','hotspotPower'].includes(params.operation))
+ if (!['scan', 'connect', 'disconnect', 'status', 'setEnabled', 'known', 'forget'].includes(params.operation))
throw Error('WIFI_INVALID_REQUEST');
if (params.operation !== 'scan') {
try { return await wifiCommand(params); }
- finally { if (['connect','connectHidden','disconnect','forget','setEnabled','hotspotPower'].includes(params.operation)) wifiScanCache = null; }
+ finally { wifiScanCache = null; }
}
// System and Settings share a broker: at most one Python scan client exists.
if (wifiScanPending) return wifiScanPending;
@@ -177,33 +176,3 @@ export async function tundraCallAudio(params) {
if (reply.error) throw Error(reply.error);
return reply.result;
}
-
-
-// Separate socket and bounded payload so MMS traffic cannot block call control.
-export async function tundraMms(operation, params={}) {
- if(Services.env.get('VULPES_TUNDRA_RADIO')!=='1') throw Error('NotSupportedError');
- if(!['send','state','incoming','ack'].includes(operation)) throw Error('INVALID_REQUEST');
- const {Subprocess}=ChromeUtils.importESModule('resource://gre/modules/Subprocess.sys.mjs');
- const process=await Subprocess.call({command:'/usr/bin/python3',arguments:['/etc/tundra/mms-control.py','--client'],stderr:'ignore'});
- await process.stdin.write(JSON.stringify({version:1,operation,...params}));await process.stdin.close();
- let output='';for(;;){const chunk=await process.stdout.readString();if(!chunk)break;output+=chunk;if(output.length>1228800){process.kill();throw Error('MMS_TOO_LARGE');}}
- const {exitCode}=await process.wait();if(exitCode)throw Error('MMS_UNAVAILABLE');
- const reply=JSON.parse(output);if(reply.ok!==true)throw Error(reply.error || 'MMS_UNAVAILABLE');return reply.result;
-}
-
-// A persistent daemon owns BlueZ discovery and pending pairing requests.
-export async function tundraBluetooth(params) {
- if (Services.env.get('VULPES_TUNDRA') !== '1') throw Error('NotSupportedError');
- const {Subprocess}=ChromeUtils.importESModule('resource://gre/modules/Subprocess.sys.mjs');
- const process=await Subprocess.call({command:'/usr/bin/python3',
- arguments:['/etc/tundra/bluetooth-control.py','--client'],stderr:'ignore'});
- await process.stdin.write(JSON.stringify({...params,version:1}));await process.stdin.close();
- let output='';
- for (;;) {
- const chunk=await process.stdout.readString();if(!chunk)break;
- output+=chunk;if(output.length>65536){process.kill();throw Error('BLUETOOTH_INVALID_REPLY');}
- }
- const {exitCode}=await process.wait();if(exitCode)throw Error('BLUETOOTH_UNAVAILABLE');
- const reply=JSON.parse(output);if(reply.error)throw Error(reply.error);
- return reply.result;
-}
diff --git a/host/Views.sys.mjs b/host/Views.sys.mjs
index 3884c1cd..20ebed76 100644
--- a/host/Views.sys.mjs
+++ b/host/Views.sys.mjs
@@ -15,52 +15,12 @@ export const Views = {
);
Services.ppmm.sharedData.flush();
},
- hideAll() {
- for (const entry of this.entries.values()) entry.browser.style.visibility='hidden';
- },
- async layout(entry) {
- const geometry=entry.geometry;
- if (!geometry) return;
- let {left,top,width,height,visible}=geometry;
- let right=left+width,bottom=top+height;
- try {
- // A child document can remain visible while its owning Gaia app is hidden.
- // Check every embedding frame in its own process, up to the system app.
- for (let child=entry.actor.browsingContext;child.parent;child=child.parent) {
- const state=await child.parent.currentWindowGlobal.getActor('Vulpes').sendQuery(
- 'Vulpes:ViewState',{contextId:child.id});
- visible=visible && state.visible;
- if (!visible) break;
- left=Math.max(left,state.left);top=Math.max(top,state.top);
- right=Math.min(right,state.right);bottom=Math.min(bottom,state.bottom);
- }
- } catch (_) {visible=false;}
- if (entry.geometry!==geometry || !entry.browser.isConnected) return;
- const shell=entry.browser.ownerDocument.defaultView;
- const bounds=shell.document.getElementById('screen').getBoundingClientRect();
- const x=shell.mozInnerScreenX+bounds.left,y=shell.mozInnerScreenY+bounds.top;
- left=Math.max(x,left);top=Math.max(y,top);
- right=Math.min(x+bounds.width,right);bottom=Math.min(y+bounds.height,bottom);
- Object.assign(entry.browser.style,{left:(left-x)+'px',top:(top-y)+'px',
- width:Math.max(0,right-left)+'px',height:Math.max(0,bottom-top)+'px',
- visibility:visible && right>left && bottom>top?'visible':'hidden'});
- },
prune() {
for (const [id, entry] of this.entries) {
if (!BrowsingContext.get(id)?.parent) {
this.remove(id, entry);
continue;
}
- this.layout(entry);
- const document=entry.browser.browsingContext?.currentWindowGlobal;
- const page=document?.documentURI.spec || '';
- // A retry keeps the previous error document until the next navigation commits.
- if (document?.innerWindowId!==entry.previousDocumentId &&
- /^about:(neterror|certerror|tabcrashed)/.test(page) && entry.errorPage!==page) {
- entry.errorPage=page;
- try {entry.actor.sendAsyncMessage('Vulpes:Event',{type:'view',
- data:{contextId:id,type:'error',detail:{type:'other'}}});} catch (_) {}
- }
// A detached iframe's BrowsingContext can outlive its DOM element.
// Ask the trusted child actor about actual ownership, not just the BC tree.
if (entry.checking) continue;
@@ -163,9 +123,7 @@ export const Views = {
if (flags & Ci.nsIWebProgressListener.STATE_STOP) {
emit('loadend', { backgroundColor: 'rgb(255, 255, 255)' });
emit('titlechange', browser.contentTitle || browser.currentURI.spec);
- // Replacing a navigation cancels the previous request normally.
- if (!Components.isSuccessCode(status) && status !== Cr.NS_BINDING_ABORTED)
- emit('error', { type: 'other', status });
+ if (!Components.isSuccessCode(status)) emit('error', { type: 'other', status });
}
},
onProgressChange() {},
@@ -185,8 +143,6 @@ export const Views = {
this.entries.set(contextId, entry);
}
entry.url = url;
- entry.errorPage=null;
- entry.previousDocumentId=entry.browser.browsingContext?.currentWindowGlobal?.innerWindowId;
entry.browser.loadURI(uri, {
triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
});
@@ -197,8 +153,22 @@ export const Views = {
if (operation === 'geometry') {
const { left, top, width, height, visible } = geometry || {};
if (![left, top, width, height].every(Number.isFinite)) throw new Error('INVALID_GEOMETRY');
- entry.geometry={left,top,width,height,visible};
- await this.layout(entry);
+ const shell = browser.ownerDocument.defaultView,
+ screen = shell.document.getElementById('screen');
+ const bounds = screen.getBoundingClientRect();
+ const x = left - shell.mozInnerScreenX - bounds.left;
+ const y = top - shell.mozInnerScreenY - bounds.top;
+ const right = Math.min(480, x + width),
+ bottom = Math.min(800, y + height);
+ const clippedX = Math.max(0, x),
+ clippedY = Math.max(0, y);
+ Object.assign(browser.style, {
+ left: clippedX + 'px',
+ top: clippedY + 'px',
+ width: Math.max(0, right - clippedX) + 'px',
+ height: Math.max(0, bottom - clippedY) + 'px',
+ visibility: visible && right > clippedX && bottom > clippedY ? 'visible' : 'hidden',
+ });
return null;
}
if (operation === 'back') {
diff --git a/host/actors/MarketplaceChild.sys.mjs b/host/actors/MarketplaceChild.sys.mjs
deleted file mode 100644
index 7181ffd4..00000000
--- a/host/actors/MarketplaceChild.sys.mjs
+++ /dev/null
@@ -1,7 +0,0 @@
-export class VulpesMarketplaceChild extends JSWindowActorChild {
- handleEvent() {
- const scope={};
- Services.scriptloader.loadSubScript('resource://vulpes/host/marketplace-page.js',scope);
- scope.installMarketplacePage(this.contentWindow,url=>this.sendAsyncMessage('Marketplace:Download',{url}));
- }
-}
diff --git a/host/actors/MarketplaceParent.sys.mjs b/host/actors/MarketplaceParent.sys.mjs
deleted file mode 100644
index f59695ca..00000000
--- a/host/actors/MarketplaceParent.sys.mjs
+++ /dev/null
@@ -1,13 +0,0 @@
-import { Runtime } from 'resource://vulpes/host/Runtime.sys.mjs';
-import { Views } from 'resource://vulpes/host/Views.sys.mjs';
-import { packageURL } from 'resource://vulpes/services/packages.mjs';
-export class VulpesMarketplaceParent extends JSWindowActorParent {
- receiveMessage(message) {
- if(message.name!=='Marketplace:Download' || this.manager.documentPrincipal.originNoSuffix!=='https://vulpes-os.org')return;
- const view=[...Views.entries.values()].find(v=>v.browser.browsingContext.top.id===this.browsingContext.top.id);
- if(!view || !Runtime.identity(view.actor))return;
- Runtime.marketplaceDownload=packageURL(message.data.url);
- const app=Runtime.apps.find(a=>a.id==='marketplace');
- Runtime.broadcast('launch',{manifestURL:app.manifestURL,origin:app.origin,url:app.origin+'/index.html',timestamp:Date.now()},'system');
- }
-}
diff --git a/host/actors/VulpesChild.sys.mjs b/host/actors/VulpesChild.sys.mjs
index 960dd01f..ed38d710 100644
--- a/host/actors/VulpesChild.sys.mjs
+++ b/host/actors/VulpesChild.sys.mjs
@@ -1,20 +1,16 @@
-import {InputEditor} from 'resource://vulpes/services/input-editor.mjs';
export class VulpesChild extends JSWindowActorChild {
handleEvent(event) {
- if(['focusin','pointerdown','click'].includes(event.type)) {
- if(this._editor?.editing) return;
+ if(['focusin','pointerdown'].includes(event.type)) {
+ if(this.editing) return;
if(!Services.cpmm.sharedData.get('vulpes:native-input')) return;
let element=event.target;
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) {
- // Dismissing on pointerdown moves buttons before pointerup and loses
- // the click. Composer actions may also explicitly retain the editor.
- if(event.type==='click' && !element?.closest?.('[data-vulpes-keep-input]'))
- this.sendAsyncMessage('Vulpes:InputFocus',null);
+ if(event.type==='pointerdown') this.sendAsyncMessage('Vulpes:InputFocus',null);
return;
}
- this.editor.focus(element);
+ this.inputElement=element;
this.sendAsyncMessage('Vulpes:InputFocus',this.inputState());
return;
}
@@ -80,39 +76,71 @@ export class VulpesChild extends JSWindowActorChild {
{ defineAs: 'vulpesFrameForContext' },
);
}
- get editor() { return this._editor ||= new InputEditor(this.contentWindow); }
- inputState() { return this.editor.state(); }
- edit(params) { return this.editor.edit(params); }
+ inputState() {
+ const e=this.inputElement;
+ const text=e.isContentEditable ? e.textContent : e.value;
+ const selection=this.contentWindow.getSelection();
+ let start=e.selectionStart ?? text.length, end=e.selectionEnd ?? start;
+ if(e.isContentEditable && selection.rangeCount && e.contains(selection.anchorNode)) {
+ const range=this.document.createRange();range.selectNodeContents(e);
+ range.setEnd(selection.anchorNode,selection.anchorOffset);start=range.toString().length;
+ range.setEnd(selection.focusNode,selection.focusOffset);end=range.toString().length;
+ [start,end]=[Math.min(start,end),Math.max(start,end)];
+ }
+ return {type:e.isContentEditable?'contenteditable':e.localName,inputType:e.type || (e.classList.contains('recipient')?'tel':'text'),
+ inputMode:e.inputMode || (e.classList.contains('recipient')?'tel':''),
+ text:e.type==='password'?'':text,selectionStart:start,selectionEnd:end};
+ }
+ edit(p) {
+ const e=this.inputElement,win=this.contentWindow;
+ if(!e?.isConnected || this.document.hidden) throw Error('INPUT_CONTEXT_EXPIRED');
+ this.editing=true;
+ try {e.focus();} finally {this.editing=false;}
+ // A touch can focus a new contenteditable while Gecko retains the previous
+ // field's selection. execCommand follows that selection, not activeElement.
+ if(e.isContentEditable) {
+ const selection=win.getSelection();
+ if(!e.contains(selection.anchorNode) || !e.contains(selection.focusNode)) {
+ const range=this.document.createRange();
+ range.selectNodeContents(e);range.collapse(false);
+ selection.removeAllRanges();selection.addRange(range);
+ }
+ }
+ if(p.operation==='key') {
+ let key=typeof p.key==='object'?p.key.key:p.key;
+ if(key===8 || key==='Backspace') this.document.execCommand('delete');
+ else if(key===13 || key==='Enter') {
+ const options={key:'Enter',code:'Enter',keyCode:13,charCode:13,which:13,bubbles:true,cancelable:true};
+ const accepted=e.dispatchEvent(new win.KeyboardEvent('keydown',options)) && e.dispatchEvent(new win.KeyboardEvent('keypress',options));
+ if(accepted && (e.isContentEditable || e.localName==='textarea')) this.document.execCommand('insertLineBreak');
+ e.dispatchEvent(new win.KeyboardEvent('keyup',options));
+ } else {
+ const text=typeof key==='string'?key:String.fromCodePoint(p.charCode || key);
+ if(text.length>16) throw Error('INVALID_VALUE');
+ this.document.execCommand('insertText',false,text);
+ }
+ } else if(['selection','replace'].includes(p.operation)) {
+ const state=this.inputState();
+ const start=p.operation==='selection'?p.start:state.selectionStart+(p.offset||0);
+ const length=p.length||0;
+ if(!Number.isInteger(start)||!Number.isInteger(length)||start<0||length<0||start+length>(e.value ?? e.textContent).length) throw Error('INVALID_VALUE');
+ if(e.isContentEditable) {
+ const walker=this.document.createTreeWalker(e,win.NodeFilter.SHOW_TEXT);
+ const points=[];let offset=0,node;
+ while((node=walker.nextNode())) {points.push({node,start:offset,end:offset+node.length});offset+=node.length;}
+ const point=index=>{const part=points.find(p=>index<=p.end);return part?[part.node,index-part.start]:[e,0];};
+ const range=this.document.createRange();range.setStart(...point(start));range.setEnd(...point(start+length));
+ win.getSelection().removeAllRanges();win.getSelection().addRange(range);
+ } else e.setSelectionRange(start,start+length);
+ if(p.operation==='replace') {
+ if(typeof p.text!=='string'||p.text.length>4096) throw Error('INVALID_VALUE');
+ this.document.execCommand('insertText',false,p.text);
+ }
+ } else throw Error('METHOD_NOT_SUPPORTED');
+ return this.inputState();
+ }
receiveMessage(message) {
if(message.name==='Vulpes:Edit') return this.edit(message.data);
- if (message.name === 'Vulpes:ViewState') {
- const frame = Array.from(this.document.querySelectorAll('iframe')).find(
- f => f.browsingContext?.id === message.data.contextId);
- if (!frame?.isConnected) return {visible:false};
- const win = this.contentWindow, rect = frame.getBoundingClientRect();
- // App chrome (the status bar and browser controls) legitimately covers
- // the corners of a mozbrowser iframe. Requiring every corner to hit the
- // iframe hid the external browser completely, leaving a white page for
- // sites such as YouTube. The centre plus one side sample is sufficient
- // to prove that the content viewport is visible.
- const samples = [[.5,.5],[.05,.5],[.95,.5],[.5,.05],[.5,.95]];
- const hits = samples.reduce((count,[x,y]) => count +
- (this.document.elementFromPoint(rect.left+rect.width*x,rect.top+rect.height*y)===frame ? 1 : 0), 0);
- const visible = !this.document.hidden && frame.checkVisibility({checkOpacity:true,checkVisibilityCSS:true}) &&
- hits >= 2;
- let top = Math.max(0,rect.top);
- // Gaia's status bar belongs above all app content, including native web views.
- if (this.document.location.hostname === 'system.localhost') {
- const bar=this.document.getElementById('statusbar');
- if (bar?.checkVisibility({checkOpacity:true,checkVisibilityCSS:true})) {
- const r=bar.getBoundingClientRect();
- if (r.width>0 && r.bottom>top && r.top<=top) top=r.bottom;
- }
- }
- return {visible,left:Math.max(0,rect.left)+win.mozInnerScreenX,
- top:top+win.mozInnerScreenY,right:Math.min(win.innerWidth,rect.right)+win.mozInnerScreenX,
- bottom:Math.min(win.innerHeight,rect.bottom)+win.mozInnerScreenY};
- }
if (message.name === 'Vulpes:ViewAlive') {
return Array.from(this.document.querySelectorAll('iframe')).some(
(frame) => frame.isConnected && frame.browsingContext?.id === message.data.contextId,
diff --git a/host/desktop.css b/host/desktop.css
index 2f92504c..d7e4cb4b 100644
--- a/host/desktop.css
+++ b/host/desktop.css
@@ -146,12 +146,6 @@ h1, h2, h3, h4, h5, h6, #statusbar-time,
/* Gaia owns edge gestures; avoid a second host overscroll effect. */
html[data-vulpes-app] { overscroll-behavior:none; }
-/* padding-box is no longer supported. Content-box made the full-width
- section wrap below the dialog's spacer, hiding its title and message. */
-form[role='dialog'][data-type='confirm'] > section {
- box-sizing: border-box;
-}
-
/* Native selects now paint their own arrow; the old Gecko arrow-hiding offset
clips Fira descenders on current Gecko. */
@@ -202,51 +196,3 @@ html[data-vulpes-app='communications'] body {
/* Camera gestures must not magnify the application chrome. */
html[data-vulpes-app='camera'], html[data-vulpes-app='camera'] body { touch-action:none; }
-
-/* Homescreen pages must settle at their boundary after a drag (Forgejo #3). */
-html[data-vulpes-app="homescreen"] #panels { scroll-snap-type:x mandatory; }
-html[data-vulpes-app="homescreen"] #panels > * { scroll-snap-align:start; }
-
-/* System gestures are handled by Gaia, not by the page's pan/zoom handler.
- App frames keep their own scrolling; the notification list scrolls vertically. */
-html[data-vulpes-app='system'],
-html[data-vulpes-app='system'] body {
- overscroll-behavior: none;
-}
-html[data-vulpes-app='system'] #lockscreen {
- touch-action: none;
-}
-html[data-vulpes-app='system'] #utility-tray {
- touch-action: pan-y;
- overscroll-behavior: contain;
-}
-
-/* Keep the original Gaia value selector inside the application's viewport. */
-html[data-vulpes-app] #vulpes-value-selector {
- position: fixed; inset: 0; z-index: 2147483000; margin: 0;
- font-family: FiraSans, sans-serif; box-sizing: border-box;
-}
-html[data-vulpes-app] #vulpes-value-selector > section { box-sizing: border-box; }
-html[data-vulpes-app] #vulpes-value-selector [role=option][aria-disabled=true] { opacity: .45; }
-
-html[data-vulpes-app] #vulpes-value-selector-layer {
- position: fixed; inset: 0; z-index: 2147483000;
-}
-
-html[data-vulpes-app] :is(.button,.bb-button) select.vulpes-select-label-owner {
- opacity: 0;
-}
-html[data-vulpes-app] .vulpes-select-label {
- position: absolute;
- inset: 0 3.5rem 0 1.1rem;
- display: flex;
- align-items: center;
- pointer-events: none;
- font: italic 1.7rem/1.5 FiraSans, sans-serif;
- white-space: nowrap;
- overflow: hidden;
- color: var(--text-color, #333);
- text-shadow: none;
-}
-html[dir=rtl][data-vulpes-app] .vulpes-select-label { inset: 0 1.1rem 0 3.5rem; }
-html[data-vulpes-theme=dark] .vulpes-select-label { color: #eee; }
diff --git a/host/gaia-bluetooth.js b/host/gaia-bluetooth.js
deleted file mode 100644
index cc848e55..00000000
--- a/host/gaia-bluetooth.js
+++ /dev/null
@@ -1,126 +0,0 @@
-// Gaia Bluetooth v2, backed by the host's permission-checked BlueZ broker.
-(() => {
- const app = location.hostname.split('.')[0];
- if (!(window.VulpesPlatform === 'tundra' || window.VulpesTestBluetooth) ||
- !['system', 'settings', 'bluetooth'].includes(app)) return;
- const {call} = VulpesCompat;
- const manager = Object.assign(new EventTarget(), {defaultAdapter:null});
- const adapter = Object.assign(new EventTarget(), {
- state:'disabled', address:'', name:'', discoverable:false, discovering:false,
- pairingReqs:new EventTarget(),
- });
- const devices = new Map();
- let discovery = null, seen = new Set(), pending = false, timer, closed = false;
- let currentPairing = null, initialized = false;
- const emit = (target, type, values={}) => {
- const event = Object.assign(new Event(type), values);
- target.dispatchEvent(event); target['on'+type]?.(event);
- };
- function change(target, fields) {
- const attrs = Object.keys(fields).filter(k=>JSON.stringify(target[k])!==JSON.stringify(fields[k]));
- Object.assign(target, fields);
- if (attrs.length) emit(target, 'attributechanged', {attrs});
- }
- function apply(state) {
- if (!state?.available) return;
- for (const row of state.devices) {
- let device = devices.get(row.address);
- const oldPaired = device?.paired;
- if (!device) {
- device = new EventTarget(); devices.set(row.address, device);
- }
- change(device, {...row, connectionStatus:row.connected?'connected':'disconnected'});
- if (oldPaired !== undefined && oldPaired !== row.paired)
- emit(adapter, row.paired?'devicepaired':'deviceunpaired', {device,address:row.address});
- if (discovery && state.discovering && !seen.has(row.address)) {
- seen.add(row.address); emit(discovery, 'devicefound', {device});
- }
- }
- for (const [address, device] of devices) {
- if (!state.devices.some(d=>d.address===address)) {
- if (device.paired) emit(adapter,'deviceunpaired',{address,device});
- devices.delete(address);
- }
- }
- change(adapter, {state:state.enabled?'enabled':'disabled',address:state.address,
- name:state.name,discoverable:state.discoverable,discovering:state.discovering});
- if (!manager.defaultAdapter) {
- manager.defaultAdapter=adapter; emit(manager,'attributechanged',{attrs:['defaultAdapter']});
- }
- currentPairing = state.pairing?.[0] || null;
- if (app==='bluetooth' && location.pathname==='/onpair.html') showPairing();
- }
- async function command(operation, params={}) {
- const value=await call('platform.bluetooth',{operation,...params});
- if (value?.available) apply(value);
- return value;
- }
- async function refresh() {
- if (pending || closed) return;
- pending=true;
- try {apply(await command('status'));}
- catch(error) {console.warn('Bluetooth:',error.message);}
- finally {pending=false;}
- }
- adapter.enable=()=>command('power',{enabled:true});
- adapter.disable=()=>command('power',{enabled:false});
- adapter.setName=name=>command('rename',{name});
- adapter.setDiscoverable=enabled=>command('discoverable',{enabled});
- adapter.startDiscovery=async()=>{
- await command('scan');
- discovery=new EventTarget(); seen=new Set();
- const handle=discovery;
- // Gaia installs its discovery listeners after this promise resolves.
- setTimeout(()=>refresh(),0);
- return handle;
- };
- adapter.stopDiscovery=async()=>{await command('stopScan');discovery=null;};
- adapter.getPairedDevices=()=>[...devices.values()].filter(d=>d.paired);
- adapter.getConnectedDevices=async profile=>{
- await refresh();
- const uuid=Number(profile).toString(16).padStart(8,'0')+'-0000-1000-8000-00805f9b34fb';
- return [...devices.values()].filter(d=>d.connected && d.uuids.includes(uuid));
- };
- for (const op of ['pair','unpair','connect','disconnect'])
- adapter[op]=async value=>{
- await command(op,{address:typeof value==='string'?value:value.address});
- await refresh();
- };
- const unsupported=()=>Promise.reject(new DOMException('Bluetooth profile is unavailable','NotSupportedError'));
- for (const name of ['sendFile','stopSendingFile','confirmReceivingFile','connectSco','disconnectSco',
- 'sendMediaMetaData','sendMediaPlayStatus']) adapter[name]=unsupported;
- adapter.isScoConnected=()=>Promise.resolve(false);
- manager.getAdapters=()=>Promise.resolve(manager.defaultAdapter?[adapter]:[]);
- Object.defineProperty(navigator,'mozBluetooth',{value:manager});
-
- let shown = null;
- function showPairing() {
- if (!initialized || !window.Pairview) return;
- const item=currentPairing;
- if (!item) {if(shown) window.close();return;}
- if (shown===item.id) return;
- shown=item.id;
- const reply=(accept,code)=>command('reply',{id:item.id,accept,code});
- Pairview.init(item.method==='enterpasskey'?'enterpincode':item.method,{
- deviceName:item.deviceName,handle:{passkey:item.passkey,
- accept:()=>reply(true),reject:()=>reply(false),setPinCode:code=>reply(true,code)},
- });
- }
- addEventListener('DOMContentLoaded',async()=>{
- initialized=true;
- await refresh(); showPairing();
- if(app==='system') {
- navigator.mozSettings.addObserver('bluetooth.enabled',event=>{
- const enabled=event.settingValue===true;
- if((adapter.state==='enabled')!==enabled)
- command('power',{enabled}).catch(()=>call('settings.set',{values:{'bluetooth.enabled':adapter.state==='enabled'}}));
- });
- await call('settings.set',{values:{'bluetooth.enabled':adapter.state==='enabled'}});
- }
- timer=setInterval(()=>{if(app==='system'||!document.hidden||discovery)refresh();},1500);
- },{once:true});
- addEventListener('pagehide',()=>{
- closed=true;clearInterval(timer);
- if(discovery)command('stopScan').catch(()=>{});
- },{once:true});
-})();
diff --git a/host/gaia-camera.js b/host/gaia-camera.js
index 63b46b0c..304c704a 100644
--- a/host/gaia-camera.js
+++ b/host/gaia-camera.js
@@ -1,44 +1,11 @@
// The Gaia camera UI consumes mozCameras. Only live MediaStream capabilities are exposed here.
(() => {
if (location.hostname !== 'camera.localhost') return;
- const videoMime = typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported('video/webm;codecs=vp8,opus') ? 'video/webm;codecs=vp8,opus' : null;
- window.VulpesVideoExtension = videoMime ? 'webm' : null;
let cameras = ['back'],
devices = new Map(),
current = null,
generation = 0;
let cameraError = null;
- let opening = Promise.resolve(), closing = Promise.resolve();
- const releases = new WeakMap();
- const nativeViews = new WeakMap();
- function releaseStream(stream, preview) {
- if (releases.has(stream)) return releases.get(stream);
- if (current === stream) current = null;
- const release = Promise.resolve().then(async () => {
- await stream.vulpesFinishRecording?.();
- emit(stream, 'previewstatechange', {newState:'stopped'});
- // Stop the capture producer before detaching its consumers. Gecko can
- // otherwise block in srcObject=null while a GPU-backed frame is in use.
- stream.getTracks().forEach(track => track.stop());
- for (const video of document.querySelectorAll('video')) {
- if (nativeViews.get(video) === stream) {
- nativeViews.delete(video);
- stream.vulpesPreviewCanvas.remove();
- video.style.display = '';
- }
- if (video.srcObject === stream) {
- video.pause(); video.srcObject = null;
- if ('vulpesCapture' in video.dataset) video.remove();
- }
- }
- preview?.remove();
- try { await stream.vulpesCameraClose?.(); }
- finally { stream.getTracks().forEach(track => track.stop()); }
- });
- releases.set(stream, release);
- closing = release.catch(recordError);
- return release;
- }
function recordError(error) {
cameraError = { name: error.name || 'Error', message: error.message || '' };
console.warn('Vulpes Camera:', cameraError.name, cameraError.message);
@@ -109,39 +76,18 @@
Object.defineProperty(HTMLMediaElement.prototype, 'mozSrcObject', {
configurable: true,
get() {
- return nativeViews.get(this) || nativeSource.get.call(this);
+ return nativeSource.get.call(this);
},
set(stream) {
- const old = nativeViews.get(this);
- if (old) {
- old.vulpesPreviewCanvas.remove();
- nativeViews.delete(this);
- this.style.display = '';
- }
- if (stream?.vulpesPreviewCanvas) {
- // The HAL already delivers NV21 frames. Render them directly in Gaia's
- // viewfinder instead of capturing the WebGL canvas into a second GPU
- // video pipeline, which stalls on repeated switches on Sargo.
- nativeViews.set(this, stream);
- const canvas = stream.vulpesPreviewCanvas;
- canvas.className = this.className;
- canvas.dataset.vulpesNativePreview = '';
- canvas.style.cssText = 'display:block;width:100%;height:100%;pointer-events:none';
- this.style.display = 'none';
- this.parentNode.append(canvas);
- } else nativeSource.set.call(this, stream);
+ nativeSource.set.call(this, stream);
},
});
- const playVideo = HTMLMediaElement.prototype.play;
- HTMLMediaElement.prototype.play = function() {
- return nativeViews.has(this) ? Promise.resolve() : playVideo.call(this);
- };
function emit(target, type, detail = {}) {
const event = new Event(type);
Object.assign(event, detail);
target.dispatchEvent(event);
}
- async function discover(stream, name) {
+ async function discover(stream) {
if (stream.vulpesNative) {
cameras = ['back', 'front'];
return;
@@ -158,32 +104,24 @@
const active = list.find((d) => d.deviceId === settings.deviceId) || list[0];
const back = list.find((d) => /back|rear|environment/i.test(d.label));
const front = list.find((d) => /front|user|face/i.test(d.label));
- // Unlabelled webcams must not exchange identities every time we switch.
- const previousBack = list.find(d => d.deviceId === devices.get('back'));
- const previousFront = list.find(d => d.deviceId === devices.get('front'));
- const first = back || previousBack ||
- ((settings.facingMode === 'user' || name === 'front') && list.length > 1
- ? list.find(d => d !== active) : active);
+ const first =
+ back ||
+ (settings.facingMode === 'user' && list.length > 1 ? list.find((d) => d !== active) : active);
devices = new Map();
if (first) devices.set('back', first.deviceId);
- const second = [front, previousFront].find(d => d && d !== first) || list.find(d => d !== first);
+ const second = front && front !== first ? front : list.find((d) => d !== first);
if (second) devices.set('front', second.deviceId);
cameras = devices.size ? [...devices.keys()] : ['back'];
}
- // Keep one graphics context per app. Losing/recreating it at every lens switch
- // can exhaust the mobile driver while captured video frames are still in use.
- let nativeCanvas = null;
async function nativeStream(name) {
let session;
const call = (params) => VulpesCompat.call('platform.cameraPreview', {...params, session});
const meta = await call({action:'start', camera:name === 'front' ? 'front' : 'back'});
session = meta.session;
- const canvas = nativeCanvas || (nativeCanvas = document.createElement('canvas'));
+ const canvas = document.createElement('canvas');
const sideways = meta.orientation % 180 !== 0;
- const width = sideways ? meta.height : meta.width;
- const height = sideways ? meta.width : meta.height;
- if (canvas.width !== width) canvas.width = width;
- if (canvas.height !== height) canvas.height = height;
+ canvas.width = sideways ? meta.height : meta.width;
+ canvas.height = sideways ? meta.width : meta.height;
// Convert NV21 in a shader instead of blocking Gaia's UI with one JS loop
// per pixel. The two textures contain Y and interleaved V/U respectively.
const gl = canvas.getContext('webgl', {alpha:false, antialias:false, preserveDrawingBuffer:true});
@@ -191,100 +129,81 @@
await call({action:'stop'});
throw new DOMException('Camera preview requires WebGL', 'NotSupportedError');
}
- const timing = {frames:0, startedAt:performance.now(), conversionMs:0, transferMs:0, sensorFrames:0};
- let stopped = false, timer, stream, wakeLock, sequence = -1, closing, program, buffer;
- const textures = [], shaders = [];
+ function shader(type, source) {
+ const value = gl.createShader(type);
+ gl.shaderSource(value, source); gl.compileShader(value);
+ if (!gl.getShaderParameter(value, gl.COMPILE_STATUS)) throw Error('Camera shader compilation failed');
+ return value;
+ }
+ const program = gl.createProgram();
+ gl.attachShader(program, shader(gl.VERTEX_SHADER, `
+ attribute vec2 position; varying vec2 coord;
+ void main() { gl_Position=vec4(position,0.,1.); coord=vec2(position.x*.5+.5,.5-position.y*.5); }
+ `));
+ gl.attachShader(program, shader(gl.FRAGMENT_SHADER, `
+ precision mediump float; varying vec2 coord;
+ uniform sampler2D luma; uniform sampler2D chroma; uniform float rotation;
+ void main() {
+ vec2 p=coord;
+ if(rotation==90.) p=vec2(coord.y,1.-coord.x);
+ else if(rotation==270.) p=vec2(1.-coord.y,coord.x);
+ else if(rotation==180.) p=1.-coord;
+ float y=1.16438356*(texture2D(luma,p).r-16./255.);
+ vec4 vu=texture2D(chroma,p);
+ float v=vu.r-128./255.; float u=vu.a-128./255.;
+ gl_FragColor=vec4(y+1.59602678*v,y-.39176229*u-.81296764*v,y+2.01723214*u,1.);
+ }
+ `));
+ gl.linkProgram(program);
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw Error('Camera shader link failed');
+ gl.useProgram(program);
+ const buffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER,buffer);
+ gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,-1,1,-1,-1,1,1,1]),gl.STATIC_DRAW);
+ const position=gl.getAttribLocation(program,'position');
+ gl.enableVertexAttribArray(position);gl.vertexAttribPointer(position,2,gl.FLOAT,false,0,0);
+ gl.uniform1f(gl.getUniformLocation(program,'rotation'),meta.orientation);
+ const textures = ['luma','chroma'].map((name,index) => {
+ const texture=gl.createTexture();gl.activeTexture(gl.TEXTURE0+index);gl.bindTexture(gl.TEXTURE_2D,texture);
+ gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR);
+ gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.LINEAR);
+ gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE);
+ gl.uniform1i(gl.getUniformLocation(program,name),index);return texture;
+ });
+ const timing = {frames:0, startedAt:performance.now(), conversionMs:0};
+ let stopped = false, timer, stream, sequence = -1, closing;
const stop = () => {
if (closing) return closing;
stopped = true; clearTimeout(timer);
- wakeLock?.unlock(); wakeLock = null;
closing = call({action:'stop'}).finally(() => {
- if (stream) stream.vulpesClosed = true;
textures.forEach(texture => gl.deleteTexture(texture));
- shaders.forEach(value => gl.deleteShader(value));
- if (buffer) gl.deleteBuffer(buffer);
- if (program) gl.deleteProgram(program);
- // The next lens reuses this context after its old stream has stopped.
- gl.clearColor(0,0,0,1); gl.clear(gl.COLOR_BUFFER_BIT);
+ gl.deleteBuffer(buffer); gl.deleteProgram(program);
+ gl.getExtension('WEBGL_lose_context')?.loseContext();
});
return closing;
};
+ const draw = async () => {
+ const frame = await call({action:'frame'});
+ if (stopped || !frame.data || frame.sequence === sequence) return false;
+ const data = new Uint8Array(frame.data), w = meta.width, h = meta.height;
+ if (data.length !== w * h * 3 / 2) throw Error('Invalid native camera frame');
+ sequence = frame.sequence;
+ const start=performance.now();
+ gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D,textures[0]);
+ gl.texImage2D(gl.TEXTURE_2D,0,gl.LUMINANCE,w,h,0,gl.LUMINANCE,gl.UNSIGNED_BYTE,data.subarray(0,w*h));
+ gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D,textures[1]);
+ gl.texImage2D(gl.TEXTURE_2D,0,gl.LUMINANCE_ALPHA,w/2,h/2,0,gl.LUMINANCE_ALPHA,gl.UNSIGNED_BYTE,data.subarray(w*h));
+ gl.drawArrays(gl.TRIANGLE_STRIP,0,4);
+ timing.frames++;timing.conversionMs+=performance.now()-start;
+ return true;
+ };
try {
- function shader(type, source) {
- const value = gl.createShader(type);
- shaders.push(value);
- gl.shaderSource(value, source); gl.compileShader(value);
- if (!gl.getShaderParameter(value, gl.COMPILE_STATUS)) throw Error('Camera shader compilation failed');
- return value;
- }
- program = gl.createProgram();
- gl.attachShader(program, shader(gl.VERTEX_SHADER, `
- attribute vec2 position; varying vec2 coord;
- void main() { gl_Position=vec4(position,0.,1.); coord=vec2(position.x*.5+.5,.5-position.y*.5); }
- `));
- gl.attachShader(program, shader(gl.FRAGMENT_SHADER, `
- precision mediump float; varying vec2 coord;
- uniform sampler2D luma; uniform sampler2D chroma; uniform float rotation;
- void main() {
- vec2 p=coord;
- if(rotation==90.) p=vec2(coord.y,1.-coord.x);
- else if(rotation==270.) p=vec2(1.-coord.y,coord.x);
- else if(rotation==180.) p=1.-coord;
- float y=1.16438356*(texture2D(luma,p).r-16./255.);
- vec4 vu=texture2D(chroma,p);
- float v=vu.r-128./255.; float u=vu.a-128./255.;
- gl_FragColor=vec4(y+1.59602678*v,y-.39176229*u-.81296764*v,y+2.01723214*u,1.);
- }
- `));
- gl.linkProgram(program);
- if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw Error('Camera shader link failed');
- gl.useProgram(program);
- buffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER,buffer);
- gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,-1,1,-1,-1,1,1,1]),gl.STATIC_DRAW);
- const position=gl.getAttribLocation(program,'position');
- gl.enableVertexAttribArray(position);gl.vertexAttribPointer(position,2,gl.FLOAT,false,0,0);
- gl.uniform1f(gl.getUniformLocation(program,'rotation'),meta.orientation);
- ['luma','chroma'].forEach((name,index) => {
- const texture=gl.createTexture();textures.push(texture);gl.activeTexture(gl.TEXTURE0+index);gl.bindTexture(gl.TEXTURE_2D,texture);
- gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR);
- gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.LINEAR);
- gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.CLAMP_TO_EDGE);
- gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE);
- gl.uniform1i(gl.getUniformLocation(program,name),index);
- });
- gl.viewport(0,0,canvas.width,canvas.height);
- const pixels = meta.width * meta.height;
- gl.activeTexture(gl.TEXTURE0);
- gl.texImage2D(gl.TEXTURE_2D,0,gl.LUMINANCE,meta.width,meta.height,0,gl.LUMINANCE,gl.UNSIGNED_BYTE,null);
- gl.activeTexture(gl.TEXTURE1);
- gl.texImage2D(gl.TEXTURE_2D,0,gl.LUMINANCE_ALPHA,meta.width/2,meta.height/2,0,gl.LUMINANCE_ALPHA,gl.UNSIGNED_BYTE,null);
- const draw = async () => {
- const requested = performance.now();
- const frame = await call({action:'frame'});
- timing.transferMs += performance.now()-requested;
- if (stopped || !frame.data || frame.sequence === sequence) return false;
- const data = new Uint8Array(frame.data), w = meta.width, h = meta.height;
- if (data.length !== w * h * 3 / 2) throw Error('Invalid native camera frame');
- sequence = frame.sequence; timing.sensorFrames = sequence;
- const start=performance.now();
- gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D,textures[0]);
- gl.texSubImage2D(gl.TEXTURE_2D,0,0,0,w,h,gl.LUMINANCE,gl.UNSIGNED_BYTE,data.subarray(0,pixels));
- gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D,textures[1]);
- gl.texSubImage2D(gl.TEXTURE_2D,0,0,0,w/2,h/2,gl.LUMINANCE_ALPHA,gl.UNSIGNED_BYTE,data.subarray(pixels));
- gl.drawArrays(gl.TRIANGLE_STRIP,0,4);
- timing.frames++;timing.conversionMs+=performance.now()-start;
- return true;
- };
const deadline = Date.now() + 6000;
while (!(await draw())) {
if (Date.now() > deadline) throw new DOMException('No camera frames', 'NotReadableError');
await new Promise(resolve => setTimeout(resolve, 50));
}
- stream = new MediaStream();
- stream.vulpesPreviewCanvas = canvas;
- stream.vulpesClosed = false;
- // A canvas has no video element's implicit screen wake lock.
- wakeLock = navigator.requestWakeLock?.('screen');
+ stream = canvas.captureStream(24);
stream.vulpesTiming = timing;
stream.vulpesNative = true;
stream.vulpesCameraMetadata = meta;
@@ -299,41 +218,20 @@
const started = performance.now();
try { await draw(); }
catch (error) {
- if (!stopped) { recordError(error); await stop().catch(recordError); }
+ if (!stopped) { recordError(error); stream.getTracks().forEach(track => track.stop()); }
return;
}
- if (!stopped) timer = setTimeout(next, Math.max(0, 1000/30-(performance.now()-started)));
+ if (!stopped) timer = setTimeout(next, Math.max(0, 1000/24-(performance.now()-started)));
};
- timer = setTimeout(next, 1000/30);
+ timer = setTimeout(next, 1000/24);
return stream;
} catch (error) { await stop().catch(() => {}); throw error; }
}
- async function captureVideo(constraints, ticket, platform) {
- // track.stop() returns before some Android camera drivers finish closing.
- // Retry the same sensor, never replace a refused/missing camera silently.
- for (let attempt = 0; ; attempt++) {
- if (document.hidden || ticket !== generation)
- throw new DOMException('Camera request superseded or hidden', 'AbortError');
- try { return await navigator.mediaDevices.getUserMedia(constraints); }
- catch (error) {
- if (platform !== 'android' || error.name !== 'NotReadableError' || attempt >= 2) throw error;
- await new Promise(resolve => setTimeout(resolve, 200 * (attempt + 1)));
- }
- }
- }
- function getCamera(name, config = {}) {
- const ticket = ++generation;
- const request = opening.then(() => openCamera(name, config, ticket));
- opening = request.catch(() => {});
- return request;
- }
- async function openCamera(name, config, ticket) {
+ async function getCamera(name, config = {}) {
if (config.mode && config.mode !== 'picture')
throw new DOMException('Video capture is not ported yet', 'NotSupportedError');
- if (current) await releaseStream(current);
- await closing;
- if (document.hidden || ticket !== generation)
- throw new DOMException('Camera request superseded or hidden', 'AbortError');
+ const ticket = ++generation;
+ await current?.release?.();
const selected = devices.get(name);
cameraError = null;
@@ -342,22 +240,22 @@
const platform = await VulpesCompat.call('platform.capabilities', {});
if (platform.platform === 'tundra' && !platform.camera)
throw new DOMException('Native camera adapter unavailable', 'NotSupportedError');
- stream = platform.platform === 'tundra' ? await nativeStream(name) : await captureVideo({
+ stream = platform.platform === 'tundra' ? await nativeStream(name) : await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
...(selected
? { deviceId: { exact: selected } }
- : { facingMode: { ideal: name === 'front' ? 'user' : 'environment' } }),
+ : { facingMode: { ideal: 'environment' } }),
width: { ideal: 1920 },
height: { ideal: 1080 },
},
- }, ticket, platform.platform);
+ });
} catch (error) {
recordError(error);
throw error;
}
if (document.hidden || ticket !== generation) {
- await releaseStream(stream);
+ stream.getTracks().forEach((t) => t.stop());
const error = new DOMException('Camera hidden', 'AbortError');
recordError(error);
throw error;
@@ -366,60 +264,49 @@
current = stream;
let preview;
try {
- await discover(stream, name);
+ await discover(stream);
if (document.hidden || ticket !== generation)
throw new DOMException('Camera hidden', 'AbortError');
const track = stream.getVideoTracks()[0];
- const native = stream.vulpesCameraMetadata;
- let size;
- if (native) {
- // Preview dimensions and still photos come directly from the HAL.
- // The native canvas needs no hidden capture video.
- const sideways = native.orientation % 180 !== 0;
- size = {width:sideways ? native.height : native.width,
- height:sideways ? native.width : native.height};
- } else {
- preview = document.createElement('video');
- preview.muted = true;
- preview.playsInline = true;
- preview.srcObject = stream;
- preview.dataset.vulpesCapture = '';
- preview.style.cssText = 'position:fixed;width:1px;height:1px;opacity:0;pointer-events:none';
- document.body.append(preview);
- let previewTimeout;
- await Promise.race([
- preview.play(),
- new Promise((_, reject) => {
- previewTimeout = setTimeout(
- () => reject(new DOMException('Camera preview timed out', 'NotReadableError')),
- 10000,
- );
- }),
- ]).finally(() => clearTimeout(previewTimeout));
- const settings = stream.getVideoTracks()[0].getSettings();
- size = {width:preview.videoWidth || settings.width,
- height:preview.videoHeight || settings.height};
- }
+ const settings = track.getSettings();
+ const size = { width: settings.width, height: settings.height };
+ preview = document.createElement('video');
+ preview.muted = true;
+ preview.playsInline = true;
+ preview.srcObject = stream;
+ preview.dataset.vulpesCapture = '';
+ preview.style.cssText = 'position:fixed;width:1px;height:1px;opacity:0;pointer-events:none';
+ document.body.append(preview);
+ let previewTimeout;
+ await Promise.race([
+ preview.play(),
+ new Promise((_, reject) => {
+ previewTimeout = setTimeout(
+ () => reject(new DOMException('Camera preview timed out', 'NotReadableError')),
+ 10000,
+ );
+ }),
+ ]).finally(() => clearTimeout(previewTimeout));
if (document.hidden || ticket !== generation)
throw new DOMException('Camera hidden', 'AbortError');
+ const native = stream.vulpesCameraMetadata;
const pictureSizes = native?.pictureSizes || [size];
let pictureSize = pictureSizes.reduce((a,b)=>a.width*a.height>b.width*b.height?a:b);
- let focusArea = null, zoom = 1, mode = 'picture', recording, startingRecording = false;
- const profile = {containerFormat:'webm',video:{width:size.width,height:size.height,codec:'vp8',bitrate:1500000,framerate:30},audio:{codec:'opus',samplerate:48000,channels:1,bitrate:64000}};
+ let focusArea = null, zoom = 1, releasing;
const configuration = () => ({
- mode,
+ mode: 'picture',
previewSize: size,
pictureSize,
- recorderProfile: videoMime ? 'webm' : null,
+ recorderProfile: null,
});
Object.assign(stream, {
capabilities: {
pictureSizes,
previewSizes: [size],
thumbnailSizes: [],
- recorderProfiles: videoMime ? {webm:profile} : {},
+ recorderProfiles: {},
flashModes: ['off'],
focusModes: native ? (native.focusModes.includes('auto') ? ['auto','infinity'] : ['fixed']) : ['fixed'],
sceneModes: ['auto'],
@@ -437,9 +324,8 @@
zoom: 1,
async setConfiguration(value) {
if (value.pictureSize) stream.setPictureSize(value.pictureSize);
- if (value.mode && !['picture',...(videoMime ? ['video'] : [])].includes(value.mode))
+ if (value.mode && value.mode !== 'picture')
throw new DOMException('Video unavailable', 'NotSupportedError');
- if(value.mode)mode=value.mode;
queueMicrotask(() => emit(stream, 'previewstatechange', { newState: 'started' }));
return configuration();
},
@@ -492,68 +378,24 @@
emit(stream, 'previewstatechange', { newState: 'started' });
},
release() {
- return releaseStream(stream, preview);
+ if (releasing) return releasing;
+ // Detach first: visibility changes and Gaia may release the same stream
+ // again while its hardware worker is still shutting down.
+ if (current === stream) current = null;
+ releasing = Promise.resolve().then(async () => {
+ for (const video of document.querySelectorAll('video')) {
+ if (video.srcObject === stream) { video.pause(); video.srcObject = null; }
+ }
+ preview.remove();
+ await stream.vulpesCameraClose?.();
+ stream.getTracks().forEach(t => t.stop());
+ });
+ return releasing;
},
- async startRecording(options, storage, filepath) {
- if(!videoMime)throw new DOMException('Video unavailable','NotSupportedError');
- if(recording || startingRecording)throw new DOMException('Recording in progress','InvalidStateError');
- startingRecording=true;
- let audio, captured;
- try {
- audio=await navigator.mediaDevices.getUserMedia({audio:true,video:false});
- if(current!==stream || document.hidden)throw new DOMException('Camera closed','AbortError');
- // Only recording captures the native canvas. The preview keeps its
- // direct WebGL surface and the capture track is stopped before a switch.
- captured=native ? stream.vulpesPreviewCanvas.captureStream(30) : new MediaStream(stream.getVideoTracks().map(t=>t.clone()));
- for(const track of audio.getAudioTracks())captured.addTrack(track);
- const recorder=new MediaRecorder(captured,{mimeType:videoMime,videoBitsPerSecond:1500000,audioBitsPerSecond:64000});
- const chunks=[];let bytes=0,failed=false,finish;
- const done=new Promise(resolve=>{finish=resolve;});
- const cap=Math.min(options.maxFileSizeBytes || Infinity,32*1024*1024);
- recorder.ondataavailable=event=>{
- if(event.data.size) {chunks.push(event.data);bytes+=event.data.size;}
- if(bytes>=cap && recorder.state==='recording') {
- emit(stream,'recorderstatechange',{newState:'FileSizeLimitReached'});
- recorder.stop();
- }
- };
- recorder.onerror=()=>{failed=true;emit(stream,'recorderstatechange',{newState:'MediaRecorderFailed'});};
- recorder.onstop=async()=>{
- try {
- if(failed || !bytes)throw Error('Empty or failed recording');
- const blob=new Blob(chunks,{type:'video/webm'});
- await storage.addNamed(blob,filepath);
- const canvas=document.createElement('canvas');
- const source=native ? stream.vulpesPreviewCanvas : preview;
- canvas.width=native ? source.width : preview.videoWidth;
- canvas.height=native ? source.height : preview.videoHeight;
- canvas.getContext('2d').drawImage(source,0,0,canvas.width,canvas.height);
- const poster=await new Promise(resolve=>canvas.toBlob(resolve,'image/jpeg',0.9));
- if(!poster)throw Error('Video poster failed');
- emit(stream,'poster',{data:poster});
- } catch(error) {
- recordError(error);emit(stream,'recorderstatechange',{newState:'MediaRecorderFailed'});
- } finally {
- captured.getTracks().forEach(t=>t.stop());
- chunks.length=0;recording=null;finish();
- emit(stream,'recorderstatechange',{newState:'Stopped'});
- }
- };
- recording={recorder,done};
- recorder.start(500);
- emit(stream,'recorderstatechange',{newState:'Started'});
- } catch(error) {
- captured?.getTracks().forEach(t=>t.stop());audio?.getTracks().forEach(t=>t.stop());
- recording=null;throw error;
- } finally {startingRecording=false;}
- },
- stopRecording() {
- if(recording?.recorder.state==='recording')recording.recorder.stop();
- },
- async vulpesFinishRecording() {
- if(!recording)return;
- const done=recording.done;stream.stopRecording();await done;
+ async startRecording() {
+ throw new DOMException('Video unavailable', 'NotSupportedError');
},
+ stopRecording() {},
});
if (native) Object.defineProperty(stream,'zoom',{
configurable:true, get:()=>zoom,
@@ -568,13 +410,18 @@
});
dispatchEvent(
new CustomEvent('vulpes-camera-capabilities', {
- detail: { cameras: [...cameras], flash: false, video: !!videoMime },
+ detail: { cameras: [...cameras], flash: false, video: false },
}),
);
return { camera: stream, configuration: configuration() };
} catch (error) {
recordError(error);
- await releaseStream(stream, preview).catch(recordError);
+ stream.getTracks().forEach((t) => t.stop());
+ if (preview) {
+ preview.srcObject = null;
+ preview.remove();
+ }
+ if (current === stream) current = null;
throw error;
}
}
@@ -586,7 +433,8 @@
// Keep that request valid; its completion still checks visibility.
if (!current) return;
++generation;
- releaseStream(current).catch(recordError);
+ if (current?.release) current.release().catch(recordError);
+ else current?.getTracks().forEach((t) => t.stop());
};
addEventListener('pagehide', stop);
addEventListener('visibilitychange', () => {
@@ -614,7 +462,7 @@
'Only one camera available',
'toggle-camera-rear',
],
- ['.mode-switch', !videoMime, 'Vidéo pas encore disponible', 'Video is not available yet', null],
+ ['.mode-switch', true, 'Vidéo pas encore disponible', 'Video is not available yet', null],
];
const fr = (document.documentElement.lang || navigator.language).startsWith('fr');
for (const [selector, disabled, french, english, icon] of controls) {
@@ -624,7 +472,7 @@
e.setAttribute('aria-disabled', String(disabled));
if (!disabled) {
e.removeAttribute('title');
- if(selector==='.js-camera')e.setAttribute('aria-label', fr ? 'Changer de caméra' : 'Switch camera');
+ e.setAttribute('aria-label', fr ? 'Changer de caméra' : 'Switch camera');
}
if (disabled) {
e.removeAttribute('data-l10n-id');
diff --git a/host/gaia-compat.js b/host/gaia-compat.js
index a56ecc2c..5631b14a 100644
--- a/host/gaia-compat.js
+++ b/host/gaia-compat.js
@@ -21,7 +21,7 @@
addEventListener('error', (event) =>
record('error', [event.message, event.filename, event.lineno]),
);
- // Binary settings use the same persistence format on every host.
+ // Desktop transports Blobs directly; Android installs a JSON-compatible codec.
const battery = Object.assign(new EventTarget(), {level:NaN, charging:false, chargingTime:Infinity, dischargingTime:Infinity});
Object.defineProperty(navigator, 'battery', {value:battery});
async function refreshBattery() {
@@ -67,25 +67,7 @@
const unsupported = () => Promise.reject(new DOMException('Not available on this adapter', 'NotSupportedError'));
telephony.conferenceGroup = Object.assign(new EventTarget(), {calls:[], state:'', add:unsupported, remove:unsupported, hangUp:unsupported});
telephony.ready = Promise.resolve();
- class TelephonyCall extends EventTarget {}
- Object.defineProperty(window,'TelephonyCall',{value:TelephonyCall});
- function makeCall(item) {
- return Object.assign(new TelephonyCall(),{path:item.path,id:{number:item.number},serviceId:0,
- state:({active:'connected',waiting:'incoming'})[item.state] || item.state,
- emergency:false,group:null,switchable:false,mergeable:false,
- answer:()=>call('platform.answer',{path:item.path}),hangUp:()=>call('platform.hangup',{path:item.path}),
- hold:unsupported,resume:unsupported});
- }
- telephony.dial = async number => {
- const result = await call('platform.dial',{number});
- let current = telephony.calls.find(c=>c.path===result.path);
- if (!current) {
- current = makeCall({path:result.path,number,state:'dialing'});
- telephony.calls.push(current);
- emit(telephony,'callschanged');
- }
- return current;
- };
+ telephony.dial = number => call('platform.dial',{number});
telephony.dialEmergency = unsupported;
telephony.sendTones = unsupported;
telephony.startTone = unsupported;
@@ -112,17 +94,14 @@
const state = ({active:'connected',waiting:'incoming'})[item.state] || item.state;
if (!c) {
changed = true;
- c = makeCall(item);
+ c = Object.assign(new EventTarget(),{path:item.path,id:{number:item.number}, serviceId:0,
+ state, emergency:false, group:null, switchable:false, mergeable:false,
+ answer:()=>call('platform.answer',{path:item.path}), hangUp:()=>call('platform.hangup',{path:item.path}),
+ hold:unsupported,resume:unsupported});
} else if (c.state !== state) { c.state=state;changes.push(c); }
return c;
});
- for (const c of old) if (!telephony.calls.includes(c)) {
- c.state='disconnected';
- // The broker currently supplies no modem disconnect cause. Avoid
- // inventing an error when a call simply disappears after hang-up.
- c.disconnectedReason=null;
- changes.push(c);changed=true;
- }
+ for (const c of old) if (!telephony.calls.includes(c)) {c.state='disconnected';changes.push(c);changed=true;}
for (const c of changes) {emit(c,'statechange',{call:c});emit(c,c.state,{call:c});}
if (changed) {
if (location.hostname === 'system.localhost' && live.some(c => ['incoming','waiting'].includes(c.state))) window.Service?.request('turnScreenOn');
@@ -149,16 +128,15 @@
Object.defineProperty(navigator, 'mozPhoneNumberService', {value:{
normalize: number => String(number || '').normalize('NFKC').replace(/[^0-9+*#]/g, '')
}});
- const settingsCodec = window.VulpesSettingsCodec;
+ const settingsCodec = window.VulpesAndroidSettingsCodec;
async function call(method, params) {
if (!navigator.vulpesRequest) return Promise.reject(new Error('VULPES_HOST_UNAVAILABLE'));
- if (settingsCodec && (method === 'settings.set' || method === 'messages.request' ||
- (window.VulpesPlatform === 'android' && ['media.request', 'activities.finish', 'contacts.request'].includes(method))))
+ if (settingsCodec && ['settings.set', 'media.request'].includes(method))
params = await settingsCodec.encode(params);
return navigator.vulpesRequest({ version: 1, id: ++sequence, method, params }).then((reply) => {
if (reply.error) throw new DOMException(reply.error.code, reply.error.code);
return settingsCodec &&
- ['messages.request', 'settings.get', 'settings.snapshot', 'media.request', 'activities.start', 'contacts.request'].includes(method)
+ ['settings.get', 'settings.snapshot', 'media.request'].includes(method)
? settingsCodec.decode(reply.result)
: reply.result;
});
@@ -187,16 +165,6 @@
}
return r;
}
- let language = navigator.language;
- function setLanguage(value) {
- if (typeof value !== 'string' || !/^[a-z]{2,3}(?:-[a-zA-Z0-9]{2,8})*$/.test(value)) return;
- const changed = language !== value;
- language = value;
- if (changed) dispatchEvent(new Event('languagechange'));
- }
- Object.defineProperty(navigator, 'language', {get:() => language});
- Object.defineProperty(navigator, 'languages', {get:() => Object.freeze([language])});
- call('locale.current').then(setLanguage).catch(() => {});
const observers = new Map();
const settings = new EventTarget();
settings.createLock = () => ({
@@ -228,7 +196,6 @@
addEventListener('vulpes-service-event', (event) => {
const { type, data } = event.detail;
if (type === 'settingchange') {
- if (data.key === 'language.current') setLanguage(data.value);
const change = {
settingName: data.key,
settingValue: settingsCodec ? settingsCodec.decode(data.value) : data.value,
@@ -238,34 +205,17 @@
}
if (type === 'callscreen-closed') document.querySelector('iframe[name="call_screen"]')?.dispatchEvent(new CustomEvent('mozbrowserclose',{bubbles:true}));
if (type === 'hardware-key') dispatchEvent(new CustomEvent('softwareButtonEvent', {detail:data}));
- if (type === 'activity-choice') dispatchEvent(new CustomEvent('mozChromeEvent', {detail:{...data,type:'activity-choice'}}));
- if (type === 'activity-launch') dispatchEvent(new CustomEvent('open-app', {detail:{...data,showApp:true,onlyShowApp:false}}));
if (type === 'launch') dispatchEvent(new CustomEvent('webapps-launch', { detail: data }));
- if (type === 'screen-off') {
- settings.createLock().get('lockscreen.enabled').then(values => {
- if (values['lockscreen.enabled'] === false) return;
- dispatchEvent(new CustomEvent('secure-killapps'));
- window.Service?.request('lock').catch(console.error);
- }).catch(console.error);
- }
- if (type === 'screen-on') window.ScreenManager?.turnScreenOn(true);
- if (type === 'home' || type === 'holdhome') {
- call('activities.cancel', {}).catch(console.error);
- dispatchEvent(new CustomEvent(type));
- }
+ if (type === 'home' || type === 'holdhome') dispatchEvent(new CustomEvent(type));
if (type === 'activity-view') dispatchEvent(new CustomEvent('activity-view', { detail: data }));
});
- if (location.hostname==='system.localhost') addEventListener('mozContentEvent', event=>{
- if (event.detail?.type==='activity-choice')
- call('activities.select',{id:event.detail.id,value:event.detail.value}).catch(console.error);
- });
function application(record) {
// DOMApplication never exposed the registry's internal id. Gaia uses `id`
// to distinguish a bookmark from an installed application.
const { id, ...publicRecord } = record;
const app = Object.assign(new EventTarget(), publicRecord, {
installState: 'installed',
- removable: record.removable === true,
+ removable: false,
installTime: 0,
updateTime: 0,
downloadAvailable: false,
@@ -288,14 +238,6 @@
}
const mgmt = new EventTarget();
mgmt.getAll = () => request(call('apps.list').then((apps) => apps.map(application)));
- mgmt.uninstall = app => request(call('apps.uninstall',{id:new URL(app.origin).hostname.split('.')[0]}));
- addEventListener('vulpes-service-event',event=>{
- const {type,data}=event.detail;
- if(!['apps-installed','apps-uninstalled'].includes(type))return;
- const name=type==='apps-installed'?'install':'uninstall';
- const change=new Event(name);Object.defineProperty(change,'application',{value:application(data)});
- mgmt.dispatchEvent(change);if(typeof mgmt['on'+name]==='function')mgmt['on'+name](change);
- });
mgmt.getNotInstalled = () => request(Promise.resolve([]));
mgmt.getIcon = async (app, size, entry) => {
const manifest = app.manifest.entry_points?.[entry] || app.manifest;
@@ -377,19 +319,10 @@
const handlers = new Map();
navigator.mozSetMessageHandler = (name, fn) => {
handlers.set(name, fn);
- if (name === 'activity') call('activities.subscribe', {}).catch(console.error);
if (name === 'connection') call('iac.subscribe', {}).catch(console.error);
if (name === 'alarm') call('alarms.request', { operation: 'subscribe' }).catch(console.error);
};
- addEventListener('vulpes-service-event', ({detail}) => {
- if (detail.type !== 'activity') return;
- const {id,source} = detail.data;
- setTimeout(() => handlers.get('activity')?.({source,
- postResult: result => call('activities.finish',{id,result}).catch(console.error),
- postError: error => call('activities.finish',{id,error:String(error)}).catch(console.error)
- }),0);
- });
- navigator.mozHasPendingMessage = name => name === 'activity' && (new URLSearchParams(location.search).has('activity') || /\/(pick|share)\.html$/.test(location.pathname));
+ navigator.mozHasPendingMessage = () => false;
navigator.mozSetMessageHandlerPromise = (promise) => promise;
window.VulpesCompat = { call, request, handlers };
window.MozActivity = function (source) {
@@ -549,7 +482,7 @@
frames.add(frame);
// Set delegation before navigation; a MutationObserver runs too late on Android.
if (location.hostname === 'system.localhost')
- frame.setAttribute('allow', 'camera http://camera.localhost:' + location.port + '; microphone http://camera.localhost:' + location.port);
+ frame.setAttribute('allow', 'camera http://camera.localhost:' + location.port);
frame.addEventListener('load', () => {
if (!frame.hasAttribute('mozbrowser')) return;
if (webViews.has(frame)) return;
@@ -724,29 +657,13 @@
Object.setPrototypeOf(GaiaElement.prototype, prototype);
customElements.define(name, GaiaElement);
registered.add(name);
- function LegacyGaiaElement() {
- return initialize(nativeCreate(name));
- }
- LegacyGaiaElement.prototype = GaiaElement.prototype;
- return LegacyGaiaElement;
+ return GaiaElement;
};
document.createElement = function (name, options) {
const element = nativeCreate(name, options);
if (name.toLowerCase() === 'iframe') wire(element);
return registered.has(name) ? initialize(element) : element;
};
- // v0 created callbacks ran for detached fragments too. Gaia reads component
- // state immediately after parsing its templates, before attaching them.
- const innerHTML = Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML');
- Object.defineProperty(Element.prototype, 'innerHTML', {
- ...innerHTML,
- set(value) {
- innerHTML.set.call(this, value);
- for (const element of this.querySelectorAll('*')) {
- if (registered.has(element.localName)) initialize(element);
- }
- },
- });
HTMLElement.prototype.createShadowRoot = function () {
const host = this,
root = this.attachShadow({ mode: 'open' });
diff --git a/host/gaia-input.js b/host/gaia-input.js
index 25c0524a..662aee9b 100644
--- a/host/gaia-input.js
+++ b/host/gaia-input.js
@@ -1,10 +1,6 @@
// Keep Gaia's keyboard and layouts; replace its retired Gecko input-method API.
(() => {
- function start() {
- // Android emits focus only when enabled. Install the keyboard API before Gaia's
- // startup scripts, without waiting for the asynchronous preference handshake.
- if ((!window.VulpesNativeInput && window.VulpesPlatform !== 'android') || window.__vulpesInputInstalled) return;
- window.__vulpesInputInstalled=true;
+ if (!window.VulpesNativeInput) return;
const app=location.hostname.split('.')[0], {call}=VulpesCompat;
if (app==='keyboard') {
let viewport, pendingState;
@@ -20,14 +16,13 @@
dispatchEvent(new Event('resize'));
if(pendingState!==undefined) {setState(pendingState);pendingState=undefined;}
});
- const ready = () => {
+ addEventListener('DOMContentLoaded', () => {
ViewManager.prototype.screenInPortraitMode = function() {
return viewport ? viewport.width<=viewport.height :
this.cachedWindowWidth<=this.cachedWindowHeight;
};
parent.postMessage({type:'vulpes-keyboard-ready'},'http://system.localhost:8765');
- };
- if(document.readyState==='loading')addEventListener('DOMContentLoaded',ready,{once:true});else ready();
+ }, {once:true});
const ime=Object.assign(new EventTarget(),{inputcontext:null,mgmt:{
supportsSwitching:()=>false,
hide:()=>call('input.request',{operation:'hide'}),
@@ -55,15 +50,8 @@
ime.dispatchEvent(new Event('inputcontextchange'));
};
Object.defineProperty(navigator,'mozInputMethod',{value:ime});
- 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);
+ addEventListener('vulpes-service-event',({detail})=>{if(detail.type==='input-focus')setState(detail.data);});
+ call('input.request',{operation:'state'}).then(setState).catch(console.error);
window.resizeTo=(_width,height)=>call('input.request',{operation:'height',height}).catch(console.error);
}
if(app==='system') {
@@ -105,7 +93,4 @@
for(const type of ['home','lockscreen-appopened','appwillclose'])
addEventListener(type,()=>call('input.request',{operation:'hide'}).catch(console.error));
}
- }
- addEventListener('vulpes-input-configured',start);
- start();
})();
diff --git a/host/gaia-mobile-settings.js b/host/gaia-mobile-settings.js
deleted file mode 100644
index 628e1849..00000000
--- a/host/gaia-mobile-settings.js
+++ /dev/null
@@ -1,366 +0,0 @@
-// Expose the single native SIM to the original Gaia Settings controllers.
-// Other applications retain their existing telephony/message adapters.
-(() => {
- if (location.hostname !== 'settings.localhost' || !window.VulpesNativeTelephony) return;
- const {call} = VulpesCompat;
- const read = action => call('platform.mobileSettings', {action});
- const unavailable = () => Promise.reject(new DOMException('Not supported by this adapter', 'NotSupportedError'));
- const icc = Object.assign(new EventTarget(), {cardState:'unknown', iccInfo:null});
- const conn = Object.assign(new EventTarget(), {iccId:null, radioState:'unknown', supportedNetworkTypes:[], voice:{type:null}, data:{type:null}});
- const manager = Object.assign(new EventTarget(), {iccIds:[], getIccById:id => id && id === conn.iccId ? icc : null});
- let fixedDialing, pinEnabled;
- icc.getCardLock = async name => {
- await VulpesMobileSettings.ready;
- const enabled = name === 'fdn' ? fixedDialing : name === 'pin' ? pinEnabled : undefined;
- if (typeof enabled !== 'boolean') return unavailable();
- return {enabled};
- };
- // Fixed-dialling changes, PIN operations and SIM contacts are not ported.
- icc.getServiceState = () => Promise.resolve(false);
- for (const method of ['unlockCardLock','setCardLock','getCardLockRetryCount','readContacts','updateContact']) icc[method] = unavailable;
- // Supplementary services can fail independently of reading the SIM itself.
- // Do not pretend a modem query is a successful preference change.
- conn.getCallingLineIdRestriction = unavailable;
- conn.setCallingLineIdRestriction = unavailable;
- conn.getCallWaitingOption = () => read('calls').then(value => {
- if (!['enabled','disabled'].includes(value.VoiceCallWaiting)) throw Error('RADIO_NOT_READY');
- return value.VoiceCallWaiting === 'enabled';
- });
- conn.setCallWaitingOption = unavailable;
- conn.getVoicePrivacyMode = unavailable; conn.setVoicePrivacyMode = unavailable;
- Object.defineProperty(navigator,'mozMobileConnections',{value:[conn]});
- Object.defineProperty(navigator,'mozIccManager',{value:manager});
- Object.defineProperty(navigator,'mozMobileMessage',{value:{getSmscAddress:() => read('smsc').then(v=>({address:v.address || ''}))}});
- async function refresh() {
- const state = await read('sim');
- const previous = conn.iccId;
- conn.radioState = state.radioEnabled ? 'enabled' : 'disabled';
- conn.dispatchEvent(new Event('radiostatechange'));
- conn.iccId = state.present ? state.id : null;
- fixedDialing = state.fixedDialing; pinEnabled = state.pinEnabled;
- manager.iccIds = conn.iccId ? [conn.iccId] : [];
- icc.cardState = !state.present ? 'absent' : state.pin === 'none' ? 'ready' : state.pin === 'pin' ? 'pinRequired' : state.pin === 'puk' ? 'pukRequired' : 'unknown';
- icc.iccInfo = {iccid:conn.iccId, spn:state.operator, msisdn:state.numbers?.[0] || '', iccType:'sim', isDisplaySpnRequired:true};
- if (previous !== conn.iccId) manager.dispatchEvent(Object.assign(new Event(conn.iccId ? 'iccdetected' : 'iccundetected'),{iccId:conn.iccId || previous}));
- icc.dispatchEvent(new Event('cardstatechange')); icc.dispatchEvent(new Event('iccinfochange'));
- }
- let messageState, changing = false;
- async function messaging(panel) {
- const control=panel.querySelector('[name="ril.sms.requestStatusReport.enabled"]');
- if (!control || control.dataset.vulpesBound) return;
- control.dataset.vulpesBound='true'; control.disabled=true;
- control.addEventListener('change', async event => {
- event.stopImmediatePropagation();
- const desired=control.checked;
- if (changing || typeof messageState !== 'boolean') return;
- changing=true;control.disabled=true;
- try {
- const result=await call('platform.mobileSettings',{action:'deliveryReports',enabled:desired});
- messageState=result.deliveryReports;
- await navigator.mozSettings.createLock().set({'ril.sms.requestStatusReport.enabled':messageState});
- } catch(error) {window.VulpesGaiaUI?.notice('SMS: '+error.message);}
- finally {control.checked=messageState;changing=false;control.disabled=false;}
- },true);
- try {
- const state=await read('messages');
- if (typeof state.deliveryReports !== 'boolean') return;
- messageState=state.deliveryReports;
- control.checked=messageState;
- await navigator.mozSettings.createLock().set({'ril.sms.requestStatusReport.enabled':messageState});
- control.disabled=false;control.closest('li').removeAttribute('aria-disabled');
- control.dataset.vulpesSupported='true';
- } catch(error) {console.warn('Message settings:',error.message);}
- }
- function carrierPanel() {
- let panel, state, pending=false;
- const controls = () => [panel.querySelector('#menuItem-enableDataCall gaia-switch'), panel.querySelector('#menuItem-enableDataRoaming gaia-switch')];
- async function sync() {
- if(typeof state?.enabled==='boolean' && typeof state?.roaming==='boolean')
- await navigator.mozSettings.createLock().set({'ril.data.enabled':state.enabled,'ril.data.roaming_enabled':state.roaming});
- }
- function render() {
- const [data,roaming]=controls();
- for(const [control,key] of [[data,'enabled'],[roaming,'roaming']]) {
- control.disabled=pending || typeof state?.[key]!=='boolean';
- control.checked=state?.[key]===true;
- control.closest('li').setAttribute('aria-disabled',String(control.disabled));
- }
- panel.querySelector('.carrier-info').hidden=false;
- panel.querySelector('.carrier-advancedSettings').hidden=false;
- panel.querySelector('#dataNetwork-desc').textContent=[state?.operator,(state?.technology||'').toUpperCase(),state?.status].filter(Boolean).join(' · ');
- }
- return {
- onInit(element) {
- panel=element;
- for(const [control,action] of [[controls()[0],'dataEnabled'],[controls()[1],'roaming']]) {
- control.addEventListener('change',async event=>{
- event.stopImmediatePropagation();if(pending)return;
- const enabled=control.checked;pending=true;render();
- try {state=await call('platform.mobileSettings',{action,enabled});await sync();}
- catch(error) {window.VulpesGaiaUI?.notice(error.message);}
- finally {pending=false;render();}
- },true);
- }
- },
- async onBeforeShow() {
- pending=true;render();
- try {state=await read('network');await sync();}
- catch(error) {window.VulpesGaiaUI?.notice(error.message);}
- finally {pending=false;render();}
- }
- };
- }
- const fr = () => document.documentElement.lang.toLowerCase().startsWith('fr');
- const text = (f,e) => fr()?f:e;
- const navigate = (id,options={}) => require(['modules/settings_service'],service=>service.navigate(id,options));
- function explain(panel,key,message) {
- let row=panel.querySelector('[data-vulpes-note="'+key+'"]');
- if(!row) {row=document.createElement('p');row.className='explanation';row.dataset.vulpesNote=key;(panel.querySelector(':scope > div')||panel).prepend(row);}
- row.textContent=message;
- }
- function failure(error) {
- const messages={
- APN_IN_USE:text('Désactivez les données mobiles avant de modifier ce profil actif.','Turn off mobile data before editing this active profile.'),
- APN_WRITE_FAILED:text('Enregistrement impossible. Le profil précédent a été restauré.','Could not save. The previous profile was restored.'),
- APN_ROLLBACK_FAILED:text('Le profil n’a pas pu être restauré. Vérifiez ses paramètres avant de réactiver les données.','The profile could not be restored. Check its settings before re-enabling data.'),
- RADIO_READBACK_FAILED:text('Le réseau n’a pas confirmé le réglage. Son état va être relu.','The network did not confirm the setting. Its state will be read again.'),
- INVALID_REQUEST:text('Vérifiez les valeurs saisies.','Check the values you entered.')
- };
- window.VulpesGaiaUI?.notice(messages[error.message]||text('Le service réseau n’a pas pu appliquer ce réglage.','The network service could not apply this setting.'));
- }
- function callPanel() {
- let panel,waiting,caller,state,pending=false;
- const modes={default:'CLIR_DEFAULT',enabled:'CLIR_INVOCATION',disabled:'CLIR_SUPPRESSION'};
- function render() {
- waiting.disabled=pending || !['enabled','disabled'].includes(state?.VoiceCallWaiting);
- waiting.checked=state?.VoiceCallWaiting==='enabled';
- waiting.closest('li').dataset.state=waiting.disabled?'unknown':waiting.checked?'on':'off';
- caller.disabled=pending || !Object.hasOwn(modes,state?.HideCallerId);
- caller.value=modes[state?.HideCallerId]||'';
- for(const c of [waiting,caller])c.closest('li').setAttribute('aria-disabled',String(c.disabled));
- explain(panel,'call-state',pending?text('Interrogation du réseau… Cela peut prendre plusieurs secondes.','Querying the network… This may take several seconds.'):text('Le double appel et la présentation du numéro utilisent les réglages de votre opérateur.','Call waiting and caller ID use your carrier’s settings.'));
- }
- async function update(params) {
- if(pending)return;
- pending=true;render();
- try {state=await call('platform.mobileSettings',params);}
- catch(error) {state=null;failure(error);try {state=await read('calls');}catch(_) {}}
- finally {pending=false;render();}
- }
- return {onInit(element){
- panel=element;waiting=panel.querySelector('#menuItem-callWaiting gaia-switch');caller=panel.querySelector('#ril-callerId');caller.removeAttribute('data-setting');
- for(const id of ['menuItem-callWaiting','menuItem-callerId','menuItem-callForwarding','menuItem-callFdn','menuItem-callBarring'])panel.querySelector('#'+id).hidden=false;
- waiting.addEventListener('change',()=>update({action:'setCallWaiting',enabled:waiting.checked}));
- caller.addEventListener('change',()=>update({action:'setCallerId',mode:Object.keys(modes).find(key=>modes[key]===caller.value)}));
- for(const [id,target] of [['menuItem-callForwarding','call-forwarding'],['menuItem-callFdn','call-fdnSettings'],['menuItem-callBarring','call-cbSettings']]) {
- const a=panel.querySelector('#'+id+' a');a.removeAttribute('data-href');a.href='#'+target;
- a.addEventListener('click',event=>{event.preventDefault();event.stopImmediatePropagation();navigate(target);},true);
- }
- const voicemail=panel.querySelector('.menuItem-voicemail');voicemail.setAttribute('aria-disabled','true');
- voicemail.addEventListener('click',event=>{event.preventDefault();event.stopImmediatePropagation();},true);
- document.l10n.setAttributes(panel.querySelector('#voiceMail-desc'),'unavailable');
- explain(panel,'call-limits',text('Le numéro de messagerie vocale, les numéros autorisés et le blocage des appels ne sont pas encore raccordés.','Voicemail number, fixed dialling and call barring are not connected yet.'));
- render();
- },onBeforeShow(){void update({action:'calls'});}};
- }
- function forwardingPanel() {
- let panel,state,pending=false;
- const rows={
- unconditional:['cfu','call-cf-unconditional-settings','.callForwardingUnconditional'],
- busy:['cfmb','call-cf-mobile-busy-settings','.callForwardingMobileBusy'],
- noReply:['cfnrep','call-cf-no-reply-settings','.callForwardingNoReply'],
- notReachable:['cfnrea','call-cf-not-reachable-settings','.callForwardingNotReachable']
- };
- function render() {
- for(const [reason,[id,,selector]] of Object.entries(rows)) {
- const known=typeof state?.[reason]==='string';
- panel.querySelector(selector).setAttribute('aria-disabled',String(pending||!known));
- const node=panel.querySelector('#'+id+'-desc');
- document.l10n.setAttributes(node,pending?'callSettingsQuery':!known?'callSettingsQueryError':state[reason]?'callForwardingForwardingVoiceToNumber':'callForwardingNotForwarding',state?.[reason]?{number:state[reason]}:{});
- }
- }
- async function load() {
- if(pending)return;
- pending=true;render();
- try {state=await read('forwarding');}catch(error){state=null;failure(error);}
- finally {pending=false;render();}
- }
- return {onInit(element){
- panel=element;
- for(const [reason,[,dialog,selector]] of Object.entries(rows))panel.querySelector(selector).addEventListener('click',async event=>{
- event.preventDefault();event.stopImmediatePropagation();
- if(pending || typeof state?.[reason]!=='string')return;
- const service=await new Promise(resolve=>require(['modules/dialog_service'],resolve));
- const result=await service.show(dialog,{number:state[reason],enabled:!!state[reason]});
- if(result.type!=='submit')return;
- const enabled=!!result.value.enabled;
- const number=enabled?String(result.value.number||'').replace(/[\s()-]/g,''):'';
- pending=true;render();
- try {state=await call('platform.mobileSettings',{action:'setForwarding',reason,enabled,number});}
- catch(error){failure(error);try{state=await read('forwarding');}catch(_){state=null;}}
- finally {pending=false;render();}
- });
- },onBeforeShow(){void load();}};
- }
-
- function operatorPanel() {
- let panel,select,pending=false,state;
- return {
- onInit(element) {
- panel=element;select=panel.querySelector('.preferred-network-type select');
- // Operator scanning/registration has a different service and remains
- // unavailable; it must not be confused with the radio mode selector.
- panel.querySelector('.auto-select gaia-switch').disabled=true;
- panel.querySelector('.search-btn').disabled=true;
- select.addEventListener('change',async()=>{
- if(pending || !state)return;
- const mode=select.value;pending=true;select.disabled=true;
- try {state=await call('platform.mobileSettings',{action:'setRadioMode',mode});}
- catch(error){failure(error);}
- finally {select.value=state.mode;pending=false;select.disabled=false;}
- });
- },
- async onBeforeShow() {
- select.disabled=true;
- explain(panel,'operator',text('Le type de réseau est réglable. La recherche et la sélection manuelle d’opérateur ne sont pas encore raccordées.','Network type can be changed. Operator scanning and manual selection are not connected yet.'));
- try {
- const [radio,network]=await Promise.all([read('radioMode'),read('network')]);state=radio;
- select.replaceChildren();
- const names={any:text('Automatique','Automatic'),gsm:'2G (GSM)',umts:'3G (UMTS)',lte:'4G (LTE)'};
- for(const mode of radio.available)select.add(new Option(names[mode]||mode,mode));
- select.value=radio.mode;select.disabled=!radio.available.includes(radio.mode);
- panel.querySelector('.auto-select gaia-switch').checked=network.mode==='auto';
- } catch(error){failure(error);}
- }
- };
- }
- function apnSettingsPanel() {
- return {onInit(panel){
- for(const anchor of panel.querySelectorAll('[data-apn-type]')) {
- const supported=['default','mms'].includes(anchor.dataset.apnType);
- if(!supported)anchor.setAttribute('aria-disabled','true');
- anchor.addEventListener('click',event=>{
- event.preventDefault();event.stopImmediatePropagation();
- if(supported)navigate('apn-list',{type:anchor.dataset.apnType});
- });
- }
- panel.querySelector('.reset-apn').disabled=true;
- },onBeforeShow(panel){
- explain(panel,'apn',text('Modifiez les profils Internet et MMS existants. Les profils IMS restent gérés par le modem. L’ajout, la suppression et la réinitialisation ne sont pas encore disponibles.','Edit existing Internet and MMS profiles. IMS profiles remain managed by the modem. Adding, deleting and resetting profiles are not available yet.'));
- }};
- }
- function apnListPanel() {
- let panel,type='default';
- return {onInit(element){panel=element;panel.querySelector('.add-apn').disabled=true;panel.querySelector('gaia-header').dataset.href='#apn-settings';},
- async onBeforeShow(element,options={}) {
- type=options.type==='mms'?'mms':'default';
- document.l10n.setAttributes(panel.querySelector('h1'),type==='mms'?'messageSettings-header':'dataSettings-header');
- const list=panel.querySelector('.apn-list');list.replaceChildren();
- try {
- const profiles=(await read('apns')).filter(p=>p.type===type);
- const template=await new Promise(resolve=>require(['panels/apn_list/apn_template_factory'],factory=>resolve(factory(type,item=>navigate('apn-editor',{profileId:item.id,type}),()=>{}))));
- for(const profile of profiles) {
- const li=template({id:profile.id,apn:{apn:profile.fields.apn},active:profile.active});
- // This mark reflects an active data context; changing the profile
- // does not activate/deactivate a bearer implicitly.
- const radio=li.querySelector('gaia-radio');radio.setAttribute('aria-readonly','true');
- list.append(li);
- }
- explain(panel,'list',profiles.length?text('Touchez le nom du profil pour le modifier.','Tap a profile name to edit it.'):text('Aucun profil fourni par le modem.','The modem returned no profile.'));
- }catch(error){failure(error);}
- }
- };
- }
- function apnEditorPanel() {
- let panel,profile,pending=false,dirty=new Set();
- const selectors={apn:'.apn',user:'.user',password:'.password',protocol:'.protocol',authtype:'.authtype',mmsc:'.mmsc',mmsproxy:'.mmsproxy'};
- const protocols={ip:'IP',ipv6:'IPV6',dual:'IPV4V6'};
- const auth={none:'none',pap:'pap',chap:'chap',any:'papOrChap'};
- function editable() {
- for(const [key,selector] of Object.entries(selectors))panel.querySelector(selector).disabled=pending || !profile || (['mmsc','mmsproxy'].includes(key)&&profile.type!=='mms');
- panel.querySelector('.ok').disabled=pending || !profile;
- }
- function render() {
- editable();
- for(const [key,selector] of Object.entries(selectors)) {
- const input=panel.querySelector(selector);
- input.disabled=pending || !profile || (['mmsc','mmsproxy'].includes(key)&&profile.type!=='mms');
- const value=profile?.fields[key]||'';
- input.value=key==='password'?'':key==='protocol'?protocols[value]||'notDefined':key==='authtype'?auth[value]||'notDefined':value;
- if(key==='password')input.placeholder=profile?.hasPassword?text('Inchangé','Unchanged'):'';
- }
- panel.querySelector('.ok').disabled=pending || !profile;
- }
- return {onInit(element){
- panel=element;
- for(const input of panel.querySelectorAll('input,select,button'))input.disabled=true;
- for(const [key,selector] of Object.entries(selectors))for(const event of ['input','change'])panel.querySelector(selector).addEventListener(event,()=>dirty.add(key));
- panel.querySelector('.ok').addEventListener('click',async()=>{
- if(pending||!profile)return;
- const fields={};
- for(const key of dirty) {
- const value=panel.querySelector(selectors[key]).value;
- fields[key]=key==='protocol'?Object.keys(protocols).find(k=>protocols[k]===value):key==='authtype'?Object.keys(auth).find(k=>auth[k]===value):value;
- if(fields[key]===undefined){failure({message:'INVALID_REQUEST'});return;}
- }
- if(!dirty.size){navigate('apn-list',{type:profile.type});return;}
- pending=true;editable();
- try {
- profile=await call('platform.mobileSettings',{action:'editApn',id:profile.id,fields});
- dirty.clear();navigate('apn-list',{type:profile.type});
- }catch(error){failure(error);}
- finally {pending=false;editable();}
- });
- panel.querySelector('gaia-header').addEventListener('action',event=>{event.stopImmediatePropagation();if(!pending)navigate('apn-list',{type:profile?.type||'default'});},true);
- },async onBeforeShow(element,options={}) {
- profile=null;dirty.clear();render();
- explain(panel,'fields',text('Les champs grisés ne sont pas gérés par ce modem. Le champ mot de passe vide conserve le secret existant tant que vous ne le modifiez pas.','Greyed-out fields are not handled by this modem. An untouched password field keeps the existing secret.'));
- try {profile=(await read('apns')).find(p=>p.id===options.profileId);if(!profile)throw Error('APN_NOT_FOUND');render();}
- catch(error){failure(error);}
- },onHide(){panel.querySelector('.password').value='';dirty.clear();}};
- }
-
- let clockState, clockUpdating=false;
- Object.defineProperty(navigator,'mozTime',{value:{set:async date=>{
- try {
- await call('platform.deviceSettings',{action:'setTime',milliseconds:+date});
- dispatchEvent(new Event('moztimechange'));
- } catch(error) {window.VulpesGaiaUI?.notice(error.message);}
- }}});
- async function clock() {
- clockState=await call('platform.deviceSettings',{action:'clock'});
- clockUpdating=true;
- try {await navigator.mozSettings.createLock().set({
- 'time.timezone':clockState.timezone, 'time.timezone.user-selected':clockState.timezone,
- 'time.clock.automatic-update.available':clockState.automaticAvailable,
- 'time.clock.automatic-update.enabled':clockState.automatic,
- 'time.timezone.automatic-update.available':false
- });} finally {clockUpdating=false;}
- return clockState;
- }
- navigator.mozSettings.addObserver('time.clock.automatic-update.enabled',async event=>{
- if(clockUpdating || !clockState || event.settingValue===clockState.automatic)return;
- try {
- clockState=await call('platform.deviceSettings',{action:'setAutomatic',enabled:event.settingValue});
- await call('settings.clockChanged',{});
- dispatchEvent(new Event('moztimechange'));
- } catch(error) {
- window.VulpesGaiaUI?.notice(error.message);
- await clock();
- }
- });
- navigator.mozSettings.addObserver('time.timezone',async event=>{
- if(clockUpdating || !clockState || event.settingValue===clockState.timezone)return;
- try {
- clockState=await call('platform.deviceSettings',{action:'setTimezone',timezone:event.settingValue});
- await call('settings.clockChanged',{});
- dispatchEvent(new Event('moztimechange'));
- } catch(error) {
- window.VulpesGaiaUI?.notice(error.message);
- await navigator.mozSettings.createLock().set({'time.timezone':clockState.timezone});
- }
- });
- clock().catch(error=>console.warn('Clock settings:',error.message));
- window.VulpesMobileSettings = {ready:refresh(), refresh, messaging, clock, panels:{call:callPanel,'call-forwarding':forwardingPanel,carrier:carrierPanel,'operator-settings':operatorPanel,'apn-settings':apnSettingsPanel,'apn-list':apnListPanel,'apn-editor':apnEditorPanel}};
- VulpesMobileSettings.ready.catch(error=>console.warn('SIM settings:',error.message));
-})();
diff --git a/host/gaia-restoration.js b/host/gaia-restoration.js
index 1ac4bb39..f4e974b6 100644
--- a/host/gaia-restoration.js
+++ b/host/gaia-restoration.js
@@ -3,22 +3,6 @@
const app = location.hostname.split('.')[0];
const fr = () => (document.documentElement.lang || navigator.language).startsWith('fr');
const label = (a, b) => fr() ? a : b;
- const labels = new Map();
- function localize(node, french, english, attribute) {
- labels.set(node, {french, english, attribute});
- translate();
- }
- function translate() {
- for (const [node, entry] of labels) {
- const value = label(entry.french, entry.english);
- if (entry.attribute) {
- if (node.getAttribute(entry.attribute) !== value) node.setAttribute(entry.attribute, value);
- } else if (node.textContent !== value) node.textContent = value;
- }
- }
- addEventListener('localized', translate);
- new MutationObserver(translate).observe(document.documentElement, {attributes:true, attributeFilter:['lang']});
-
function notice(message) {
document.getElementById('vulpes-gaia-notice')?.remove();
const link = document.createElement('link');
@@ -44,14 +28,6 @@
window.VulpesGaiaUI = {notice};
async function ready() {
if (app === 'system') {
- // Android reserves the edge swipe for its own shade. A tap on Gaia's
- // status bar provides access without competing with that system gesture.
- if (window.VulpesPlatform === 'android') {
- document.getElementById('statusbar')?.addEventListener('click', () => {
- if (!window.Service?.query('locked')) window.UtilityTray?.show(true);
- });
- }
-
// OperatorIcon also owns the date. It must exist even without a modem.
const startDate = async () => {
if (!window.BaseIcon || !window.Service || !window.LazyLoader) return false;
@@ -74,8 +50,8 @@
const li = document.createElement('li'), button = document.createElement('button'), error = document.createElement('p');
li.id = 'import-sim-option-unavailable';li.setAttribute('role','presentation');
button.className = 'icon icon-sim';button.setAttribute('role','option');button.disabled = true;
- localize(button, 'Carte SIM', 'SIM card');
- error.className = 'error-message';localize(error, 'Cette fonction n’est pas encore disponible sur cette plateforme.', 'This feature is not available on this platform yet.');
+ button.textContent = label('Carte SIM', 'SIM card');
+ error.className = 'error-message';error.textContent = unavailable();
li.append(button,error);list.prepend(li);
}
const item = document.getElementById('import-sd-option');
@@ -86,7 +62,7 @@
if(item.classList.contains('disabled')) item.classList.remove('disabled');
const span = item.querySelector('span');
if(span && span.dataset.l10nId === 'importMemoryCard') {
- span.removeAttribute('data-l10n-id');localize(span, 'Fichier de contacts', 'Contact file');
+ span.removeAttribute('data-l10n-id');span.textContent = label('Fichier de contacts', 'Contact file');
}
const error = item.querySelector('p');
if(error && !error.hidden) error.hidden = true;
@@ -103,31 +79,14 @@
}, true);
}
- if (app === 'sms') {
- let mmsAvailable=false;
- VulpesCompat.call('platform.capabilities',{}).then(c=>{mmsAvailable=c.mms===true;}).catch(()=>{});
- // Keep the composer intact until a real MMS transport is available.
- document.addEventListener('click', event => {
- if (mmsAvailable || !event.target.closest('#messages-send-button') || window.Compose?.type !== 'mms') return;
- event.preventDefault(); event.stopImmediatePropagation();
- notice(label('L’envoi de MMS n’est pas encore disponible dans cette version. Votre message et ses pièces jointes sont conservés.',
- 'MMS sending is not yet available in this version. Your message and attachments have been kept.'));
- }, true);
- }
if (app === 'sms' && window.VulpesPlatform === 'android') {
document.addEventListener('click', event => {
if (!event.target.closest('#messages-send-button')) return;
event.preventDefault(); event.stopImmediatePropagation();
// Android owns final confirmation and delivery. Do not fabricate a sent SMS.
- const view = window.ConversationView;
- view?.assimilateRecipients();
- const numbers = window.Navigation?.isCurrentPanel('composer') ?
- view?.recipients?.numbers : view?.activeThread?.participants;
- const body = window.Compose?.getText?.() || '';
- if (!numbers?.length || !body) return;
- if (window.Compose.type !== 'sms') { notice(unavailable()); return; }
VulpesCompat.call('platform.sms', {
- number:numbers.join(';'), body
+ number:window.ThreadUI?.recipients?.numbers?.join(';') || '',
+ body:window.Compose?.getText?.() || ''
}).catch(() => notice(unavailable()));
}, true);
}
@@ -138,7 +97,7 @@
if (!header || header.querySelector('[data-vulpes-import]')) return;
const action = document.createElement('button');
action.dataset.vulpesImport = ''; action.dataset.icon = 'add';
- localize(action, 'Importer des fichiers', 'Import files', 'aria-label');
+ action.setAttribute('aria-label', label('Importer des fichiers', 'Import files'));
action.onclick = () => VulpesCompat.call('platform.pick',{type:mediaType}).catch(error => {
if (error.name !== 'AbortError' && error.message !== 'AbortError') notice(unavailable());
});
@@ -169,79 +128,31 @@
const unsupported = {
wifi: !navigator.mozWifiManager,
bluetooth: !navigator.mozBluetooth,
- carrier: !window.VulpesMobileSettings,
- call: !window.VulpesMobileSettings,
- messaging: !navigator.mozMobileConnections?.length,
+ carrier: !navigator.mozMobileConnections,
+ call: !navigator.mozMobileConnections,
+ messaging: !navigator.mozMobileConnections,
'sim-manager': !navigator.mozIccManager,
- simpin: true,
- 'wifi-manageCertificates': true,
- 'wifi-selectCertificateFile': true,
- hotspot: !window.VulpesWifiSettings,
- downloads: capabilities.platform === 'android',
- 'wifi-manageNetworks': !navigator.mozWifiManager,
- 'messaging-details': !navigator.mozMobileConnections?.length,
- 'operator-settings': !window.VulpesMobileSettings,
- 'carrier-detail': !window.VulpesMobileSettings,
- 'apn-settings': !window.VulpesMobileSettings,
- 'apn-list': !window.VulpesMobileSettings,
- 'apn-editor': !window.VulpesMobileSettings,
- 'call-forwarding': !window.VulpesMobileSettings,
- 'call-cbSettings': true,
- 'call-fdnSettings': true,
- 'call-fdnList': true,
- 'developer-service-workers': true,
- 'developer-hud': true,
- 'accessibility-screenreader': true,
- crashReports: true,
- improveBrowserOS: true,
- 'improveBrowserOS-sendFeedback': true,
- 'improveBrowserOS-chooseFeedback': true,
- 'full-developer-mode': true,
+ simpin: !navigator.mozIccManager,
+ hotspot: !navigator.mozWifiManager,
findmydevice: true,
fxa: true,
fxsync: true,
addons: true,
mediaStorage: !navigator.getDeviceStorages,
applicationStorage: !navigator.getDeviceStorage,
- usbStorage: true,
+ usbStorage: !navigator.getDeviceStorages,
};
function preservePanel(panel) {
- const content = panel.querySelector(':scope > div') || panel;
- if (content.querySelector('[data-vulpes-unavailable]')) return;
+ const content = panel.querySelector(':scope > div');
+ if (!content || content.querySelector('[data-vulpes-unavailable]')) return;
const explanation = document.createElement('p');
explanation.className = 'explanation'; explanation.dataset.vulpesUnavailable = '';
- explanation.setAttribute('role', 'status'); localize(explanation, 'Cette fonction n’est pas encore disponible sur cette plateforme.', 'This feature is not available on this platform yet.');
- const header=content===panel ? panel.querySelector(':scope > gaia-header') : null;
- if(header)header.after(explanation);else content.prepend(explanation);
- const serviceNotes = {
- fxa: ['La connexion intégrée au compte Mozilla doit être portée vers l’authentification actuelle. Ouvrir le site du compte ne connecte pas encore Vulpes.', 'Integrated Mozilla account sign-in needs the current authentication protocol. Opening the account website does not sign Vulpes in.'],
- fxsync: ['La synchronisation nécessite la connexion au compte Mozilla et un adaptateur pour les données Vulpes. Elle n’est pas encore raccordée.', 'Sync requires Mozilla account sign-in and an adapter for Vulpes data. It is not connected yet.'],
- findmydevice: ['La localisation distante nécessite un service Vulpes sécurisé et associé à votre compte. Aucun appareil n’est actuellement suivi.', 'Remote device location requires a secure Vulpes service linked to your account. No device is currently tracked.'],
- addons: ['Ce panneau gérait les extensions des applications Firefox OS, pas les extensions du navigateur Firefox. Leur moteur d’exécution reste à porter.', 'This panel managed Firefox OS app add-ons, not Firefox browser extensions. Their runtime still needs to be ported.'],
- usbStorage: ['Le transfert de fichiers USB n’est pas encore raccordé. La connexion USB de maintenance reste disponible.', 'USB file transfer is not connected yet. The maintenance USB connection remains available.']
- };
- if(serviceNotes[panel.id]) localize(explanation,...serviceNotes[panel.id]);
- if (panel.id === 'wifi-manageCertificates' || panel.id === 'wifi-selectCertificateFile') localize(explanation, 'Les certificats Wi-Fi et les réseaux WPA-Enterprise ne sont pas encore pris en charge par cet adaptateur.', 'Wi-Fi certificates and WPA-Enterprise networks are not supported by this adapter yet.');
- if (panel.id === 'call' && window.VulpesNativeTelephony) {
- localize(explanation, 'Les appels fonctionnent. Le renvoi, le double appel et le masquage du numéro ne sont pas encore configurables ici.', 'Calling is available. Call forwarding, call waiting and caller ID cannot be configured here yet.');
- for (const item of content.querySelectorAll('li[hidden]')) item.hidden = false;
- }
- if(panel.id==='improveBrowserOS') {
- const list=content.querySelector('ul');
- for(const child of [...content.children]) if(child!==list && child!==explanation)child.hidden=true;
- const anchor=list.querySelector('a');anchor.href='https://nnsprod.com/git/oversu/VulpesOS/issues';
- const title=anchor.querySelector('span');title.removeAttribute('data-l10n-id');
- localize(title,'Signaler un problème ou proposer une idée','Report a problem or suggest an idea');
- localize(explanation,'Les retours sont recueillis sur le dépôt Vulpes OS. Aucun rapport n’est envoyé automatiquement.','Feedback is collected on the Vulpes OS repository. No report is sent automatically.');
- anchor.addEventListener('click',event=>{event.preventDefault();new MozActivity({name:'view',data:{type:'url',url:anchor.href}});});
- return;
- }
+ explanation.setAttribute('role', 'status'); explanation.textContent = unavailable();
+ content.prepend(explanation);
for (const control of content.querySelectorAll('button,input,select,gaia-switch,gaia-checkbox,gaia-radio')) {
control.disabled = true; control.setAttribute('disabled', '');
}
for (const anchor of content.querySelectorAll('a')) {
- const href=anchor.getAttribute('href');
- if(href && href!=='#' && href.startsWith('#') && unsupported[href.slice(1)]) continue;
anchor.setAttribute('aria-disabled', 'true');
anchor.addEventListener('click', event => {event.preventDefault();event.stopImmediatePropagation();}, true);
}
@@ -251,19 +162,9 @@
const install = setInterval(() => {
if (typeof window.require !== 'function' || !require.defined?.('modules/settings_panel') || !require.defined?.('modules/panel_cache')) return;
clearInterval(install);
- require(['modules/panel_cache', 'modules/settings_panel', 'modules/dialog_panel'], (cache, SettingsPanel, DialogPanel) => {
+ require(['modules/panel_cache', 'modules/settings_panel'], (cache, SettingsPanel) => {
const original = cache.get.bind(cache), panels = new Map();
cache.get = (id, callback) => {
- const dialog = window.VulpesWifiSettings?.dialogs[id];
- const native = window.VulpesMobileSettings?.panels[id] || window.VulpesWifiSettings?.panels[id];
- if(dialog) {
- if(!panels.has(id))panels.set(id,DialogPanel(dialog()));
- callback?.(panels.get(id));return;
- }
- if(native) {
- if(!panels.has(id))panels.set(id,SettingsPanel(native()));
- callback?.(panels.get(id));return;
- }
if (!unsupported[id]) return original(id, callback);
if (!panels.has(id)) panels.set(id, SettingsPanel({onInit:preservePanel, onBeforeShow:preservePanel}));
callback?.(panels.get(id));
@@ -272,70 +173,26 @@
}, 100);
function restore() {
batteryText();
- if (window.VulpesMobileSettings) {
- const panel = document.querySelector('#messaging > div');
- if (panel && !panel.querySelector('[data-vulpes-message-limit]')) {
- const p = document.createElement('p'); p.className = 'explanation'; p.dataset.vulpesMessageLimit = '';
- localize(p, 'Les accusés de réception SMS sont reliés au modem. Les autres options grisées ne sont pas encore prises en charge.', 'SMS delivery reports are connected to the modem. The other greyed-out options are not supported yet.'); panel.prepend(p);
- }
- if (panel) VulpesMobileSettings.messaging(panel);
- for (const control of panel?.querySelectorAll('gaia-switch,select') || []) {
- if (control.dataset.vulpesSupported === 'true') continue;
- if (!control.hasAttribute('disabled')) control.setAttribute('disabled','');
- if (control.disabled !== true) control.disabled = true;
- }
- }
- const clockPanel=document.getElementById('dateTime');
- if(clockPanel?.querySelector('.date-picker') && !navigator.mozTime) {
- for(const control of clockPanel.querySelectorAll('.date-picker,.time-picker,.timezone-picker select')) if(!control.disabled)control.disabled=true;
- if(!clockPanel.querySelector('[data-vulpes-clock-host]')) {
- const row=document.createElement('p');row.className='explanation';row.dataset.vulpesClockHost='';
- localize(row,'La date et le fuseau horaire sont gérés par le système hôte. Le format de l’heure reste réglable ici.', 'Date and timezone are managed by the host system. The time format can still be changed here.');
- clockPanel.querySelector(':scope > div').prepend(row);
- }
- }
- for(const id of ['mediaStorage','applicationStorage']) {
- const content=document.querySelector('#'+id+' > div');
- if(content && !content.querySelector('[data-vulpes-storage-scope]')) {
- const p=document.createElement('p');p.className='explanation';p.dataset.vulpesStorageScope='';
- localize(p,'Espace utilisé par Vulpes et espace libre sur le volume hôte. Les données des autres applications du système ne sont pas incluses.', 'Vulpes storage usage and free space on the host volume. Other host applications are not included.');
- content.prepend(p);
- }
- }
const root = document.querySelector('#root');
if (root) for (const id of ['wifi','bluetooth','carrier','call','messaging','sim-manager','simpin',
'findmydevice','addons','mediaStorage','applicationStorage']) {
const li = root.querySelector('a[href="#' + id + '"]')?.closest('li');
if (li?.hidden) li.hidden = false;
- if (li?.hasAttribute('aria-disabled')) li.removeAttribute('aria-disabled');
- const anchor = li?.querySelector('a');
- if (anchor?.hasAttribute('aria-disabled')) anchor.removeAttribute('aria-disabled');
}
if (capabilities.platform === 'android' && root && !document.getElementById('vulpes-android-settings')) {
const ul = document.createElement('ul'), li = document.createElement('li'), a = document.createElement('a');
a.id = 'vulpes-android-settings'; a.className = 'menu-item'; a.href = '#';
- localize(a, 'Paramètres Android', 'Android settings');
+ a.textContent = label('Paramètres Android', 'Android settings');
a.onclick = event => {event.preventDefault();VulpesCompat.call('platform.settings',{panel:'root'}).catch(() => notice(unavailable()));};
li.append(a); ul.append(li); root.querySelector(':scope > div')?.append(ul);
}
- if (capabilities.platform === 'android') {
- const keyboard=document.querySelector('#keyboard > div');
- if(keyboard && !document.getElementById('vulpes-keyboard-mode')) {
- const ul=document.createElement('ul'),li=document.createElement('li'),control=document.createElement('gaia-switch'),title=document.createElement('label');
- control.id='vulpes-keyboard-mode';
- localize(title,'Clavier Gaia (expérimental)','Gaia keyboard (experimental)');
- control.append(title);li.append(control);ul.append(li);keyboard.prepend(ul);
- VulpesCompat.call('input.config',{}).then(enabled=>{control.checked=enabled;});
- control.addEventListener('change',()=>navigator.mozSettings.createLock().set({'keyboard.gaia.enabled':control.checked}).catch(console.error));
- }
- }
if (capabilities.platform === 'android') {
const homescreens = document.querySelector('#homescreens > div');
if (homescreens && !document.getElementById('vulpes-default-home')) {
const ul = document.createElement('ul'), li = document.createElement('li'), a = document.createElement('a');
a.id = 'vulpes-default-home'; a.className = 'menu-item'; a.href = '#';
- localize(a, 'Accueil Android par défaut', 'Default Android home');
+ a.textContent = label('Accueil Android par défaut', 'Default Android home');
a.onclick = event => {event.preventDefault();VulpesCompat.call('platform.settings',{panel:'home'}).catch(() => notice(unavailable()));};
li.append(a);ul.append(li);homescreens.append(ul);
}
@@ -350,12 +207,12 @@
const legal = document.querySelector('#about-legal > div');
if (legal && !document.getElementById('vulpes-credits')) {
const p = document.createElement('p'); p.id = 'vulpes-credits'; p.className = 'explanation';
- localize(p, 'Vulpes OS repose sur Gaia et Gecko de Mozilla et le travail de leurs contributeurs. Capyloon fait partie des projets qui inspirent cette continuité. Les licences et attributions d’origine sont conservées.',
+ p.textContent = label('Vulpes OS repose sur Gaia et Gecko de Mozilla et le travail de leurs contributeurs. Capyloon fait partie des projets qui inspirent cette continuité. Les licences et attributions d’origine sont conservées.',
'Vulpes OS builds on Mozilla Gaia and Gecko and the work of their contributors. Capyloon is among the projects inspiring this continuation. Original licenses and attributions are retained.');
legal.append(p);
}
}
- new MutationObserver(restore).observe(document.body, {subtree:true, childList:true, attributes:true, attributeFilter:['hidden','aria-disabled','disabled']});
+ new MutationObserver(restore).observe(document.body, {subtree:true, childList:true, attributes:true, attributeFilter:['hidden']});
restore();
}
if (document.readyState === 'loading') addEventListener('DOMContentLoaded', () => ready().catch(console.error), {once:true});
diff --git a/host/gaia-select.js b/host/gaia-select.js
deleted file mode 100644
index c6b94c76..00000000
--- a/host/gaia-select.js
+++ /dev/null
@@ -1,206 +0,0 @@
-// Gaia's value-selector markup and stylesheet, without a browser-chrome popup.
-(() => {
- let active;
- function close(commit) {
- if (!active) return;
- const {select, wrapper, chosen} = active;
- active = null;
- if (commit && select.isConnected && !select.disabled) {
- const before = [...select.options].map(o => o.selected).join(',');
- [...select.options].forEach((o, i) => { o.selected = chosen.has(i); });
- if (before !== [...select.options].map(o => o.selected).join(',')) {
- select.dispatchEvent(new Event('input', {bubbles:true}));
- select.dispatchEvent(new Event('change', {bubbles:true}));
- }
- }
- wrapper.remove();
- // Several original panels commit modem settings on blur.
- select.blur();
- }
- function open(select) {
- if (select.disabled || select.closest('[aria-disabled="true"]')) return;
- close(false);
- if (!document.querySelector('[data-vulpes-select-style]')) {
- const link = document.createElement('link');
- link.rel = 'stylesheet'; link.dataset.vulpesSelectStyle = '';
- link.href = `${location.protocol}//system.localhost:${location.port}/shared/style/value_selector.css`;
- document.head.append(link);
- }
- const wrapper = document.createElement('div'); wrapper.className = 'value-selector';
- wrapper.id = 'vulpes-value-selector-layer';
- const form = document.createElement('form');
- form.id = 'vulpes-value-selector'; form.role = 'dialog';
- form.dataset.type = 'value-selector'; form.setAttribute('aria-modal', 'true');
- form.innerHTML = 'OK ';
- form.setAttribute('aria-labelledby', 'vulpes-select-title');
- const fr = (document.documentElement.lang || navigator.language).startsWith('fr');
- form.querySelector('[data-action="cancel"]').textContent = fr ? 'Annuler' : 'Cancel';
- form.querySelector('h1').textContent = document.getElementById(select.getAttribute('aria-labelledby'))?.textContent || select.closest('li')?.querySelector('span[data-l10n-id],label,p')?.textContent || (fr ? 'Choisir une option' : 'Choose an option');
- const chosen = new Set([...select.options].flatMap((o,i) => o.selected ? [i] : []));
- const list = form.querySelector('ol');
- if (select.multiple) list.setAttribute('aria-multiselectable', 'true');
- [...select.children].forEach(child => {
- if (child.tagName === 'OPTGROUP') {
- const heading = document.createElement('li'); heading.role = 'subheader';
- heading.textContent = child.label; list.append(heading);
- }
- for (const option of child.tagName === 'OPTGROUP' ? child.children : [child]) {
- const i = [...select.options].indexOf(option);
- if (i < 0 || option.hidden) continue;
- const li = document.createElement('li'); li.role = 'option'; li.tabIndex = 0;
- li.dataset.index = i; li.dataset.value = option.value;
- li.setAttribute('aria-selected', String(chosen.has(i)));
- const disabled = option.disabled || option.parentElement.disabled === true;
- li.setAttribute('aria-disabled', String(disabled));
- const label = document.createElement('label'), span = document.createElement('span');
- label.role = 'presentation'; span.textContent = option.textContent;
- label.append(span); li.append(label); list.append(li);
- const pick = () => {
- if (disabled) return;
- if (!select.multiple) chosen.clear();
- if (select.multiple && chosen.has(i)) chosen.delete(i); else chosen.add(i);
- list.querySelectorAll('[role="option"]').forEach(n => n.setAttribute('aria-selected', String(chosen.has(Number(n.dataset.index)))));
- };
- li.onclick = pick;
- li.onkeydown = event => { if (['Enter',' '].includes(event.key)) {event.preventDefault();pick();} };
- }
- });
- active = {select, form, wrapper, chosen};
- select.focus({preventScroll:true});
- form.onsubmit = event => {event.preventDefault();close(true);};
- form.querySelector('[data-action="cancel"]').onclick = () => close(false);
- wrapper.append(form); document.body.append(wrapper);
- }
- // Gecko's embedded native select may consume the compatibility click.
- // Open on a completed tap too, without treating a scroll or a hold as a tap.
- let gesture, recentTap;
- addEventListener('touchstart', event => {
- const select=event.target;
- if (select.tagName !== 'SELECT' || select.size > 1 || event.touches.length !== 1) {gesture=null;return;}
- const point=event.touches[0];
- gesture={select,id:point.identifier,x:point.clientX,y:point.clientY,moved:false,at:performance.now()};
- },{capture:true,passive:true});
- addEventListener('touchmove', event => {
- if (!gesture) return;
- const point=[...event.touches].find(p=>p.identifier===gesture.id);
- if (!point || event.touches.length!==1 || Math.hypot(point.clientX-gesture.x,point.clientY-gesture.y)>10) gesture.moved=true;
- },{capture:true,passive:true});
- addEventListener('touchcancel',()=>{gesture=null;},{capture:true,passive:true});
- addEventListener('touchend', event => {
- const tap=gesture;gesture=null;
- if (!tap || tap.moved || performance.now()-tap.at>600 || event.touches.length) return;
- const point=[...event.changedTouches].find(p=>p.identifier===tap.id);
- if (!point || Math.hypot(point.clientX-tap.x,point.clientY-tap.y)>10) return;
- event.preventDefault();event.stopImmediatePropagation();
- recentTap={select:tap.select,at:performance.now()};open(tap.select);
- },{capture:true,passive:false});
- // Cancel the browser-chrome popup. Mouse and keyboard activation use Gaia too.
- for (const type of ['mousedown','pointerdown']) addEventListener(type, event => {
- if (event.target.tagName === 'SELECT' && event.target.size <= 1) event.preventDefault();
- }, true);
- addEventListener('click', event => {
- if (event.target.tagName !== 'SELECT' || event.target.size > 1) return;
- event.preventDefault(); event.stopImmediatePropagation();
- if (recentTap?.select===event.target && performance.now()-recentTap.at<700) return;
- open(event.target);
- }, true);
- addEventListener('keydown', event => {
- if (active && event.key === 'Escape') {event.preventDefault();close(false);}
- else if (event.target.tagName === 'SELECT' && [' ','Enter','ArrowDown'].includes(event.key)) {
- event.preventDefault();open(event.target);
- }
- }, true);
- addEventListener('pagehide', () => close(false));
- document.addEventListener('visibilitychange', () => {if (document.hidden) close(false);});
- addEventListener('panelready', event => {
- // A late transition event for the same panel must not dismiss a menu
- // opened immediately after navigation. Leaving the panel still cancels it.
- if (active && event.detail?.current !== '#' + active.select.closest('section')?.id) close(false);
- });
-})();
-
-// Date/time pickers still belong to Gaia System. Embedded Gecko does not
-// provide the retired mozInputMethod value-selector events for these inputs.
-(() => {
- const systemOrigin = 'http://system.localhost:8765';
- if (location.origin === systemOrigin) {
- addEventListener('message', event => {
- if (event.data?.type === 'vulpes-keyboard-close' && event.origin === 'http://keyboard.localhost:8765') {
- const frame=[...document.querySelectorAll('iframe')].find(f=>f.contentWindow===event.source && f.src.startsWith(event.origin+'/settings.html'));
- if(frame) {
- const keyboard=Service.query('getApp',event.origin);
- const close=()=>frame.isConnected && frame.dispatchEvent(new CustomEvent('mozbrowserclose',{bubbles:true}));
- // Closing a still-transitioning background window can send Gaia
- // Home over the application which has just replaced it.
- if(keyboard?.isActive())keyboard.element.addEventListener('_closed',close,{once:true});
- else close();
- }
- return;
- }
- const app = window.Service?.query('getActiveApp');
- if (!app || event.source !== app.iframe?.contentWindow || event.origin !== app.origin) return;
- if (event.data?.type !== 'vulpes-datetime-open' || !['date','time'].includes(event.data.inputType)) return;
- const picker = app.valueSelector;
- if (!picker) return;
- const previous = picker._im;
- const reply = value => event.source.postMessage({type:'vulpes-datetime-result', id:event.data.id, value}, event.origin);
- picker._im = {
- setValue: value => reply(value),
- removeFocus() { reply(null); picker._im = previous; }
- };
- picker.handleEvent({type:'_inputfocus', detail:{inputType:event.data.inputType,
- value:String(event.data.value || '').slice(0,32), min:event.data.min, max:event.data.max}});
- });
- return;
- }
- if (location.hostname === 'keyboard.localhost' && /settings/.test(location.pathname)) {
- window.close = () => parent.postMessage({type:'vulpes-keyboard-close'}, systemOrigin);
- }
- let pending, sequence=0;
- addEventListener('click', event => {
- const input=event.target;
- if (input.tagName !== 'INPUT' || !['date','time'].includes(input.type) || input.disabled || input.readOnly) return;
- event.preventDefault();event.stopImmediatePropagation();
- pending={input,id:++sequence};
- parent.postMessage({type:'vulpes-datetime-open',id:sequence,inputType:input.type,
- value:input.value,min:input.min,max:input.max},systemOrigin);
- },true);
- addEventListener('message', event => {
- if(event.source!==parent || event.origin!==systemOrigin || event.data?.type!=='vulpes-datetime-result' || event.data.id!==pending?.id)return;
- const {input}=pending;pending=null;
- if(event.data.value!==null && input.isConnected && !input.disabled) {
- input.value=event.data.value;
- if(input.value) {
- input.dispatchEvent(new Event('input',{bubbles:true}));
- input.dispatchEvent(new Event('change',{bubbles:true}));
- }
- }
- input.blur();
- });
-})();
-
-// Modern native select text metrics differ from Gaia's 4rem button box.
-// Keep the real select for interaction and accessibility, and paint its label
-// in a regular line box so Fira's descenders are not clipped by the control.
-(() => {
- const labels=new WeakMap();
- function refresh() {
- for(const select of document.querySelectorAll('.button select,.bb-button select')) {
- if(select.multiple || select.size>1)continue;
- let label=labels.get(select);
- if(!label) {
- label=document.createElement('span');label.className='vulpes-select-label';
- label.setAttribute('aria-hidden','true');select.after(label);labels.set(select,label);
- select.classList.add('vulpes-select-label-owner');
- select.addEventListener('change',refresh);
- }
- const text=select.selectedOptions[0]?.textContent || '';
- if(label.textContent!==text)label.textContent=text;
- }
- }
- let queued=false;
- new MutationObserver(()=>{if(!queued){queued=true;queueMicrotask(()=>{queued=false;refresh();});}})
- .observe(document.documentElement,{childList:true,subtree:true,characterData:true,attributes:true,attributeFilter:['selected','value']});
- addEventListener('panelready',refresh);addEventListener('localized',refresh);
- refresh();
-})();
diff --git a/host/gaia-services.js b/host/gaia-services.js
index de360e80..238282e8 100644
--- a/host/gaia-services.js
+++ b/host/gaia-services.js
@@ -18,21 +18,6 @@
function unavailable(message) {
window.VulpesGaiaUI?.notice(message);
}
- if(location.hostname==='system.localhost') {
- const colorKeys=['accessibility.colors.enable','accessibility.colors.invert','accessibility.colors.grayscale','accessibility.colors.contrast'];
- const values={};
- function colors() {
- const screen=document.getElementById('screen');if(!screen)return;
- const enabled=values[colorKeys[0]]===true;
- const contrast=Number(values[colorKeys[3]])||0;
- screen.style.filter=enabled ? `invert(${values[colorKeys[1]]?1:0}) grayscale(${values[colorKeys[2]]?1:0}) contrast(${Math.max(0,Math.min(2,1+contrast))})` : '';
- }
- for(const key of colorKeys) {
- navigator.mozSettings.addObserver(key,e=>{values[key]=e.settingValue;colors();});
- navigator.mozSettings.createLock().get(key).then(row=>{values[key]=row[key];colors();});
- }
- addEventListener('DOMContentLoaded',colors,{once:true});
- }
addEventListener('DOMContentLoaded', () => {
if (location.hostname === 'system.localhost' && !window.VulpesPlatform) {
const ids = ['wifi', 'bluetooth', 'data', 'airplane-mode'];
@@ -87,23 +72,6 @@
);
}
});
- if(['settings.localhost','system.localhost'].includes(location.hostname) && window.VulpesPlatform!=='android') {
- const manager=Object.assign(new EventTarget(),{}), downloads=new Map();
- manager.getDownloads=async()=>{
- const rows=await call('downloads.request',{operation:'list'});
- return rows.map(row=>{
- let item=downloads.get(row.id);
- if(!item) {
- item=Object.assign(new EventTarget(),{pause:()=>call('downloads.request',{operation:'pause',id:row.id}),resume:()=>call('downloads.request',{operation:'resume',id:row.id})});
- downloads.set(row.id,item);
- }
- Object.assign(item,row,{vulpesDownload:true,getBlob:()=>call('downloads.request',{operation:'blob',id:row.id}),startTime:new Date(row.startTime)});return item;
- });
- };
- manager.remove=item=>call('downloads.request',{operation:'remove',id:item.id});
- manager.clearAllDone=async()=>{for(const item of await manager.getDownloads())if(item.state==='succeeded')await manager.remove(item);};
- Object.defineProperty(navigator,'mozDownloadManager',{value:manager});
- }
Object.defineProperty(navigator, 'mozAlarms', {
value: {
getAll: () =>
@@ -254,15 +222,6 @@
}
window.VulpesCompat.cursor = cursor;
if (location.hostname === 'sms.localhost') {
- addEventListener('DOMContentLoaded', () => {
- const send = document.getElementById('messages-send-button');
- if (!send) return;
- send.setAttribute('data-vulpes-keep-input', '');
- // Keep the composer focused, including with Android's native keyboard.
- // Sending remains a click action, so a cancelled gesture never sends.
- for (const type of ['pointerdown', 'mousedown'])
- send.addEventListener(type, event => event.preventDefault());
- });
Object.defineProperty(navigator, 'mozMobileConnections', { value: Object.freeze([]) });
const messages = new EventTarget();
for (const operation of ['getThreads', 'getMessages'])
@@ -283,10 +242,11 @@
'getSegmentInfoForText',
])
messages[operation] = (...args) => request(call('messages.request', { operation, args }));
- const dated = raw => { const value=window.VulpesSettingsCodec.decode(raw);return value && typeof value==='object' && 'timestamp' in value ?
- {...value,timestamp:new Date(value.timestamp)} : value; };
+ const dated = value => value && typeof value==='object' && 'timestamp' in value ?
+ {...value,timestamp:new Date(value.timestamp)} : value;
messages.getMessage = id => request(call('messages.request',{operation:'getMessage',args:[id]}).then(dated));
- const submit = (operation,args) => {
+ messages.send = (recipients,body,options) => {
+ const send = recipient => {
let id, settle;
const early = new Map();
const terminal = new Promise((resolve,reject)=>{settle=message=>{
@@ -300,7 +260,7 @@
else if(id===undefined && early.size<100) early.set(message.id,message);
};
addEventListener('vulpes-service-event',listener);
- const result=call('messages.request',{operation,args}).then(message=>{
+ const result=call('messages.request',{operation:'send',args:[recipient,body,options]}).then(message=>{
if(message.delivery!=='sending') return dated(message);
id=message.id;
if(early.has(id)) settle(early.get(id));
@@ -308,12 +268,9 @@
return terminal;
}).finally(()=>removeEventListener('vulpes-service-event',listener));
return request(result);
- };
- messages.send = (recipients,body,options) => {
- const send=recipient=>submit('send',[recipient,body,options]);
+ };
return Array.isArray(recipients) ? recipients.map(send) : send(recipients);
};
- messages.sendMMS = (message,options) => submit('sendMMS',[message,options]);
addEventListener('vulpes-service-event',({detail})=>{
const type = {'sms-received':'received','sms-sending':'sending','sms-sent':'sent','sms-failed':'failed'}[detail.type];
if (!type) return;
@@ -324,22 +281,17 @@
Object.defineProperty(navigator, 'mozMobileMessage', { value: messages });
}
if (
- ['settings', 'gallery', 'music', 'video', 'camera', 'ringtones'].includes(location.hostname.split('.')[0])
+ ['gallery', 'music', 'video', 'camera', 'ringtones'].includes(location.hostname.split('.')[0])
) {
const stores = new Map();
function storage(type) {
- if (!['pictures', 'music', 'videos', ...(location.hostname==='settings.localhost'?['apps','sdcard']:[])].includes(type)) return null;
+ if (!['pictures', 'music', 'videos'].includes(type)) return null;
if (stores.has(type)) return stores.get(type);
const s = new EventTarget();
s.storageName = 'sdcard';
s.default = true;
s.canBeMounted = false;
- const run = (operation, params = {}) => {
- if(!['apps','sdcard'].includes(type))return call('media.request', { type, operation, ...params });
- if(operation==='available')return call('storage.stats',{}).then(()=>'available');
- if(['freeSpace','usedSpace'].includes(operation))return call('storage.stats',{}).then(stats=>operation==='freeSpace'?stats.free:stats[type]);
- return Promise.reject(new DOMException('Read-only storage statistics','NotSupportedError'));
- };
+ const run = (operation, params = {}) => call('media.request', { type, operation, ...params });
for (const op of ['available', 'freeSpace', 'usedSpace']) s[op] = () => request(run(op));
s.get = (name) =>
request(
diff --git a/host/gaia-styles.js b/host/gaia-styles.js
index 1ece3fb7..01000cf2 100644
--- a/host/gaia-styles.js
+++ b/host/gaia-styles.js
@@ -109,14 +109,6 @@
// Only a custom element's type selector names the scope root. A div
// scope still contains ordinary div descendants (notably Lockscreen).
if (owner.localName.includes('-')) text = text.replace(tag, ':scope');
- if (owner.id) {
- const rootId = '#' + CSS.escape(owner.id);
- const pattern = new RegExp(rootId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?![\\w-])', 'g');
- // Old scoped sheets could style their owner by ID. Modern @scope
- // requires :scope for that element (for example the volume HUD).
- text = text.replace(/([^{}]+)\{/g, (rule, selector) =>
- selector.replace(pattern, ':scope') + '{');
- }
text = text.replace(/\.-(?:host|content)(?![\w-])/g, (match) => ':scope' + match);
style.textContent = '@scope ([data-vulpes-scope="' + id + '"]) {' + text + '}';
style.removeAttribute('scoped');
diff --git a/host/gaia-theme.css b/host/gaia-theme.css
deleted file mode 100644
index b2432989..00000000
--- a/host/gaia-theme.css
+++ /dev/null
@@ -1,127 +0,0 @@
-/* Keep Gaia geometry, icons and media intact; change the surfaces and ink. */
-:root[data-vulpes-theme="dark"] {
- color-scheme: dark;
- --vulpes-surface: #18232e;
- --vulpes-raised: #253442;
- --vulpes-text: #f1f5f9;
- --vulpes-muted: #b8c8d8;
- --vulpes-line: #526272;
- --link-color: #79ceff;
- --header-background: var(--vulpes-raised);
- --header-color: var(--vulpes-text);
- --header-title-color: var(--vulpes-text);
- --header-button-color: var(--vulpes-text);
- --header-icon-color: var(--vulpes-text);
- --header-action-button-color: #79ceff;
- --header-disabled-button-color: var(--vulpes-muted);
-}
-:root[data-vulpes-theme="dark"]:is([data-vulpes-app="settings"],
- [data-vulpes-app="communications"], [data-vulpes-app="sms"],
- [data-vulpes-app="email"], [data-vulpes-app="calendar"],
- [data-vulpes-app="ringtones"]) {
- --background: var(--vulpes-surface);
- --text-color: var(--vulpes-text);
- --title-color: var(--vulpes-text);
- --label-color: var(--vulpes-text);
- background: var(--vulpes-surface) !important;
- color: var(--vulpes-text) !important;
-}
-:root[data-vulpes-theme="dark"] gaia-header {
- --header-background: var(--vulpes-raised) !important;
- --header-title-color: var(--vulpes-text) !important;
- --header-action-button-color: #79ceff !important;
-}
-#display #vulpes-theme-row {
- display: block !important;
- min-height: 10rem;
- height: auto !important;
- padding-bottom: 1rem;
-}
-#vulpes-theme-row > p {
- height: auto;
- line-height: 2.4rem;
- padding: 1rem 1.5rem;
- transform: none;
-}
-:root[data-vulpes-theme="dark"][data-vulpes-app="settings"] h2 {
- background-color: var(--vulpes-surface) !important;
-}
-:root[data-vulpes-theme="dark"]:is([data-vulpes-app="settings"],
- [data-vulpes-app="communications"], [data-vulpes-app="sms"],
- [data-vulpes-app="email"], [data-vulpes-app="calendar"],
- [data-vulpes-app="ringtones"]) :is(body, section, [role="region"],
- [data-type="list"], .view, .card, #main, #messages-container, #thread-list,
- #contacts-list, #view-contacts-list, #no-contacts, #call-log-container, #messages-compose-form) {
- background-color: var(--vulpes-surface) !important;
- color: var(--vulpes-text) !important;
-}
-:root[data-vulpes-theme="dark"] :is(input:not([type="range"]):not([type="checkbox"]):not([type="radio"]), textarea, select, [contenteditable="true"]) {
- background-color: var(--vulpes-raised) !important;
- color: var(--vulpes-text) !important;
- border-color: var(--vulpes-line) !important;
- caret-color: #79ceff;
-}
-:root[data-vulpes-theme="dark"] :is(input, textarea)::placeholder { color: var(--vulpes-muted) !important; }
-:root[data-vulpes-theme="dark"][data-vulpes-app="settings"] :is(li, li > a, li > a > span, li > p, li > small, label, .explanation, h1, h2) {
- color: var(--vulpes-text) !important;
- border-color: var(--vulpes-line) !important;
-}
-:root[data-vulpes-theme="dark"][data-vulpes-app="settings"] :is(ul, header, .button, button:not(.danger)) {
- background-color: var(--vulpes-raised) !important;
- color: var(--vulpes-text) !important;
-}
-:root[data-vulpes-theme="dark"][data-vulpes-app="communications"] :is(.contact-item, .contact-item a, .contact-item p, #call-log-container li, #call-log-container a, #call-log-container p, .phone-number, #keypad-phone-number, .keypad-key) {
- color: var(--vulpes-text) !important;
-}
-:root[data-vulpes-theme="dark"][data-vulpes-app="communications"] #phone-number-view-container {
- background-color: var(--vulpes-raised) !important;
-}
-:root[data-vulpes-theme="dark"][data-vulpes-app="communications"] :is(.keypad-subicon, [role="tab"]) {
- color: #79ceff !important;
-}
-:root[data-vulpes-theme="dark"][data-vulpes-app="communications"] [role="tab"][aria-selected="true"] {
- background-color: var(--vulpes-raised) !important;
-}
-:root[data-vulpes-theme="dark"][data-vulpes-app="communications"] #no-contacts :is(p, .info) {
- color: var(--vulpes-muted) !important;
-}
-:root[data-vulpes-theme="dark"][data-vulpes-app="communications"] #no-contacts {
- background-blend-mode: soft-light;
-}
-:root[data-vulpes-theme="dark"][data-vulpes-app="sms"] :is(.threadlist-item, .threadlist-item a, .threadlist-item p, .time, .name, .body-text) {
- color: var(--vulpes-text) !important;
-}
-:root[data-vulpes-theme="dark"][data-vulpes-app="sms"] .bubble {
- background-color: var(--vulpes-raised) !important;
- color: var(--vulpes-text) !important;
-}
-:root[data-vulpes-theme="dark"] :is([data-type="confirm"], [data-type="action"]) :is(section, menu, header, p) {
- background-color: var(--vulpes-raised) !important;
- color: var(--vulpes-text) !important;
-}
-:root[data-vulpes-theme="dark"][data-vulpes-app="system"] :is(#utility-tray, #notifications-container) {
- 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/host/gaia-theme.js b/host/gaia-theme.js
deleted file mode 100644
index f6745cf3..00000000
--- a/host/gaia-theme.js
+++ /dev/null
@@ -1,58 +0,0 @@
-// Shared appearance preference; external browser pages are not injected.
-(() => {
- const key = 'vulpes.theme';
- const system = matchMedia('(prefers-color-scheme: dark)');
- let mode = 'light', select;
- function apply(value) {
- mode = ['light', 'dark', 'system'].includes(value) ? value : 'light';
- document.documentElement.dataset.vulpesTheme =
- mode === 'system' ? (system.matches ? 'dark' : 'light') : mode;
- if (select) select.value = mode;
- }
- system.addEventListener('change', () => apply(mode));
- addEventListener('vulpes-service-event', ({detail}) => {
- if (detail.type === 'settingchange' && detail.data.key === key) apply(detail.data.value);
- });
- VulpesCompat.call('appearance.current', {}).then(apply).catch(console.error);
-
- if (location.hostname !== 'settings.localhost') return;
- function label() {
- const lang = document.documentElement.lang || navigator.language;
- const labels = lang.startsWith('fr') ? ['Apparence', 'Clair', 'Sombre', 'Selon le système'] :
- lang.startsWith('el') ? ['Εμφάνιση', 'Φωτεινό', 'Σκοτεινό', 'Σύμφωνα με το σύστημα'] :
- ['Appearance', 'Light', 'Dark', 'Follow system'];
- const title = document.getElementById('vulpes-theme-label');
- if (title && title.textContent !== labels[0]) title.textContent = labels[0];
- if (select) [...select.options].forEach((option, i) => {
- if (option.textContent !== labels[i + 1]) option.textContent = labels[i + 1];
- });
- }
- function insert() {
- const list = document.querySelector('#display > div > ul');
- if (!list || select?.isConnected) return;
- const row = document.createElement('li'), title = document.createElement('p');
- row.id = 'vulpes-theme-row';
- title.id = 'vulpes-theme-label';
- const wrapper = document.createElement('span');
- wrapper.className = 'button icon icon-dialog';
- select = document.createElement('select');
- select.id = 'vulpes-theme';
- select.setAttribute('aria-labelledby', title.id);
- for (const value of ['light', 'dark', 'system']) {
- const option = document.createElement('option');
- option.value = value; select.append(option);
- }
- select.value = mode;
- select.onchange = async () => {
- select.disabled = true;
- try { await navigator.mozSettings.createLock().set({[key]:select.value}); }
- catch (error) { select.value = mode; console.error(error); }
- finally { select.disabled = false; }
- };
- wrapper.append(select); row.append(title, wrapper); list.append(row); label();
- }
- addEventListener('localized', label);
- new MutationObserver(() => { insert(); label(); }).observe(document.documentElement,
- {childList:true, subtree:true, attributes:true, attributeFilter:['lang']});
- insert();
-})();
diff --git a/host/gaia-wifi.js b/host/gaia-wifi.js
index 9de90ff4..a632cd95 100644
--- a/host/gaia-wifi.js
+++ b/host/gaia-wifi.js
@@ -6,7 +6,7 @@
enabled:false, connection:{status:'disconnected',network:null},
connectionInformation:null, macAddress:'',
});
- let networks = [], connectionInfo = {};
+ let networks = [];
function emit(type, values = {}) {
const event = Object.assign(new Event(type), values);
manager.dispatchEvent(event); manager['on' + type]?.(event);
@@ -21,11 +21,9 @@
const status = network ? 'connected' : 'disconnected';
const changed = manager.connection.status !== status || manager.connection.network?.ssid !== network?.ssid;
manager.connection = {status, network};
- manager.connectionInformation = network ? {...connectionInfo,relSignalStrength:network.relSignalStrength} : null;
+ manager.connectionInformation = network ? {relSignalStrength:network.relSignalStrength} : null;
if (changed) emit('statuschange', manager.connection);
- // Gaia's signal listeners require the associated network. A scan while
- // disconnected must not announce connection information for a missing AP.
- if (network) emit('connectioninfoupdate', {network, ...manager.connectionInformation});
+ emit('connectioninfoupdate', manager.connectionInformation || {});
}
let scanPending;
function scan() {
@@ -36,23 +34,17 @@
async function scanNetworks() {
const result = await call('platform.wifi',{operation:'scan'});
power(result.enabled !== false);
- connectionInfo=result.connectionInformation || {};
networks = result.networks.map(n => ({ssid:n.ssid, relSignalStrength:n.signal,
- security:n.security === 'open' ? [] : [['wpa-psk','sae'].includes(n.security) ? 'WPA-PSK' : 'WPA-EAP'],
+ security:n.security === 'open' ? [] : [n.security === 'wpa-psk' ? 'WPA-PSK' : n.security === 'sae' ? 'SAE' : 'WPA-EAP'],
known:n.known === true, connected:n.connected, vulpesId:n.id, savedId:n.savedId}));
update(networks.find(n => n.connected) || null);
return networks;
}
manager.getNetworks = () => request(scan());
- let knownPending;
- manager.getKnownNetworks = () => {
- if (!knownPending) knownPending = call('platform.wifi',{operation:'known'}).then(r=>r.networks.map(n=>({ssid:n.ssid,security:n.security==='open'?[]:['WPA-PSK'],known:true,savedId:n.id}))).finally(()=>{knownPending=null;});
- return request(knownPending);
- };
+ manager.getKnownNetworks = () => request(call('platform.wifi',{operation:'known'}).then(r=>r.networks.map(n=>({ssid:n.ssid,security:n.security==='open'?[]:['WPA-PSK'],known:true,savedId:n.id}))));
manager.associate = network => request((async () => {
- const security=(network.security || []).filter(Boolean);
- if (security.length && security[0] !== 'WPA-PSK') throw Error('WIFI_UNSUPPORTED_SECURITY');
- await call('platform.wifi',network.hidden ? {operation:'connectHidden',ssid:network.ssid,security:security.length?'wpa-psk':'open',password:network.psk || ''} : {operation:'connect',id:network.vulpesId,password:network.psk || ''});
+ if (network.security.length && network.security[0] !== 'WPA-PSK') throw Error('WIFI_UNSUPPORTED_SECURITY');
+ await call('platform.wifi',{operation:'connect',id:network.vulpesId,password:network.psk || ''});
await scan(); return network;
})());
manager.forget = network => request(call('platform.wifi',{operation:'forget',id:network.savedId}).then(()=>scan()));
@@ -60,91 +52,6 @@
manager.wps = unsupported;
manager.setPowerSavingMode = unsupported;
Object.defineProperty(navigator,'mozWifiManager',{value:manager});
- if(location.hostname.startsWith('settings.')) {
- const wifi = value => call('platform.wifi',value);
- const french = () => (document.documentElement.lang || navigator.language || '').startsWith('fr');
- const message = (fr,en) => french() ? fr : en;
- const note = panel => {
- const p=document.createElement('p');p.className='explanation';p.setAttribute('role','status');
- panel.querySelector(':scope > div').prepend(p);return p;
- };
- function hotspotPanel() {
- let panel,toggle,link,status,state,busy=false;
- function render() {
- toggle.checked=state?.enabled===true;toggle.disabled=busy || !state;
- link.setAttribute('aria-disabled',String(busy || !state || state.enabled));
- panel.querySelector('#wifi-hotspot-msg').textContent=state?.enabled ? state.ssid : '';
- }
- async function refresh() {
- busy=true;render();
- try {state=await wifi({operation:'hotspotStatus'});status.textContent='';
- await call('settings.set',{values:{'tethering.wifi.enabled':state.enabled}});
- } catch(error) {state=null;status.textContent=message('Impossible de lire le point d’accès Wi-Fi.','Cannot read Wi-Fi hotspot status.');}
- finally {busy=false;render();}
- }
- return {
- onInit(element) {
- panel=element;toggle=panel.querySelector('#tethering-wifi-enabled');link=panel.querySelector('#hotspot-settings-section a');status=note(panel);
- const usb=panel.querySelector('#tethering-usb-enabled');usb.disabled=true;
- const explanation=document.createElement('p');explanation.className='explanation';
- explanation.textContent=message('Le partage par USB n’est pas encore disponible.','USB tethering is not available yet.');usb.closest('ul').append(explanation);
- link.addEventListener('click',event=>{
- event.preventDefault();event.stopImmediatePropagation();if(busy || !state || state.enabled)return;
- require(['modules/dialog_service'],DialogService=>DialogService.show('hotspot-wifiSettings',{state}).then(refresh));
- });
- toggle.addEventListener('change',async()=>{
- if(busy || !state)return;
- if(!state.configured) {toggle.checked=false;status.textContent=message('Configurez d’abord le nom et le mot de passe du point d’accès.','Configure the hotspot name and password first.');return;}
- const enabled=toggle.checked;busy=true;render();
- status.textContent=message('Modification du partage Wi-Fi…','Changing Wi-Fi sharing…');
- try {
- if(enabled && (await call('settings.get',{key:'airplaneMode.status'})).value==='enabled')throw Error('AIRPLANE_MODE');
- state=await wifi({operation:'hotspotPower',enabled});
- await call('settings.set',{values:{'tethering.wifi.enabled':state.enabled}});status.textContent='';
- } catch(error) {
- try {state=await wifi({operation:'hotspotStatus'});}catch {state=null;}
- status.textContent=error.message==='AIRPLANE_MODE' ? message('Désactivez le mode avion avant de partager la connexion.','Turn off airplane mode before sharing the connection.') : message('Le changement a échoué. Vérifiez la connexion réseau.','The change failed. Check the network connection.');
- } finally {busy=false;render();}
- });
- },onBeforeShow:refresh
- };
- }
- function hotspotDialog() {
- let panel,ssid,security,password,show,status,busy=false;
- function visibility() {
- const hidden=security.value==='open';
- panel.querySelector('.password').hidden=hidden;panel.querySelector('.password-description').hidden=hidden;
- }
- return {
- onInit(element) {
- panel=element;ssid=panel.querySelector('input[type=text]');security=panel.querySelector('select');password=panel.querySelector('input[name=password]');show=panel.querySelector('gaia-checkbox');status=note(panel);
- // These credentials belong to NetworkManager, not the global settings database.
- for(const control of [ssid,security,password])control.removeAttribute('data-setting');
- security.replaceChildren(new Option('WPA2','wpa2-psk'),new Option(message('Ouvert','Open'),'open'));
- security.addEventListener('change',visibility);
- show.addEventListener('change',()=>{password.type=show.checked?'text':'password';});
- },
- onBeforeShow(element,options) {
- const state=options.state;ssid.value=state.ssid;security.value=state.security;password.value=state.password;
- password.type='password';show.checked=false;status.textContent='';visibility();
- },
- async onSubmit() {
- if(busy)throw Error('HOTSPOT_BUSY');
- const values={operation:'hotspotSave',ssid:ssid.value,security:security.value,password:password.value};
- if(!values.ssid || new TextEncoder().encode(values.ssid).length>32 || (values.security!=='open' && (new TextEncoder().encode(values.password).length<8 || new TextEncoder().encode(values.password).length>63))) {
- status.textContent=message('Nom : 1 à 32 octets. Mot de passe WPA2 : 8 à 63 octets.','Name: 1–32 bytes. WPA2 password: 8–63 bytes.');throw Error('HOTSPOT_INVALID');
- }
- busy=true;const controls=panel.querySelectorAll('input,select,button,gaia-checkbox');controls.forEach(c=>c.disabled=true);
- try {await wifi(values);return {};}
- catch(error) {status.textContent=message('Impossible d’enregistrer le point d’accès.','Cannot save the hotspot.');throw error;}
- finally {busy=false;controls.forEach(c=>c.disabled=false);}
- },
- onCancel() {if(busy)throw Error('HOTSPOT_BUSY');},
- onHide() {password.value='';}
- };
- }
- window.VulpesWifiSettings={panels:{hotspot:hotspotPanel},dialogs:{'hotspot-wifiSettings':hotspotDialog}};
- }
addEventListener('DOMContentLoaded', async () => {
try {
const state = await call('platform.wifi',{operation:'status'});
@@ -158,7 +65,6 @@
try {
const result=await call('platform.wifi',{operation:'setEnabled',enabled});
power(result.enabled,true);
- if(!result.enabled)await call('settings.set',{values:{'tethering.wifi.enabled':false}});
} catch(error) {
await call('settings.set',{values:{'wifi.enabled':manager.enabled}});
console.warn('Wi-Fi radio change failed',error);
diff --git a/host/marketplace-page.js b/host/marketplace-page.js
deleted file mode 100644
index e2cf40e9..00000000
--- a/host/marketplace-page.js
+++ /dev/null
@@ -1,14 +0,0 @@
-// Runs only on our remote catalogue. It exposes no privileged API to the page.
-function installMarketplacePage(win, send) {
- if(win.location.origin!=='https://vulpes-os.org' || !win.location.pathname.startsWith('/marketplace/'))return;
- const doc=win.document;
- for(const link of doc.querySelectorAll('a[href]')) {
- const url=new URL(link.href);
- if(url.origin!=='https://vulpes-os.org' || url.pathname!=='/marketplace/index.php' || !/^\d{1,10}$/.test(url.searchParams.get('download')||''))continue;
- link.textContent=doc.documentElement.lang==='fr'?'Installer dans Vulpes':'Install in Vulpes';
- link.addEventListener('click',event=>{
- if(!event.isTrusted || event.button!==0 || event.ctrlKey || event.metaKey || event.shiftKey)return;
- event.preventDefault();send(url.href);
- });
- }
-}
diff --git a/host/platform-ui.js b/host/platform-ui.js
index 241b2174..182d4947 100644
--- a/host/platform-ui.js
+++ b/host/platform-ui.js
@@ -86,18 +86,6 @@
try { return await call('platform.' + operation, params); }
catch (error) { failure(error); }
};
- if (app === 'communications') {
- // All Gaia call entry points use the same authorized host operation.
- window.VulpesPlatformDial = async number => {
- try {
- return navigator.mozTelephony ? await navigator.mozTelephony.dial(number) :
- await call('platform.dial', {number});
- } catch (error) {
- failure(error);
- throw error;
- }
- };
- }
function button(id, french, english, action) {
const b = text(document.createElement('button'), french, english);
b.id = id;
@@ -157,10 +145,7 @@
event.preventDefault();
event.stopImmediatePropagation();
const number = document.getElementById('phone-number-view')?.value;
- if (number) {
- if (window.CallHandler) CallHandler.call(number, 0);
- else window.VulpesPlatformDial(number).catch(() => {});
- }
+ if (number) run('dial', {number});
}, true);
async function initialize() {
const caps = await capabilities;
diff --git a/host/settings-codec.js b/host/settings-codec.js
deleted file mode 100644
index bb361377..00000000
--- a/host/settings-codec.js
+++ /dev/null
@@ -1,41 +0,0 @@
-// Settings persistence and Android RPC use JSON. Preserve binary Gaia values across
-// both boundaries and reconstruct Blobs in the application's own JS realm.
-(() => {
- const marker = '__vulpesBlobV1';
- async function encode(value) {
- if (value instanceof Date) return value.toJSON();
- if (value instanceof Blob) {
- if (value.size > 16 * 1024 * 1024) throw new Error('SETTING_BLOB_TOO_LARGE');
- const bytes = new Uint8Array(await value.arrayBuffer());
- const chunks = [];
- for (let i = 0; i < bytes.length; i += 16384) {
- chunks.push(String.fromCharCode(...bytes.subarray(i, i + 16384)));
- }
- return { [marker]: true, type: value.type, base64: btoa(chunks.join('')) };
- }
- if (Array.isArray(value)) return Promise.all(value.map(encode));
- if (value && typeof value === 'object') {
- return Object.fromEntries(
- await Promise.all(
- Object.entries(value).map(async ([key, item]) => [key, await encode(item)]),
- ),
- );
- }
- return value;
- }
- function decode(value) {
- if (!value || typeof value !== 'object' || value instanceof Blob || value instanceof Date) return value;
- if (
- value[marker] === true &&
- typeof value.type === 'string' &&
- typeof value.base64 === 'string' &&
- Object.keys(value).length === 3
- ) {
- const bytes = Uint8Array.from(atob(value.base64), (c) => c.charCodeAt(0));
- return new Blob([bytes], { type: value.type });
- }
- if (Array.isArray(value)) return value.map(decode);
- return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, decode(item)]));
- }
- window.VulpesSettingsCodec = Object.freeze({ encode, decode });
-})();
diff --git a/host/shell.js b/host/shell.js
index cfc1615f..b1e22863 100644
--- a/host/shell.js
+++ b/host/shell.js
@@ -30,15 +30,7 @@ window.addEventListener('load', async () => {
const pollKeys = async () => {
try {
const state = await IOUtils.readJSON('/run/tundra-buttons/events.json');
- if (state.session !== session) {
- session = state.session;
- // Keep events already present when the poller first sees a new
- // service session. Setting the sequence to the file's final
- // value silently dropped the first physical key presses after a
- // Gecko reload.
- const first = state.events?.[0]?.id;
- sequence = Number.isInteger(first) ? first - 1 : 0;
- }
+ if (state.session !== session) { session = state.session; sequence = state.sequence; }
else if (Date.now()/1000-state.recordedAt < 5) {
for (const event of state.events) if (event.id > sequence) {
Runtime.broadcast('hardware-key', {type:event.type}, 'system');
@@ -54,16 +46,11 @@ window.addEventListener('load', async () => {
parent: { esModuleURI: 'resource://vulpes/host/actors/VulpesParent.sys.mjs' },
child: {
esModuleURI: 'resource://vulpes/host/actors/VulpesChild.sys.mjs',
- events: { DOMDocElementInserted: { capture: true }, focusin:{capture:true}, pointerdown:{capture:true}, click:{capture:true} },
+ events: { DOMDocElementInserted: { capture: true }, focusin:{capture:true}, pointerdown:{capture:true} },
},
allFrames: true,
safeForUntrustedWebProcess: true,
- matches: Runtime.apps.filter(app=>!app.installed).map((app) => 'http://' + Services.io.newURI(app.origin).host + '/*'),
- });
- ChromeUtils.registerWindowActor('VulpesMarketplace', {
- parent:{esModuleURI:'resource://vulpes/host/actors/MarketplaceParent.sys.mjs'},
- child:{esModuleURI:'resource://vulpes/host/actors/MarketplaceChild.sys.mjs',events:{DOMContentLoaded:{capture:true}}},
- allFrames:true,safeForUntrustedWebProcess:true,matches:['https://vulpes-os.org/marketplace/*']
+ matches: Runtime.apps.map((app) => 'http://' + Services.io.newURI(app.origin).host + '/*'),
});
ChromeUtils.registerWindowActor('VulpesTouch', {
child: {
@@ -97,11 +84,6 @@ window.addEventListener('load', async () => {
browser.style.width = width + 'px';
browser.style.height = height + 'px';
homeButton.style.width = landscape ? '44px' : window.innerWidth + 'px';
- // Flush the embedder's layout before notifying the remote document.
- // On Wayland its initial viewport can otherwise remain 0x0 until an
- // unrelated chrome layout query, leaving Gaia and video unpainted.
- browser.getBoundingClientRect();
- browser.frameLoader?.requestUpdatePosition?.();
};
window.addEventListener('resize', resize);
@@ -172,7 +154,6 @@ window.addEventListener('load', async () => {
home.setPointerCapture(event.pointerId);
holdTimer = setTimeout(() => {
held = true;
- Views.hideAll();
Runtime.broadcast('holdhome', {}, 'system');
}, 650);
});
@@ -181,13 +162,12 @@ window.addEventListener('load', async () => {
home.addEventListener('pointerup', cancel);
home.addEventListener('pointercancel', cancel);
home.addEventListener('click', () => {
- if (!held) {Views.hideAll();Runtime.broadcast('home', {}, 'system');}
+ if (!held) Runtime.broadcast('home', {}, 'system');
held = false;
});
window.addEventListener('keydown', (event) => {
if (event.ctrlKey && event.code === 'Space') {
event.preventDefault();
- Views.hideAll();
Runtime.broadcast('holdhome', {}, 'system');
}
});
diff --git a/overrides/camera.gaiamobile.org/js/lib/dcf.js b/overrides/camera.gaiamobile.org/js/lib/dcf.js
deleted file mode 100644
index ba28ba98..00000000
--- a/overrides/camera.gaiamobile.org/js/lib/dcf.js
+++ /dev/null
@@ -1,101 +0,0 @@
-define(function(require, exports, module) {
-'use strict';
-
-// This handles the logic pertaining to the naming of files according
-// to the Design rule for Camera File System
-// * http://en.wikipedia.org/wiki/Design_rule_for_Camera_File_system
-
-/**
- * Dependencies
- */
-
-var asyncStorage = require('asyncStorage');
-var debug = require('debug')('dcf');
-var format = require('format');
-
-/**
- * Locals
- */
-
-var dcfConfigLoaded = false;
-var deferredArgs = null;
-var defaultSeq = {file: 1, dir: 100};
-var dcfConfig = {
- key: 'dcf_key',
- seq: null,
- postFix: 'MZLLA',
- prefix: {video: 'VID_', image: 'IMG_'},
- ext: {video: window.VulpesVideoExtension || '3gp', image: 'jpg'}
-};
-
-exports.init = function() {
- debug('initializing');
- exports.checkFileCounter(function() {
- // We have a previous call to createDCFFilename that is waiting for
- // a response, fire it again
- if (deferredArgs) {
- var args = deferredArgs;
- exports.createDCFFilename(args.storage, args.type, args.callback);
- deferredArgs = null;
- }
-
- debug('initialized');
- });
-};
-
-exports.checkFileCounter = function(callback) {
- asyncStorage.getItem(dcfConfig.key, function(value) {
- dcfConfigLoaded = true;
- dcfConfig.seq = value ? value : defaultSeq;
-
- if (callback) {
- callback();
- }
- });
-};
-
-exports.createDCFFilename = function(storage, type, callback) {
-
- // We havent loaded the current counters from indexedDB yet, defer
- // the call
- if (!dcfConfigLoaded) {
- deferredArgs = {storage: storage, type: type, callback: callback};
- return;
- }
-
- var dir = 'DCIM/' + dcfConfig.seq.dir + dcfConfig.postFix + '/';
- var filename = dcfConfig.prefix[type] +
- format.padLeft(dcfConfig.seq.file, 4, '0') + '.' +
- dcfConfig.ext[type];
- var filepath = dir + filename;
-
- // A file with this name may have been written by the user or
- // our indexeddb sequence tracker was cleared, check we wont overwrite
- // anything
- var req = storage.get(filepath);
-
- // A file existed, we bump the directory then try to generate a
- // new filename
- req.onsuccess = function() {
- dcfConfig.seq.file = 1;
- dcfConfig.seq.dir += 1;
- asyncStorage.setItem(dcfConfig.key, dcfConfig.seq, function() {
- exports.createDCFFilename(storage, type, callback);
- });
- };
-
- // No file existed, we are good to go
- req.onerror = function() {
- if (dcfConfig.seq.file < 9999) {
- dcfConfig.seq.file += 1;
- } else {
- dcfConfig.seq.file = 1;
- dcfConfig.seq.dir += 1;
- }
- asyncStorage.setItem(dcfConfig.key, dcfConfig.seq, function() {
- callback(filepath, filename, dir);
- });
- };
-};
-
-});
diff --git a/overrides/camera.gaiamobile.org/js/main.js b/overrides/camera.gaiamobile.org/js/main.js
index 376b3c17..e8443fe6 100644
--- a/overrides/camera.gaiamobile.org/js/main.js
+++ b/overrides/camera.gaiamobile.org/js/main.js
@@ -4265,7 +4265,7 @@ define('controllers/overlay', [
if (id !== overlay.id) {
return;
}
- var closable = self.activity.pick || reason === 'request-fail';
+ var closable = self.activity.pick && type !== 'request-fail';
var view = new OverlayView({
type: reason,
closable: closable
@@ -4274,10 +4274,6 @@ define('controllers/overlay', [
return;
}
overlay.view = view.appendTo(document.body).on('click:close-btn', function () {
- if (reason === 'request-fail' && !self.activity.pick) {
- self.updateOverlay(type, false);
- return;
- }
self.app.emit('activitycanceled');
});
debug('inserted \'%s/%s\' overlay', type, reason);
@@ -5456,9 +5452,6 @@ define('views/viewfinder', [
if (!preview) {
return;
}
- // Recompute when the embedded app becomes visible or rotates.
- this.getSize();
- if (!this.width || !this.height) { return; }
var aspect;
if (sensorAngle % 180 === 0) {
this.container = {
@@ -5709,7 +5702,6 @@ define('controllers/viewfinder', [
this.views.viewfinder.set('grid', 'off');
};
ViewfinderController.prototype.bindEvents = function () {
- window.addEventListener('resize', this.configurePreview);
this.views.viewfinder.on('fadedin', this.app.firer('viewfinder:visible'));
this.views.viewfinder.on('fadedout', this.app.firer('viewfinder:hidden'));
this.views.viewfinder.on('click', this.app.firer('viewfinder:click'));
@@ -6288,313 +6280,167 @@ define('lib/bytes-to-pixels', [
return Math.round(uncompressedBytes / bytesPerPixel);
};
});
-define("controllers/activity",["require", "exports", "module", "debug", "lib/bytes-to-pixels", "lib/bind-all"],function(require, exports, module) {
-'use strict';
-
-/**
- * Dependencies
- */
-
-var debug = require('debug')('controller:activity');
-var bytesToPixels = require('lib/bytes-to-pixels');
-var bindAll = require('lib/bind-all');
-
-/**
- * Exports
- */
-
-module.exports = function(app) { return new ActivityController(app); };
-module.exports.ActivityController = ActivityController;
-
-/**
- * Initialize new `ActivityController`
- *
- * @param {App} app
- */
-function ActivityController(app) {
- bindAll(this);
- this.app = app;
- this.win = app.win;
- this.settings = app.settings;
- this.configure();
- this.bindEvents();
- debug('initialized');
-}
-
-/**
- * Supported activity types.
- *
- * @type {Object}
- */
-ActivityController.prototype.types = {
- pick: 'pick',
- record: 'record'
-};
-
-/**
- * Initial configuration.
- *
- * @private
- */
-ActivityController.prototype.configure = function() {
- this.name = this.getName();
- this.app.activity[this.name] = true;
-};
-
-/**
- * Filter down pictureSizes and
- * recorderProfiles to match activity
- * parameters each time the settings
- * are configured.
- *
- * @private
- */
-ActivityController.prototype.bindEvents = function() {
- this.app.on('activitycanceled', this.onActivityCanceled);
- this.app.on('confirm:selected', this.onActivityConfirmed);
-
- // If an activity name was found, we must bind
- // the listener straight away so we don't miss
- // the event, otherwise we can bind more lazily.
- if (this.name) { this.setupListener(); }
- else { this.app.once('criticalpathdone', this.setupListener); }
-};
-
-ActivityController.prototype.setupListener = function() {
- debug('setup listener');
- navigator.mozSetMessageHandler('activity', this.onMessage);
- debug('listener setup');
-};
-
-/**
- * Get activity name from the hash fragment.
- *
- * This is used only so that some parts
- * of the app can prepare for an incoming
- * activity before the message arrives.
- *
- * @private
- */
-ActivityController.prototype.getName = function() {
- var hash = this.win.location.hash;
- var name = hash && hash.substr(1);
- return this.types[name];
-};
-
-/**
- * Responds to incoming activity message.
- *
- * Configures the mode then emits an
- * event so that other parts of
- * the app can update accordingly.
- *
- * @param {MozActivity} activity
- */
-ActivityController.prototype.onMessage = function(activity) {
- debug('incoming activity', activity);
- var name = activity.source.name;
- var supported = this.types[name];
-
- // Exit if this activity isn't supported
- if (!supported) { return; }
-
- var data = {
- name: name,
- maxPixelSize: this.getMaxPixelSize(activity),
- maxFileSizeBytes: activity.source.data.maxFileSizeBytes
- };
-
- this.activity = activity;
- this.configureMode(activity);
- this.app.emit('activity', data);
- this.app.emit('activity:' + name, data);
-};
-
-/**
- * Configures the mode setting based
- * on the parameters supplied by
- * the incoming activity.
- *
- * @param {MozActivity} activity
- * @private
- */
-ActivityController.prototype.configureMode = function(activity) {
- var type = activity.source.data.type;
- var name = activity.source.name;
- var modes = (name === 'pick') ?
- this.getModesForPick(type) :
- this.getModesForRecord(type);
-
- this.settings.mode.filterOptions(modes);
- this.settings.mode.select(modes[0]);
- debug('configured mode', modes);
-};
-
-/**
- * Get a max pixel size estimate based
- * on the parameters supplied by the
- * incoming activity.
- *
- * Activities don't always supply pixel
- * size restrictions.
- *
- * NOTE: There
- *
- * @param {MozActivity} activity
- * @return {Number|null}
- */
-ActivityController.prototype.getMaxPixelSize = function(activity) {
- var data = activity.source.data;
- var bytes = data.maxFileSizeBytes;
- var maxPickPixelSize = this.settings.activity.get('maxPickPixelSize') || 0;
- var maxPixelSize;
-
- // If bytes were specified then derive
- // a maxPixelSize from that, else we
- // calculate the maxPixelSize using
- // supplied dimensions.
- if (bytes) {
- maxPixelSize = bytesToPixels(bytes);
- } else if (data.width || data.height) {
- maxPixelSize = this.getMaxPixelsFromSize(data);
- } else {
- maxPixelSize = maxPickPixelSize;
- }
-
- // If the Camera app has been configured to have a max pixel size
- // for pick activities, ensure we are at or below that value.
- if (maxPickPixelSize > 0) {
- maxPixelSize = Math.min(maxPixelSize, maxPickPixelSize);
- }
-
- debug('maxPixelsSize: %s', maxPixelSize);
- return maxPixelSize;
-};
-
-ActivityController.prototype.getMaxPixelsFromSize = function(size) {
- var scale = this.settings.activity.get('maxPixelSizeScaleFactor');
- var aspect = 4 / 3;
-
- // In the event that only one dimension has
- // been supplied, calculate the largest the
- // other edge could be based on a 4:3 aspect.
- var width = size.width || size.height * aspect;
- var height = size.height || size.width * aspect;
- var pixels = width * height;
-
- // Take the actual total number of
- // pixels asked for and bump it by the
- // `scale` to cover pictureSizes above
- // the pixels asked for. We later resize
- // the image post-capture to the exact size
- // requested (data.width * data.height).
- //
- // This is to avoid us picking a pictureSize
- // smaller than the number of pixels requested
- // and then to scaling up post-capture,
- // resulting in a shitty image.
- return pixels * scale;
-};
-
-/**
- * Parse types given by 'pick' activity
- * and return a list of modes.
- *
- * @param {Array|String} types
- * @return {Array}
- */
-ActivityController.prototype.getModesForPick = function(types) {
- types = [].concat(types || []);
- var modes = [];
-
- types.forEach(function(item) {
- var type = item.split('/')[0];
- var mode = type === 'image' ? 'picture' : type;
-
- if (modes.indexOf(mode) === -1) {
- modes.push(mode);
+define('controllers/activity', [
+ 'require',
+ 'exports',
+ 'module',
+ 'debug',
+ 'lib/bytes-to-pixels',
+ 'lib/bind-all'
+], function (require, exports, module) {
+ 'use strict';
+ var debug = require('debug')('controller:activity');
+ var bytesToPixels = require('lib/bytes-to-pixels');
+ var bindAll = require('lib/bind-all');
+ module.exports = function (app) {
+ return new ActivityController(app);
+ };
+ module.exports.ActivityController = ActivityController;
+ function ActivityController(app) {
+ bindAll(this);
+ this.app = app;
+ this.win = app.win;
+ this.settings = app.settings;
+ this.configure();
+ this.bindEvents();
+ debug('initialized');
}
- });
-
- if (modes.length === 0) {
- modes = ['picture', 'video'];
- }
-
- return modes;
-};
-
-/**
- * Parse types given by 'record' activity
- * and return a list of modes.
- *
- * @param {String} type
- * @return {Array}
- */
-ActivityController.prototype.getModesForRecord = function(type) {
- return type === 'videos' ?
- ['video', 'picture'] :
- ['picture', 'video'];
-};
-
-/**
- * Respond to activity cancelation
- * events and send the error call
- * via the original acitity object.
- *
- * @private
- */
-ActivityController.prototype.onActivityCanceled = function() {
- if (!this.activity) { return; }
- this.activity.postError('pick cancelled');
-};
-
-// TODO: Messy, tidy up!
-ActivityController.prototype.onActivityConfirmed = function(newMedia) {
- var self = this;
- var activity = this.activity;
- var media = {
- blob: newMedia.blob
- };
-
- // In low end devices resizing can be slow.
- // We display a spinner
- this.app.showSpinner();
-
- // Video
- if (newMedia.isVideo) {
- media.type = media.blob.type || 'video/3gpp';
- media.poster = newMedia.poster.blob;
- activity.postResult(media);
- this.app.clearSpinner();
- }
-
- // Image
- else {
- media.type = 'image/jpeg';
-
- // We need to call resizeImageAndSave() unconditionally even if
- // the image does not need to be resized because if the camera
- // produces images with EXIF orientation, we need to rotate the
- // image before returning it from the pick activity. If the image
- // does not need to be resized or rotated, then resizeImageAndSave
- // will return it unchanged, and will not re-save it.
- require(['lib/resize-image-and-save'], function(resizeImageAndSave) {
- resizeImageAndSave(
- {
- blob: newMedia.blob,
- width: activity.source.data.width,
- height: activity.source.data.height
- },
- function onImageResized(resizedBlob) {
- media.blob = resizedBlob;
- activity.postResult(media);
- self.app.clearSpinner();
+ ActivityController.prototype.types = {
+ pick: 'pick',
+ record: 'record'
+ };
+ ActivityController.prototype.configure = function () {
+ this.name = this.getName();
+ this.app.activity[this.name] = true;
+ };
+ ActivityController.prototype.bindEvents = function () {
+ this.app.on('activitycanceled', this.onActivityCanceled);
+ this.app.on('confirm:selected', this.onActivityConfirmed);
+ if (this.name) {
+ this.setupListener();
+ } else {
+ this.app.once('criticalpathdone', this.setupListener);
}
- );
- });
- }
-};
-
+ };
+ ActivityController.prototype.setupListener = function () {
+ debug('setup listener');
+ navigator.mozSetMessageHandler('activity', this.onMessage);
+ debug('listener setup');
+ };
+ ActivityController.prototype.getName = function () {
+ var hash = this.win.location.hash;
+ var name = hash && hash.substr(1);
+ return this.types[name];
+ };
+ ActivityController.prototype.onMessage = function (activity) {
+ debug('incoming activity', activity);
+ var name = activity.source.name;
+ var supported = this.types[name];
+ if (!supported) {
+ return;
+ }
+ var data = {
+ name: name,
+ maxPixelSize: this.getMaxPixelSize(activity),
+ maxFileSizeBytes: activity.source.data.maxFileSizeBytes
+ };
+ this.activity = activity;
+ this.configureMode(activity);
+ this.app.emit('activity', data);
+ this.app.emit('activity:' + name, data);
+ };
+ ActivityController.prototype.configureMode = function (activity) {
+ var type = activity.source.data.type;
+ var name = activity.source.name;
+ var modes = name === 'pick' ? this.getModesForPick(type) : this.getModesForRecord(type);
+ this.settings.mode.filterOptions(modes);
+ this.settings.mode.select(modes[0]);
+ debug('configured mode', modes);
+ };
+ ActivityController.prototype.getMaxPixelSize = function (activity) {
+ var data = activity.source.data;
+ var bytes = data.maxFileSizeBytes;
+ var maxPickPixelSize = this.settings.activity.get('maxPickPixelSize') || 0;
+ var maxPixelSize;
+ if (bytes) {
+ maxPixelSize = bytesToPixels(bytes);
+ } else if (data.width || data.height) {
+ maxPixelSize = this.getMaxPixelsFromSize(data);
+ } else {
+ maxPixelSize = maxPickPixelSize;
+ }
+ if (maxPickPixelSize > 0) {
+ maxPixelSize = Math.min(maxPixelSize, maxPickPixelSize);
+ }
+ debug('maxPixelsSize: %s', maxPixelSize);
+ return maxPixelSize;
+ };
+ ActivityController.prototype.getMaxPixelsFromSize = function (size) {
+ var scale = this.settings.activity.get('maxPixelSizeScaleFactor');
+ var aspect = 4 / 3;
+ var width = size.width || size.height * aspect;
+ var height = size.height || size.width * aspect;
+ var pixels = width * height;
+ return pixels * scale;
+ };
+ ActivityController.prototype.getModesForPick = function (types) {
+ types = [].concat(types || []);
+ var modes = [];
+ types.forEach(function (item) {
+ var type = item.split('/')[0];
+ var mode = type === 'image' ? 'picture' : type;
+ if (modes.indexOf(mode) === -1) {
+ modes.push(mode);
+ }
+ });
+ if (modes.length === 0) {
+ modes = [
+ 'picture',
+ 'video'
+ ];
+ }
+ return modes;
+ };
+ ActivityController.prototype.getModesForRecord = function (type) {
+ return type === 'videos' ? [
+ 'video',
+ 'picture'
+ ] : [
+ 'picture',
+ 'video'
+ ];
+ };
+ ActivityController.prototype.onActivityCanceled = function () {
+ if (!this.activity) {
+ return;
+ }
+ this.activity.postError('pick cancelled');
+ };
+ ActivityController.prototype.onActivityConfirmed = function (newMedia) {
+ var self = this;
+ var activity = this.activity;
+ var media = { blob: newMedia.blob };
+ this.app.showSpinner();
+ if (newMedia.isVideo) {
+ media.type = 'video/3gpp';
+ media.poster = newMedia.poster.blob;
+ activity.postResult(media);
+ this.app.clearSpinner();
+ } else {
+ media.type = 'image/jpeg';
+ require(['lib/resize-image-and-save'], function (resizeImageAndSave) {
+ resizeImageAndSave({
+ blob: newMedia.blob,
+ width: activity.source.data.width,
+ height: activity.source.data.height
+ }, function onImageResized(resizedBlob) {
+ media.blob = resizedBlob;
+ activity.postResult(media);
+ self.app.clearSpinner();
+ });
+ });
+ }
+ };
});
define('controllers/camera', [
'require',
@@ -6946,606 +6792,354 @@ define('controllers/camera', [
}
};
});
-define("lib/storage",["require", "exports", "module", "debug", "lib/bind-all", "evt"],function(require, exports, module) {
-'use strict';
-
-/**
- * Dependencies
- */
-
-var debug = require('debug')('storage');
-var bindAll = require('lib/bind-all');
-var events = require('evt');
-var storageSingleton;
-
-/**
- * Expose `Storage`
- */
-
-module.exports = Storage;
-
-/**
- * Mixin event emitter
- */
-
-events(Storage.prototype);
-
-/**
- * Initialize a new `Storage`.
- *
- * @param {Object} options
- */
-function Storage(options) {
- if (storageSingleton) {
- return storageSingleton;
- }
- storageSingleton = this;
- bindAll(this);
- this.maxFileSize = 0;
- options = options || {};
- this.dcf = options.dcf;
- if (this.dcf) {
- this.dcf.init();
- }
- this.require = options.require || require;
- navigator.mozSettings.addObserver(
- 'device.storage.writable.name',
- this.onStorageVolumeChanged);
- this.configure();
- debug('initialized');
-}
-
-Storage.prototype.createFilename = function(storage, type, done) {
- var self = this;
- this.require(['asyncStorage', 'lib/dcf'], function(as, dcf) {
- if (!self.dcf) {
- self.dcf = dcf;
- self.dcf.init();
+define('lib/storage', [
+ 'require',
+ 'exports',
+ 'module',
+ 'debug',
+ 'lib/bind-all',
+ 'evt'
+], function (require, exports, module) {
+ 'use strict';
+ var debug = require('debug')('storage');
+ var bindAll = require('lib/bind-all');
+ var events = require('evt');
+ var storageSingleton;
+ module.exports = Storage;
+ events(Storage.prototype);
+ function Storage(options) {
+ if (storageSingleton) {
+ return storageSingleton;
+ }
+ storageSingleton = this;
+ bindAll(this);
+ this.maxFileSize = 0;
+ options = options || {};
+ this.dcf = options.dcf;
+ if (this.dcf) {
+ this.dcf.init();
+ }
+ this.require = options.require || require;
+ navigator.mozSettings.addObserver('device.storage.writable.name', this.onStorageVolumeChanged);
+ this.configure();
+ debug('initialized');
}
-
- self.dcf.createDCFFilename(storage, type, done);
- });
-};
-
-/**
- * Save the image Blob to DeviceStorage
- * then lookup the File reference and
- * return that in the callback as well
- * as the resulting paths.
- *
- * You always want to forget about the
- * Blob you told us about and use the
- * File instead since otherwise you
- * are wasting precious memory.
- *
- * @param {Object} [options]
- * @param {String} options.filepath
- * The path to save the image to.
- */
-Storage.prototype.addPicture = function(blob, options, done) {
- if (typeof options === 'function') {
- done = options;
- options = {};
- }
-
- done = done || function() {};
- var filepath = options && options.filepath;
- var self = this;
- debug('add picture', filepath);
-
- // Create a filepath if
- // one hasn't been given.
- if (!filepath) {
- debug('creating filename');
- this.createFilename(this.picture, 'image', onCreated);
- } else {
- onCreated(filepath);
- }
-
- function onCreated(filepath) {
- var req = self.picture.addNamed(blob, filepath);
- req.onerror = function() { self.emit('error'); };
- req.onsuccess = function(e) {
- debug('image stored', filepath);
- var absolutePath = e.target.result;
-
- // `addNamed` does not give us a File
- // handle so we need to get() it again.
- refetchFile(filepath, absolutePath);
+ Storage.prototype.createFilename = function (storage, type, done) {
+ var self = this;
+ this.require([
+ 'asyncStorage',
+ 'lib/dcf'
+ ], function (as, dcf) {
+ if (!self.dcf) {
+ self.dcf = dcf;
+ self.dcf.init();
+ }
+ self.dcf.createDCFFilename(storage, type, done);
+ });
};
- }
-
- function refetchFile(filepath, absolutePath) {
- var req = self.picture.get(filepath);
- req.onerror = function() {
- self.emit('error');
- done('Error adding picture to storage');
+ Storage.prototype.addPicture = function (blob, options, done) {
+ if (typeof options === 'function') {
+ done = options;
+ options = {};
+ }
+ done = done || function () {
+ };
+ var filepath = options && options.filepath;
+ var self = this;
+ debug('add picture', filepath);
+ if (!filepath) {
+ debug('creating filename');
+ this.createFilename(this.picture, 'image', onCreated);
+ } else {
+ onCreated(filepath);
+ }
+ function onCreated(filepath) {
+ var req = self.picture.addNamed(blob, filepath);
+ req.onerror = function () {
+ self.emit('error');
+ };
+ req.onsuccess = function (e) {
+ debug('image stored', filepath);
+ var absolutePath = e.target.result;
+ refetchFile(filepath, absolutePath);
+ };
+ }
+ function refetchFile(filepath, absolutePath) {
+ var req = self.picture.get(filepath);
+ req.onerror = function () {
+ self.emit('error');
+ done('Error adding picture to storage');
+ };
+ req.onsuccess = function (e) {
+ debug('image file blob handle retrieved');
+ var fileBlob = e.target.result;
+ done(null, filepath, absolutePath, fileBlob);
+ };
+ }
};
- req.onsuccess = function(e) {
- debug('image file blob handle retrieved');
- var fileBlob = e.target.result;
- done(null, filepath, absolutePath, fileBlob);
+ Storage.prototype.createVideoFilepath = function (done) {
+ var videoStorage = this.video;
+ var self = this;
+ this.createFilename(this.video, 'video', function (filepath) {
+ var dummyFilepath = getDir(filepath) + '.tmp.3gp';
+ var blob = new Blob([''], { type: 'video/3gpp' });
+ var req = videoStorage.addNamed(blob, dummyFilepath);
+ req.onerror = function (e) {
+ debug('Failed to add ' + filepath + ' to DeviceStorage', e);
+ var req = videoStorage.delete(dummyFilepath);
+ req.onerror = function () {
+ done('Error creating video file path');
+ };
+ req.onsuccess = function () {
+ self.createVideoFilepath(done);
+ };
+ };
+ req.onsuccess = function (e) {
+ videoStorage.delete(e.target.result);
+ done(null, filepath);
+ };
+ });
};
- }
-};
-
-/**
- * Create a new video filepath.
- *
- * The CameraControl API will not
- * automatically create directories
- * for the new file if they do not
- * exist.
- *
- * So we write a dummy file to the
- * same directory via DeviceStorage
- * to ensure that the directory exists
- * before attempting to record to this
- * filepath.
- *
- * If the request errors it can mean that
- * there is a file that already exists
- * in that name. If so, we attempt to delete
- * it and try to create the filepath again.
- *
- * @param {Function} done
- * @public
- */
-Storage.prototype.createVideoFilepath = function(done) {
- var videoStorage = this.video;
- var self = this;
-
- this.createFilename(this.video, 'video', function(filepath) {
- var dummyFilepath = getDir(filepath) + '.tmp.3gp';
- var blob = new Blob([''], { type: 'video/3gpp' });
- var req = videoStorage.addNamed(blob, dummyFilepath);
-
- req.onerror = function(e) {
- debug('Failed to add ' + filepath + ' to DeviceStorage', e);
- var req = videoStorage.delete(dummyFilepath);
- req.onerror = function() { done('Error creating video file path'); };
- req.onsuccess = function() { self.createVideoFilepath(done); };
+ Storage.prototype.onStorageChange = function (e) {
+ debug('state change: %s', e.reason);
+ var value = e.reason;
+ switch (value) {
+ case 'deleted':
+ var filepath = this.checkFilepath(e.path);
+ this.emit('itemdeleted', { path: filepath });
+ break;
+ case 'available':
+ case 'shared':
+ case 'unavailable':
+ this.setState(value);
+ break;
+ }
+ this.check();
};
-
- req.onsuccess = function(e) {
- videoStorage.delete(e.target.result);
- done(null, filepath);
+ Storage.prototype.configure = function (storageVolumeName) {
+ var i;
+ var videosStorages;
+ var picturesStorages;
+ if (this.picture) {
+ this.picture.removeEventListener('change', this.onStorageChange);
+ }
+ if (!storageVolumeName) {
+ this.video = navigator.getDeviceStorage('videos');
+ this.picture = navigator.getDeviceStorage('pictures');
+ } else {
+ videosStorages = navigator.getDeviceStorages('videos');
+ this.video = videosStorages[0];
+ for (i = 0; i < videosStorages.length; ++i) {
+ if (videosStorages[i].storageName === storageVolumeName) {
+ this.video = videosStorages[i];
+ break;
+ }
+ }
+ picturesStorages = navigator.getDeviceStorages('pictures');
+ this.picture = picturesStorages[0];
+ for (i = 0; i < picturesStorages.length; ++i) {
+ if (picturesStorages[i].storageName === storageVolumeName) {
+ this.picture = picturesStorages[i];
+ break;
+ }
+ }
+ }
+ if (!this.picture) {
+ this.setState('unavailable');
+ return;
+ }
+ this.picture.addEventListener('change', this.onStorageChange);
+ this.emit('volumechanged', {
+ video: this.video,
+ picture: this.picture
+ });
};
- });
-};
-
-Storage.prototype.onStorageChange = function(e) {
- debug('state change: %s', e.reason);
- var value = e.reason;
-
- switch (value) {
- case 'deleted':
- // Emit an `itemdeleted` event to
- // allow parts of the UI to update.
- var filepath = this.checkFilepath(e.path);
- this.emit('itemdeleted', { path: filepath });
- break;
- case 'available':
- case 'shared':
- case 'unavailable':
- this.setState(value);
- break;
- }
-
- // Check storage
- // has spare capacity
- this.check();
-};
-
-Storage.prototype.configure = function(storageVolumeName) {
- var i;
- var videosStorages;
- var picturesStorages;
- // If we had a previous ds for pictures, let's remove the observer
- // we had set as well before fetching new ds.
- if (this.picture) {
- this.picture.removeEventListener('change', this.onStorageChange);
- }
- if (!storageVolumeName) {
- this.video = navigator.getDeviceStorage('videos');
- this.picture = navigator.getDeviceStorage('pictures');
- } else { // We select the volumes with the passed name
- videosStorages = navigator.getDeviceStorages('videos');
- this.video = videosStorages[0];
- for (i = 0; i < videosStorages.length; ++i) {
- if (videosStorages[i].storageName === storageVolumeName) {
- this.video = videosStorages[i];
- break;
- }
+ Storage.prototype.onStorageVolumeChanged = function (setting) {
+ debug('default storage volume change: %s', setting.settingValue);
+ this.configure(setting.settingValue);
+ };
+ Storage.prototype.checkFilepath = function (filepath) {
+ var startString = filepath.indexOf('DCIM/');
+ if (startString < -1) {
+ return;
+ } else if (startString > 0) {
+ filepath = filepath.substr(startString);
+ }
+ if (filepath.indexOf('VID') != -1 && filepath.lastIndexOf('.jpg') === filepath.length - 4) {
+ filepath = filepath.replace('.jpg', '.3gp');
+ }
+ return filepath;
+ };
+ Storage.prototype.setState = function (value) {
+ this.state = value;
+ debug('set state: %s', value);
+ this.emit('changed', value);
+ };
+ Storage.prototype.setMaxFileSize = function (maxFileSize) {
+ this.maxFileSize = maxFileSize;
+ this.check();
+ debug('max file size set: %d', maxFileSize);
+ };
+ Storage.prototype.check = function (done) {
+ debug('check');
+ var self = this;
+ done = done || function () {
+ };
+ this.getState(function (result) {
+ self.setState(result);
+ if (!self.available()) {
+ onComplete('unhealthy');
+ return;
+ }
+ if (self.dcf) {
+ self.dcf.checkFileCounter();
+ }
+ self.isSpace(function (result) {
+ if (!result) {
+ self.setState('nospace');
+ }
+ onComplete('healthy');
+ });
+ });
+ function onComplete(state) {
+ self.emit('checked', state);
+ }
+ };
+ Storage.prototype.isSpace = function (done) {
+ var maxFileSize = this.maxFileSize;
+ this.picture.freeSpace().onsuccess = function (e) {
+ var freeSpace = e.target.result;
+ var result = freeSpace > maxFileSize;
+ debug('is space: %s', result, freeSpace, maxFileSize);
+ done(result);
+ };
+ };
+ Storage.prototype.getState = function (done) {
+ if (!this.picture) {
+ setTimeout(function () {
+ done('unavailable');
+ });
+ return;
+ }
+ this.picture.available().onsuccess = function (e) {
+ done(e.target.result);
+ };
+ };
+ Storage.prototype.available = function () {
+ return this.state === 'available';
+ };
+ Storage.prototype.deletePicture = function (filepath, done) {
+ var req = this.picture.delete(filepath);
+ req.onerror = function (e) {
+ var message = 'Failed to delete ' + filepath + ' from DeviceStorage:' + e.target.error;
+ console.warn(message);
+ done(message);
+ };
+ if (done) {
+ req.onsuccess = function () {
+ done(null);
+ };
+ }
+ };
+ Storage.prototype.deleteVideo = function (filepath) {
+ var poster = filepath.replace('.3gp', '.jpg');
+ this.video.delete(filepath).onerror = function (e) {
+ console.warn('Failed to delete', filepath, 'from DeviceStorage:', e.target.error);
+ };
+ this.picture.delete(poster).onerror = function (e) {
+ console.warn('Failed to delete poster image', poster, 'for video', filepath, 'from DeviceStorage:', e.target.error);
+ };
+ };
+ function getDir(filepath) {
+ var index = filepath.lastIndexOf('/') + 1;
+ return index ? filepath.substring(0, index) : '';
}
-
- picturesStorages = navigator.getDeviceStorages('pictures');
- this.picture = picturesStorages[0];
- for (i = 0; i < picturesStorages.length; ++i) {
- if (picturesStorages[i].storageName === storageVolumeName) {
- this.picture = picturesStorages[i];
- break;
- }
- }
- }
-
- // Shouldn't happen?
- if (!this.picture) {
- this.setState('unavailable');
- return;
- }
-
- this.picture.addEventListener('change', this.onStorageChange);
- this.emit('volumechanged',{
- video: this.video,
- picture: this.picture
- });
-};
-
-Storage.prototype.onStorageVolumeChanged = function(setting) {
- debug('default storage volume change: %s', setting.settingValue);
- this.configure(setting.settingValue);
-};
-
-Storage.prototype.checkFilepath = function(filepath) {
- var startString = filepath.indexOf('DCIM/');
-
- if (startString < -1) { return; }
- else if (startString > 0) {
- filepath = filepath.substr(startString);
- }
-
- // Check whether filepath is a video poster image or not. If filepath
- // contains 'VID' and ends with '.jpg', consider it a video poster
- // image and get the video filepath by changing '.jpg' to '.3gp'
- if (filepath.indexOf('VID') != -1 &&
- filepath.lastIndexOf('.jpg') === filepath.length - 4) {
- filepath = filepath.replace('.jpg', '.' + (window.VulpesVideoExtension || '3gp'));
- }
-
- return filepath;
-};
-
-Storage.prototype.setState = function(value) {
- this.state = value;
- debug('set state: %s', value);
- this.emit('changed', value);
-};
-
-Storage.prototype.setMaxFileSize = function(maxFileSize) {
- this.maxFileSize = maxFileSize;
- this.check();
- debug('max file size set: %d', maxFileSize);
-};
-
-/**
- * Run a full storage check.
- *
- * @param {Function} done
- * @public
- */
-Storage.prototype.check = function(done) {
- debug('check');
-
- var self = this;
- done = done || function() {};
-
- this.getState(function(result) {
- self.setState(result);
-
- if (!self.available()) {
- onComplete('unhealthy');
- return;
- }
-
- // check file counter in dcf if it already exist.
- if (self.dcf) {
- self.dcf.checkFileCounter();
- }
- self.isSpace(function(result) {
- if (!result) { self.setState('nospace'); }
- onComplete('healthy');
- });
- });
-
- function onComplete(state) {
- self.emit('checked', state);
- }
-};
-
-/**
- * Checks if there is enough space to
- * accomdate the current `maxFileSize`.
- *
- * @param {Function} done
- */
-Storage.prototype.isSpace = function(done) {
- var maxFileSize = this.maxFileSize;
- this.picture
- .freeSpace()
- .onsuccess = function(e) {
- var freeSpace = e.target.result;
- var result = freeSpace > maxFileSize;
- debug('is space: %s', result, freeSpace, maxFileSize);
- done(result);
- };
-};
-
-/**
- * Get current storage state.
- *
- * @param {Function} done
- */
-Storage.prototype.getState = function(done) {
- if (!this.picture) {
- setTimeout(function() {
- done('unavailable');
- });
- return;
- }
-
- this.picture
- .available()
- .onsuccess = function(e) {
- done(e.target.result);
- };
-};
-
-/**
- * States if sotrage is available.
- *
- * @return {Boolean}
- */
-Storage.prototype.available = function() {
- return this.state === 'available';
-};
-
-/**
- * Delete a picture.
- *
- * @param {String} filepath
- */
-Storage.prototype.deletePicture = function(filepath, done) {
- var req = this.picture.delete(filepath);
- req.onerror = function(e) {
- var message = 'Failed to delete ' + filepath +
- ' from DeviceStorage:' + e.target.error;
- console.warn(message);
- done(message);
- };
-
- if (done) {
- req.onsuccess = function() {
- done(null);
- };
- }
-};
-
-/**
- * Delete a video and accompanying
- * poster image.
- *
- * @param {String} filepath
- */
-Storage.prototype.deleteVideo = function(filepath) {
- var poster = filepath.replace(/\.(?:3gp|mp4|webm)$/, '.jpg');
-
- this.video.delete(filepath).onerror = function(e) {
- console.warn(
- 'Failed to delete', filepath,
- 'from DeviceStorage:', e.target.error);
- };
-
- this.picture.delete(poster).onerror = function(e) {
- console.warn(
- 'Failed to delete poster image', poster,
- 'for video', filepath,
- 'from DeviceStorage:', e.target.error);
- };
-};
-
-/**
- * Get the directory from a filepath.
- *
- * @param {String} filepath
- * @return {String}
- */
-function getDir(filepath) {
- var index = filepath.lastIndexOf('/') + 1;
- return index ? filepath.substring(0, index) : '';
-}
-
});
-define("controllers/storage",["require", "exports", "module", "debug", "lib/bind-all", "lib/storage"],function(require, exports, module) {
-'use strict';
-
-/**
- * Dependencies
- */
-
-var debug = require('debug')('controller:storage');
-var bindAll = require('lib/bind-all');
-var Storage = require('lib/storage');
-
-/**
- * Exports
- */
-
-module.exports = function(app) { return new StorageController(app); };
-module.exports.StorageController = StorageController;
-
-/**
- * Initialize a new `StorageController`
- *
- * @param {App} app
- */
-function StorageController(app) {
- bindAll(this);
- this.app = app;
- this.camera = app.camera;
- this.settings = app.settings;
- this.storage = app.storage || new Storage();
- this.bindEvents();
- this.configure();
- debug('initialized');
-}
-
-/**
- * Initial configuration.
- *
- * Give the camera a way to create video filepaths.
- * This is so that the camera can record videos
- * directly to the final location without us
- * having to move the video file from temporary,
- * to final location at recording end.
- *
- * @private
- */
-StorageController.prototype.configure = function() {
- this.storage.configure();
- this.camera.createVideoFilepath = this.storage.createVideoFilepath;
- this.updateMaxFileSize();
-};
-
-/**
- * Bind to relevant events.
- *
- * @private
- */
-StorageController.prototype.bindEvents = function() {
- debug('bind events');
-
- // App
- this.settings.pictureSizes.on('change:selected', this.updateMaxFileSize);
- this.app.on('previewgallery:deletepicture', this.storage.deletePicture);
- this.app.on('previewgallery:deletevideo', this.storage.deleteVideo);
- this.app.on('settings:configured', this.updateMaxFileSize);
- this.app.on('camera:newimage', this.storePicture);
- this.app.on('camera:newvideo', this.storeVideo);
- this.app.on('visible', this.storage.check);
-
- // Storage
- this.storage.on('volumechanged', this.app.firer('storage:volumechanged'));
- this.storage.on('itemdeleted', this.app.firer('storage:itemdeleted'));
- this.storage.on('changed', this.onChanged);
- this.storage.on('checked', this.onChecked);
- debug('events bound');
-};
-
-/**
- * Relay storage state change events.
- *
- * @param {String} state
- * @private
- */
-StorageController.prototype.onChanged = function(state) {
- debug('changed: %s', state);
- this.app.emit('storage:changed', state);
-};
-
-/**
- * Emit the outcome of a storage check
- * so that other parts of the app
- * can respond.
- *
- * @param {String} value
- * @private
- */
-StorageController.prototype.onChecked = function(value) {
- debug('checked: %s', value);
- this.app.emit('storage:checked', value);
- this.app.emit('storage:checked:' + value);
-};
-
-/**
- * Store a picture.
- *
- * In either case, save the memory-backed
- * photo blob to device storage, retrieve
- * the resulting File (blob) and pass that
- * around instead of the original memory blob.
- *
- * This is critical for "pick" activity consumers
- * where the memory-backed Blob is either highly
- * inefficent or will almost-immediately become
- * inaccesible, depending on the state of the
- * platform. https://bugzil.la/982779
- *
- * @param {Object} picture
- * @private
- */
-StorageController.prototype.storePicture = function(picture) {
- var memoryBlob = picture.blob;
- var self = this;
-
- this.storage.addPicture(
- memoryBlob,
- function(error, filepath, abspath, fileBlob) {
- picture.blob = fileBlob;
- picture.filepath = filepath;
- debug('stored picture', picture);
- self.app.emit('newmedia', picture);
- });
-};
-
-/**
- * Store a video.
- *
- * We don't store the video blob like
- * we do for images, as it is recorded
- * directly to the final location.
- * This is for memory reason. The poster
- * is also automatically created.
- *
- * @param {Object} video
- */
-StorageController.prototype.storeVideo = function(video) {
- debug('new video', video);
- var poster = video.poster;
- var self = this;
-
- // Add the poster image to the image storage
- poster.filepath = video.filepath.replace(/\.(?:3gp|mp4|webm)$/, '.jpg');
- video.isVideo = true;
-
- this.storage.addPicture(
- poster.blob,
- { filepath: poster.filepath },
- function(error, path, absolutePath, fileBlob) {
- // Replace the memory-backed Blob with the DeviceStorage file-backed File.
- // Note that "video" has a reference to "poster", so video previews will
- // use this File.
- poster.blob = fileBlob;
- debug('new video', video);
- self.app.emit('newmedia', video);
- });
-};
-
-/**
- * Calculate and set a `maxFileSize`
- * value on the storage instance.
- *
- * This is so that it can alert us when there
- * isn't enough space left in storage
- * to accomodate a new picture.
- *
- * It is very unlikely that a JPEG file will have a file size that is
- * more than half a byte per pixel. There is some fixed EXIF overhead
- * that is the same for small and large pictures, however, so we add
- * an additional 25,000 bytes of padding.
- *
- * @private
- */
-StorageController.prototype.updateMaxFileSize = function() {
- var pictureSize = this.settings.pictureSizes.selected('data');
- /* Depending on when we get loaded, we may not have a picture size yet. */
- if (pictureSize) {
- var bytes = (pictureSize.width * pictureSize.height / 2) + 25000;
- this.storage.setMaxFileSize(bytes);
- debug('maxFileSize updated %s', bytes);
- }
-};
-
+define('controllers/storage', [
+ 'require',
+ 'exports',
+ 'module',
+ 'debug',
+ 'lib/bind-all',
+ 'lib/storage'
+], function (require, exports, module) {
+ 'use strict';
+ var debug = require('debug')('controller:storage');
+ var bindAll = require('lib/bind-all');
+ var Storage = require('lib/storage');
+ module.exports = function (app) {
+ return new StorageController(app);
+ };
+ module.exports.StorageController = StorageController;
+ function StorageController(app) {
+ bindAll(this);
+ this.app = app;
+ this.camera = app.camera;
+ this.settings = app.settings;
+ this.storage = app.storage || new Storage();
+ this.bindEvents();
+ this.configure();
+ debug('initialized');
+ }
+ StorageController.prototype.configure = function () {
+ this.storage.configure();
+ this.camera.createVideoFilepath = this.storage.createVideoFilepath;
+ this.updateMaxFileSize();
+ };
+ StorageController.prototype.bindEvents = function () {
+ debug('bind events');
+ this.settings.pictureSizes.on('change:selected', this.updateMaxFileSize);
+ this.app.on('previewgallery:deletepicture', this.storage.deletePicture);
+ this.app.on('previewgallery:deletevideo', this.storage.deleteVideo);
+ this.app.on('settings:configured', this.updateMaxFileSize);
+ this.app.on('camera:newimage', this.storePicture);
+ this.app.on('camera:newvideo', this.storeVideo);
+ this.app.on('visible', this.storage.check);
+ this.storage.on('volumechanged', this.app.firer('storage:volumechanged'));
+ this.storage.on('itemdeleted', this.app.firer('storage:itemdeleted'));
+ this.storage.on('changed', this.onChanged);
+ this.storage.on('checked', this.onChecked);
+ debug('events bound');
+ };
+ StorageController.prototype.onChanged = function (state) {
+ debug('changed: %s', state);
+ this.app.emit('storage:changed', state);
+ };
+ StorageController.prototype.onChecked = function (value) {
+ debug('checked: %s', value);
+ this.app.emit('storage:checked', value);
+ this.app.emit('storage:checked:' + value);
+ };
+ StorageController.prototype.storePicture = function (picture) {
+ var memoryBlob = picture.blob;
+ var self = this;
+ this.storage.addPicture(memoryBlob, function (error, filepath, abspath, fileBlob) {
+ picture.blob = fileBlob;
+ picture.filepath = filepath;
+ debug('stored picture', picture);
+ self.app.emit('newmedia', picture);
+ });
+ };
+ StorageController.prototype.storeVideo = function (video) {
+ debug('new video', video);
+ var poster = video.poster;
+ var self = this;
+ poster.filepath = video.filepath.replace('.3gp', '.jpg');
+ video.isVideo = true;
+ this.storage.addPicture(poster.blob, { filepath: poster.filepath }, function (error, path, absolutePath, fileBlob) {
+ poster.blob = fileBlob;
+ debug('new video', video);
+ self.app.emit('newmedia', video);
+ });
+ };
+ StorageController.prototype.updateMaxFileSize = function () {
+ var pictureSize = this.settings.pictureSizes.selected('data');
+ if (pictureSize) {
+ var bytes = pictureSize.width * pictureSize.height / 2 + 25000;
+ this.storage.setMaxFileSize(bytes);
+ debug('maxFileSize updated %s', bytes);
+ }
+ };
});
window.jsStarted = Date.now();
define('main', [
diff --git a/overrides/gallery.gaiamobile.org/js/MetadataParser.js b/overrides/gallery.gaiamobile.org/js/MetadataParser.js
deleted file mode 100644
index 87fba101..00000000
--- a/overrides/gallery.gaiamobile.org/js/MetadataParser.js
+++ /dev/null
@@ -1,713 +0,0 @@
-'use strict';
-/* global
- CONFIG_MAX_IMAGE_PIXEL_SIZE,
- CONFIG_REQUIRED_EXIF_PREVIEW_HEIGHT,
- CONFIG_REQUIRED_EXIF_PREVIEW_WIDTH,
- cropResizeRotate,
- Downsample,
- getImageSize,
- getVideoRotation,
- isPhone,
- parseJPEGMetadata,
- videostorage
-*/
-/* exported
- metadataParser
-*/
-
-//
-// This file defines a single metadataParsers object. The two
-// properties of this object are metadata parsing functions for image
-// files and video files, intended for use with the MediaDB class.
-//
-// This file depends on JPEGMetadataParser.js and blobview.js
-//
-var metadataParser = (function() {
- // If we generate our own thumbnails, aim for this size.
- // Calculate needed size from longer side of the screen.
- var THUMBNAIL_WIDTH = computeThumbnailWidth();
- var THUMBNAIL_HEIGHT = THUMBNAIL_WIDTH;
-
- function computeThumbnailWidth() {
- // Make sure this works regardless of current device orientation
- var width = window.innerWidth || screen.width;
- var height = window.innerHeight || screen.height;
- var portraitWidth = Math.max(1, Math.min(width, height));
- var landscapeWidth = Math.max(1, Math.max(width, height));
- var thumbnailsPerRowPortrait = isPhone ? 3 : 4;
- var thumbnailsPerRowLandscape = isPhone ? 4 : 6;
- return Math.round(window.devicePixelRatio *
- Math.max(portraitWidth / thumbnailsPerRowPortrait,
- landscapeWidth / thumbnailsPerRowLandscape));
- }
-
- var thumbnailSize = {
- width: THUMBNAIL_WIDTH,
- height: THUMBNAIL_HEIGHT
- };
-
- // Don't try to decode image files of unknown type if bigger than this
- var MAX_UNKNOWN_IMAGE_FILE_SIZE = 0.5 * 1024 * 1024; // half a megabyte
-
- // If a jpeg does not have a suitable EXIF preview and has more than
- // this number of pixels, then we create an external preview for it
- var MAX_PREVIEWLESS_JPEG_SIZE = 5 * 1024 * 1024; // 5 megapixels
-
- // An element for loading images
- var offscreenImage = new Image();
-
- // The screen size. Preview images must be at least this big
- // Note that we use screen.width instead of window.innerWidth because
- // the window size is different when we are invoked for a pick activity
- // (non-fullscreen) and when we are invoked normally (fullscreen).
- var sw = screen.width * window.devicePixelRatio;
- var sh = screen.height * window.devicePixelRatio;
-
- // Create a thumbnail size canvas, copy the or into it
- // cropping the edges as needed to make it fit, and then extract the
- // thumbnail image as a blob and pass it to the callback.
- // This utility function is used by both the image and video metadata parsers
- function createThumbnailFromElement(elt, video, rotation,
- mirrored, callback, error) {
- try {
- // Create a thumbnail image
- var canvas = document.createElement('canvas');
- canvas.width = THUMBNAIL_WIDTH;
- canvas.height = THUMBNAIL_HEIGHT;
- var context = canvas.getContext('2d', { willReadFrequently: true });
- var eltwidth = elt.width;
- var eltheight = elt.height;
- var scalex = canvas.width / eltwidth;
- var scaley = canvas.height / eltheight;
-
- // Take the larger of the two scales: we crop the image to the thumbnail
- var scale = Math.max(scalex, scaley);
-
- // Calculate the region of the image that will be copied to the
- // canvas to create the thumbnail
- var w = Math.round(THUMBNAIL_WIDTH / scale);
- var h = Math.round(THUMBNAIL_HEIGHT / scale);
- var x = Math.round((eltwidth - w) / 2);
- var y = Math.round((eltheight - h) / 2);
-
- var centerX = Math.floor(THUMBNAIL_WIDTH / 2);
- var centerY = Math.floor(THUMBNAIL_HEIGHT / 2);
-
- // If a orientation is specified, rotate/mirroring the canvas context.
- if (rotation || mirrored) {
- context.save();
- // All transformation are applied to the center of the thumbnail.
- context.translate(centerX, centerY);
- }
- if (mirrored) {
- context.scale(-1, 1);
- }
- if (rotation) {
- switch (rotation) {
- case 90:
- context.rotate(Math.PI / 2);
- break;
- case 180:
- context.rotate(Math.PI);
- break;
- case 270:
- context.rotate(-Math.PI / 2);
- break;
- }
- }
-
- if (rotation || mirrored) {
- context.translate(-centerX, -centerY);
- }
-
- // Draw that region of the image into the canvas, scaling it down
- context.drawImage(elt, x, y, w, h,
- 0, 0, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT);
-
- // Restore the default rotation so the play arrow comes out correctly
- if (rotation || mirrored) {
- context.restore();
- }
-
- // If this is a video, superimpose a translucent play button over
- // the captured video frame to distinguish it from a still photo thumbnail
- if (video) {
- // First draw a transparent gray circle
- context.fillStyle = 'rgba(0, 0, 0, .2)';
- context.beginPath();
- context.arc(THUMBNAIL_WIDTH / 2, THUMBNAIL_HEIGHT / 2,
- THUMBNAIL_HEIGHT / 5, 0, 2 * Math.PI, false);
- context.fill();
-
- // Now outline the circle in white
- context.strokeStyle = 'rgba(255,255,255,.6)';
- context.lineWidth = 2;
- context.stroke();
-
- // And add a white play arrow.
- context.beginPath();
- context.fillStyle = 'rgba(255,255,255,.6)';
- // The height of an equilateral triangle is sqrt(3)/2 times the side
- var side = THUMBNAIL_HEIGHT / 5;
- var triangle_height = side * Math.sqrt(3) / 2;
- context.moveTo(THUMBNAIL_WIDTH / 2 + triangle_height * 2 / 3,
- THUMBNAIL_HEIGHT / 2);
- context.lineTo(THUMBNAIL_WIDTH / 2 - triangle_height / 3,
- THUMBNAIL_HEIGHT / 2 - side / 2);
- context.lineTo(THUMBNAIL_WIDTH / 2 - triangle_height / 3,
- THUMBNAIL_HEIGHT / 2 + side / 2);
- context.closePath();
- context.fill();
- }
-
- canvas.toBlob(function(blob) {
- context = null;
- canvas.width = canvas.height = 0;
- canvas = null;
- callback(blob);
- }, 'image/jpeg');
- } catch (ex) {
- // An error may be thrown when the drawImage decodes a broken/trancated
- // image.
- // The elt may be a offscreen image. So, the image metadata is parsed, and
- // the image data is loaded but not decoded. The drawImage triggers the
- // image decoder to decode the image data. And an error may be thrown.
- error('createThumbnailFromElement:' + ex.message);
- }
- }
-
- var VIDEOFILE = /DCIM\/\d{3}MZLLA\/VID_\d{4}\.jpg/;
-
- function metadataParser(file, metadataCallback, metadataError, bigFile) {
- // If the file is a poster image for a video file, then we've want
- // video metadata, not image metadata
- if (VIDEOFILE.test(file.name)) {
- videoMetadataParser(file, metadataCallback, metadataError);
- return;
- }
-
- // Figure out how big the image is if we can. For JPEG files this
- // calls the JPEG parser and returns the EXIF preview if there is one.
- getImageSize(file, gotImageSize, gotImageSizeError);
-
- function gotImageSizeError(errmsg) {
- // The image is not a JPEG, PNG, GIF or BMP file. We may still be
- // able to decode and display it but we don't know the image
- // size, so we won't even try if the file is too big.
- if (file.size > MAX_UNKNOWN_IMAGE_FILE_SIZE) {
- metadataError('Ignoring large file ' + file.name);
- return;
- }
-
- // If the file is too small to be an image, ignore it
- if (file.size < 32) {
- metadataError('Ignoring small file ' + file.name);
- return;
- }
-
- // If the error message is anything other than unknown image type
- // it means we've got a corrupt image file, or the image metdata parser
- // can't handle the file for some reason. Log a warning but keep going
- // in case the image is good and the metadata parser is buggy.
- if (errmsg !== 'unknown image type') {
- console.warn('getImageSize', errmsg, file.name);
- }
-
- // If it is not too big create a preview and thumbnail.
- createThumbnailAndPreview(file,
- metadataCallback,
- metadataError,
- false,
- bigFile,
- {});
- }
-
- function gotImageSize(metadata) {
- //
- // If the image is too big, reject it now so we don't have
- // memory trouble later.
- //
- // CONFIG_MAX_IMAGE_PIXEL_SIZE is maximum image resolution we
- // can handle. It's from config.js which is generated at build
- // time (see build/application-data.js).
- //
- // For jpeg images we can downsample while decoding which means that
- // we can handle images that are quite a bit larger.
- //
- // getImageSize() distinguishes pjpeg files from jpeg, because
- // progressive jpeg files are quite different than regular jpegs.
- // Not only do pjpegs not benefit from using #-moz-samplesize, they
- // actually require more memory to decode than other images of the same
- // resolution, so we actually need to reduce the image size limit for
- // this image type.
- //
- var imagesizelimit = CONFIG_MAX_IMAGE_PIXEL_SIZE;
- if (metadata.type === 'jpeg') {
- imagesizelimit *= Downsample.MAX_AREA_REDUCTION;
- }
- else if (metadata.type === 'pjpeg') {
- // As a semi-educated guess, we'll say that we can handle pjpegs
- // 2/3rds the size of the largest PNG we can handle.
- imagesizelimit *= 2 / 3;
- }
-
- //
- // Even if we can downsample an image while decoding it, we still
- // have to read the entire image file. If the file is particularly
- // large we might also have memory problems. (See bug 1008834: a 20mb
- // 80mp jpeg file will cause an OOM on Tarako even though we can
- // decode it at < 2mp). Rather than adding another build-time config
- // variable to specify the maximum file size, however, we'll just
- // base the file size limit on CONFIG_MAX_IMAGE_PIXEL_SIZE.
- // So if that variable is set to 2M, then we might use up to 12Mb of
- // memory. 2 * 2M bytes for the image file and 4 bytes times 2M pixels
- // for the decoded image. A 4mb file size limit should accomodate
- // most JPEG files up to 12 or 16 megapixels.
- //
- // Note that this size limit is just a guess. If we continue to see
- // OOMs with large files then we should change the number 2 below
- // to something smaller.
- //
- var filesizelimit = 2 * CONFIG_MAX_IMAGE_PIXEL_SIZE;
-
- if (metadata.width * metadata.height > imagesizelimit ||
- file.size > filesizelimit) {
- metadataError('Ignoring high-resolution image ' + file.name);
- return;
- }
-
- // If the file included a preview image, see if it is big enough
- if (metadata.preview) {
- // Create a blob that is just the preview image
- var previewblob = file.slice(metadata.preview.start,
- metadata.preview.end,
- 'image/jpeg');
-
- // Check to see if the preview is big enough to use in MediaFrame
- parseJPEGMetadata(previewblob, previewsuccess, previewerror);
- }
- else {
- // If there wasn't a preview image, then generate a preview and
- // thumbnail from the full size image.
- useFullsizeImage();
- }
-
- function previewerror(msg) {
- // The preview isn't a valid jpeg file, so use the full image to
- // create a preview and a thumbnail
- console.error(msg);
- useFullsizeImage();
- }
-
- // If we can't find a valid embedded EXIF preview image then we come here
- function useFullsizeImage() {
- // If the full size image is a JPEG then we'll just skip the
- // preview generation because we can downsample and decode it
- // at preview size when needed. So in this case, we just need
- // to create a thumbnail for the image. On the other hand, if
- // this is not a JPEG image then we have to create both a
- // thumbnail and an external preview image. Note that progressive
- // jpegs will have a type 'pjpeg' and will be handled below
- // like png images.
-
- // For jpeg images with image sizes more than 5MP, we need
- // to create preview on sdcard to avoid flash while opening image.
- // See bug 1181290.
- // This constraint can be removed when gecko supports downscale during
- // decode by canvas size for offscreen images. This will
- // facilitate removing #moz-sample-size and using ddd.
- // See bug 1206206.
-
- if (metadata.type === 'jpeg') {
- if (metadata.width * metadata.height <= MAX_PREVIEWLESS_JPEG_SIZE) {
- // If the jpeg image isn't too big we don't need a preview
- // because we can decode it quickly enough at screen size.
- // So in this case we just need to create a thumbnail.
- cropResizeRotate(file, null, thumbnailSize, null, metadata,
- function gotThumbnail(error, thumbnailBlob) {
- if (error) {
- var msg = 'Failed to create thumbnail for' +
- file.name;
- console.error(msg);
- metadataError(msg);
- return;
- }
-
- metadata.preview = null;
- metadata.thumbnail = thumbnailBlob;
- metadataCallback(metadata);
- });
- }
- else {
- // Otherwise, this is a high-resolution jpeg that does not
- // have an EXIF preview big enough for the screen. We need
- // to create both a thumbnail and a preview image but we can
- // do that with efficient downsampling using cropResizeRotate().
- createJPEGThumbnailAndPreview(file, metadata,
- metadataCallback,
- metadataError);
- }
- }
- else {
- // In this case we have no preview and the image is not a jpeg
- // that can be downsampled with #-moz-samplesize
- createThumbnailAndPreview(file,
- metadataCallback,
- metadataError,
- false,
- bigFile,
- metadata);
- }
- }
-
- function previewsuccess(previewmetadata) {
- // Size of the preview image
- var pw = previewmetadata.width;
- var ph = previewmetadata.height;
- // optional configuration specifying minimum size
- var mw = CONFIG_REQUIRED_EXIF_PREVIEW_WIDTH;
- var mh = CONFIG_REQUIRED_EXIF_PREVIEW_HEIGHT;
-
- var bigenough;
-
- // If config.js specifies a minimum required preview size,
- // then this preview is big enough if both dimensions are
- // larger than that configured minimum. Otherwise, the preview
- // is big enough if at least one dimension is >= the screen
- // size in both portait and landscape mode.
- if (mw && mh) {
- bigenough =
- Math.max(pw, ph) >= Math.max(mw, mh) &&
- Math.min(pw, ph) >= Math.min(mw, mh);
- }
- else {
- bigenough = (pw >= sw || ph >= sh) && (pw >= sh || ph >= sw);
- }
-
- // Does the aspect ratio of the preview match the aspect ratio of
- // the full-size image? If not we have to assume it is distorted
- // and should not be used.
- var aspectRatioMatches =
- Math.abs(pw / ph - metadata.width / metadata.height) < 0.01;
-
- // If the preview is good, use it to create a thumbnail.
- if (bigenough && aspectRatioMatches) {
- metadata.preview.width = pw;
- metadata.preview.height = ph;
-
- // This is the most common case. We've got a JPEG image with a
- // JPEG preview. All we need is a thumbnail, and we can create that
- // most efficiently with cropResizeRotate which will use
- // #-moz-samplesize to save memory while decoding the image.
- // Note that we apply the EXIF orientation information from
- // the full size image to the preview image.
- previewmetadata.rotation = metadata.rotation;
- previewmetadata.mirrored = metadata.mirrored;
- cropResizeRotate(previewblob, null, thumbnailSize, null,
- previewmetadata,
- function gotThumbnailBlob(error, thumbnailBlob) {
- if (error) {
- previewerror(error);
- return;
- }
- metadata.thumbnail = thumbnailBlob;
- metadataCallback(metadata);
- });
- } else {
- // Preview isn't big enough so get one the hard way
- useFullsizeImage();
- }
- }
- }
- }
-
- // This function is called for JPEG images that do not have a suitable
- // EXIF preview image and that have more than MAX_PREVIEWLESS_JPEG_SIZE
- // pixels. It creates and saves a preview image and a thumbnail.
- // Unlike createThumbnailAndPreview(), it uses cropResizeRotate() which
- // downsamples jpeg images while decoding them to save memory.
- function createJPEGThumbnailAndPreview(file, metadata,
- metadataCallback, metadataError)
- {
-
- // Figure out the preview size.
- var scale = Math.max(Math.min(sw / metadata.width,
- sh / metadata.height, 1),
- Math.min(sh / metadata.width,
- sw / metadata.height, 1));
- var previewSize = {
- width: Math.round(metadata.width * scale),
- height: Math.round(metadata.height * scale)
- };
-
- // Create a preview image.
- // Note that we can't use the full metadata object here
- // because we don't want the preview image to be rotated.
- // We want the same orientation as the original image
- cropResizeRotate(file, null, previewSize, null,
- { width: metadata.width, height: metadata.height },
- function gotPreview(error, previewBlob) {
- if (error) {
- var msg = 'Failed to create preview for' +
- file.name;
- console.error(msg);
- metadataError(msg);
- return;
- }
-
- // Save this preview to the sdcard
- savePreview(file, previewBlob, metadataError, function(filename) {
- // Record the preview image in the metadata
- metadata.preview = {
- filename: filename,
- width: previewSize.width,
- height: previewSize.height
- };
-
- // Now create the thumbnail from this smaller preview image
- // In this case we need to pass the size of the preview
- // and the rotation of the original image.
- cropResizeRotate(previewBlob, null, thumbnailSize, null,
- {
- width: previewSize.width,
- height: previewSize.height,
- rotation: metadata.rotation
- },
- function gotThumbnail(error, thumbnailBlob) {
- // If thumbnail creation failed report the error
- if (error) {
- metadataError(error);
- return;
- }
-
- // Otherwise, remember thumbnail and we're done
- metadata.thumbnail = thumbnailBlob;
- metadataCallback(metadata);
- });
- });
- });
- }
-
- // Load an image from a file into an tag, and then use that
- // to get its dimensions and create a thumbnail. Store these values in
- // a metadata object, and pass the object to the callback function.
- // If anything goes wrong, pass an error message to the error function.
- // If it is a large image, create and save a preview for it as well.
- //
- // We used to call this function for every image. Now that gecko supports
- // the #-moz-samplesize media fragment, however, we handle jpeg images
- // with cropResizeRotate() for memory efficiency, and this function is
- // used only for non-jpeg files.
- function createThumbnailAndPreview(file, callback, error, nopreview,
- bigFile, metadata) {
- var url = URL.createObjectURL(file);
- offscreenImage.src = url;
-
- offscreenImage.onerror = function() {
- URL.revokeObjectURL(url);
- offscreenImage.onload = offscreenImage.onerror = null; offscreenImage.removeAttribute('src');
- error('createThumbnailAndPreview: Image failed to load');
- };
-
- offscreenImage.onload = function() {
- URL.revokeObjectURL(url);
-
- var iw = offscreenImage.width;
- var ih = offscreenImage.height;
-
- // Don't overwrite the metadata in the case we read a previewblob.
- if (!nopreview) {
- metadata.width = iw;
- metadata.height = ih;
- }
-
- // If this is a big image, then decoding it takes a lot of memory.
- // We set this flag to prevent the user from zooming in on other
- // images at the same time because that also takes a lot of memory
- // and we don't want to crash with an OOM. If we find one big image
- // we assume that there may be others, so the flag remains set until
- // the current scan is complete.
- //
- // XXX: When bug 854795 is fixed, we'll be able to create previews
- // for large images without using so much memory, and we can remove
- // this flag then.
- if (iw * ih > 2 * 1024 * 1024 && bigFile) {
- bigFile();
- }
-
- // If the image was already thumbnail size, it is its own thumbnail
- // and it does not need a preview
- if (metadata.width <= THUMBNAIL_WIDTH &&
- metadata.height <= THUMBNAIL_HEIGHT) {
- offscreenImage.onload = offscreenImage.onerror = null; offscreenImage.removeAttribute('src');
- metadata.thumbnail = file;
- callback(metadata);
- }
- else {
- createThumbnailFromElement(
- offscreenImage,
- false,
- metadata.rotation || 0,
- metadata.mirrored || false,
- gotThumbnail,
- error);
- }
-
- function gotThumbnail(thumbnail) {
- metadata.thumbnail = thumbnail;
- // If no preview was requested, or if if the image was less
- // than half a megapixel then it does not need a preview
- // image, and we can call the callback right away
- if (nopreview || metadata.width * metadata.height < 512 * 1024) {
- offscreenImage.onload = offscreenImage.onerror = null; offscreenImage.removeAttribute('src');
- callback(metadata);
- }
- else {
- // Otherwise, this was a big image and we need to create a
- // preview for it so we can avoid decoding the full size
- // image again when possible
- createAndSavePreview();
- }
- }
-
- function createAndSavePreview() {
- // Figure out the preview size.
- // Make sure the size is big enough for both landscape and portrait
- var scale = Math.max(Math.min(sw / iw, sh / ih, 1),
- Math.min(sh / iw, sw / ih, 1));
- // preview width and height
- var pw = Math.round(iw * scale);
- var ph = Math.round(ih * scale);
-
- // Create the preview in a canvas
- var canvas = document.createElement('canvas');
- canvas.width = pw;
- canvas.height = ph;
- var context = canvas.getContext('2d', { willReadFrequently: true });
- context.drawImage(offscreenImage, 0, 0, iw, ih, 0, 0, pw, ph);
- canvas.toBlob(function(blob) {
- offscreenImage.onload = offscreenImage.onerror = null; offscreenImage.removeAttribute('src');
- canvas.width = canvas.height = 0;
- savePreview(file, blob, error, function(filename) {
- metadata.preview = {
- filename: filename,
- width: pw,
- height: ph
- };
- callback(metadata);
- });
- }, 'image/jpeg');
- }
- };
- }
-
- // Given an image file and a preview blob for it, this function saves
- // the preview to a file in .gallery/previews/ and then passes the filename
- // to the specified callback. Calls errorCallback if an error occurs.
- // This is used by createThumbnailAndPreview() and by
- // createJPEGThumbnailAndPreview().
- function savePreview(file, previewblob, errorCallback, filenameCallback) {
- var storage = navigator.getDeviceStorage('pictures');
- var filename;
- if (file.name[0] === '/') {
- // We expect file.name to be a fully qualified name (perhaps
- // something like /sdcard/DCIM/100MZLLA/IMG_0001.jpg).
- var slashIndex = file.name.indexOf('/', 1);
- if (slashIndex < 0) {
- errorCallback('savePreview: Bad filename: \'' + file.name + '\'');
- return;
- }
- filename =
- file.name.substring(0, slashIndex) + // storageName (i.e. /sdcard)
-
- '/.gallery/previews' +
- file.name.substring(slashIndex); // rest of path (i,e, /DCIM/...)
- } else {
- // On non-composite storage areas (e.g. desktop), file.name will be
- // a relative path.
- filename = '.gallery/previews/' + file.name;
- }
-
- // Add a '.jpeg' extension to all preview files. This is necessary
- // because all previews get saved as jpegs even if the original
- // image is not a jpeg. But DeviceStorage uses the filename extension
- // to determine the file type.
- filename += '.jpeg';
-
- // Delete any existing preview by this name
- var delreq = storage.delete(filename);
- delreq.onsuccess = delreq.onerror = save;
-
- function save() {
- var savereq = storage.addNamed(previewblob, filename);
- savereq.onerror = function() {
- console.error('Could not save preview image', filename);
- };
-
- // Don't actually wait for the save to complete.
- filenameCallback(filename);
- }
- }
-
- function videoMetadataParser(file, metadataCallback, errorCallback) {
- var metadata = {};
- var extensions = ['webm', '3gp', 'mp4'];
- function findVideo() {
- var videofilename = file.name.replace(/\.jpg$/, '.' + extensions.shift());
- var getreq = videostorage.get(videofilename);
- getreq.onerror = function() {
- if (extensions.length) findVideo();
- else errorCallback('cannot get video file: ' + videofilename);
- };
- getreq.onsuccess = function() {
- metadata.video = videofilename;
- if (/\.webm$/.test(videofilename)) {
- metadata.rotation = 0;
- getVideoThumbnailAndSize();
- return;
- }
- getVideoRotation(getreq.result, function(rotation) {
- if (typeof rotation === 'number') {
- metadata.rotation = rotation;
- getVideoThumbnailAndSize();
- } else errorCallback('Video rotation: ' + rotation);
- });
- };
- }
- findVideo();
-
- function getVideoThumbnailAndSize() {
- var url = URL.createObjectURL(file);
- offscreenImage.src = url;
-
- offscreenImage.onerror = function() {
- URL.revokeObjectURL(url);
- offscreenImage.onload = offscreenImage.onerror = null; offscreenImage.removeAttribute('src');
- errorCallback('getVideoThumanailAndSize: Image failed to load');
- };
-
- offscreenImage.onload = function() {
- URL.revokeObjectURL(url);
-
- // We store the unrotated size of the poster image. The video
- // itself might be larger or smaller than this, but it will
- // have the same orientation and aspect ratio.
- metadata.width = offscreenImage.width;
- metadata.height = offscreenImage.height;
-
- createThumbnailFromElement(offscreenImage,
- true,
- metadata.rotation,
- false,
- function(thumbnail) {
- metadata.thumbnail = thumbnail;
- offscreenImage.onload = offscreenImage.onerror = null; offscreenImage.removeAttribute('src');
- metadataCallback(metadata);
- },
- errorCallback);
- };
- }
- }
-
- return metadataParser;
-}());
diff --git a/overrides/gallery.gaiamobile.org/js/metadata_scripts.js b/overrides/gallery.gaiamobile.org/js/metadata_scripts.js
deleted file mode 100644
index 94691295..00000000
--- a/overrides/gallery.gaiamobile.org/js/metadata_scripts.js
+++ /dev/null
@@ -1,1777 +0,0 @@
-/* exported BlobView */
-'use strict';
-
-var BlobView = (function() {
- function fail(msg) {
- throw Error(msg);
- }
-
- var decoderCache = {};
- function getDecoder(encoding) {
- if (encoding in decoderCache) {
- return decoderCache[encoding];
- }
- var decoder = decoderCache[encoding] = new TextDecoder(encoding);
- return decoder;
- }
-
- // This constructor is for internal use only.
- // Use the BlobView.get() factory function or the getMore instance method
- // to obtain a BlobView object.
- function BlobView(blob, sliceOffset, sliceLength, slice,
- viewOffset, viewLength, littleEndian)
- {
- this.blob = blob; // The parent blob that the data is from
- this.sliceOffset = sliceOffset; // The start address within the blob
- this.sliceLength = sliceLength; // How long the slice is
- this.slice = slice; // The ArrayBuffer of slice data
- this.viewOffset = viewOffset; // The start of the view within the slice
- this.viewLength = viewLength; // The length of the view
- this.littleEndian = littleEndian; // Read little endian by default?
-
- // DataView wrapper around the ArrayBuffer
- this.view = new DataView(slice, viewOffset, viewLength);
-
- // These fields mirror those of DataView
- this.buffer = slice;
- this.byteLength = viewLength;
- this.byteOffset = viewOffset;
-
- this.index = 0; // The read methods keep track of the read position
- }
-
- // Async factory function
- BlobView.get = function(blob, offset, length, callback, littleEndian) {
- if (offset < 0) {
- fail('negative offset');
- }
- if (length < 0) {
- fail('negative length');
- }
- if (offset > blob.size) {
- fail('offset larger than blob size');
- }
- // Don't fail if the length is too big; just reduce the length
- if (offset + length > blob.size) {
- length = blob.size - offset;
- }
- var slice = blob.slice(offset, offset + length);
- var reader = new FileReader();
- reader.readAsArrayBuffer(slice);
- reader.onloadend = function() {
- var result = null;
- if (reader.result) {
- result = new BlobView(blob, offset, length, reader.result,
- 0, length, littleEndian || false);
- }
- callback(result, reader.error);
- };
- };
-
- // Synchronous factory function for use when you have an array buffer and want
- // to treat it as a BlobView (e.g. to use the readXYZ functions). We need
- // this for the music app, which uses an array buffer to hold
- // de-unsynchronized ID3 frames.
- BlobView.getFromArrayBuffer = function(buffer, offset, length, littleEndian) {
- return new BlobView(null, offset, length, buffer, offset, length,
- littleEndian);
- };
-
- BlobView.prototype = {
- constructor: BlobView,
-
- // This instance method is like the BlobView.get() factory method,
- // but it is here because if the current buffer includes the requested
- // range of bytes, they can be passed directly to the callback without
- // going back to the blob to read them
- getMore: function(offset, length, callback) {
- // If we made this BlobView from an array buffer, there's no blob backing
- // it, and so it's impossible to get more data.
- if (!this.blob) {
- fail('no blob backing this BlobView');
- }
-
- if (offset >= this.sliceOffset &&
- offset + length <= this.sliceOffset + this.sliceLength) {
- // The quick case: we already have that region of the blob
- callback(new BlobView(this.blob,
- this.sliceOffset, this.sliceLength, this.slice,
- offset - this.sliceOffset, length,
- this.littleEndian));
- }
- else {
- // Otherwise, we have to do an async read to get more bytes
- BlobView.get(this.blob, offset, length, callback, this.littleEndian);
- }
- },
-
- // Set the default endianness for the other methods
- littleEndian: function() {
- this.littleEndian = true;
- },
- bigEndian: function() {
- this.littleEndian = false;
- },
-
- // These "get" methods are just copies of the DataView methods, except
- // that they honor the default endianness
- getUint8: function(offset) {
- return this.view.getUint8(offset);
- },
- getInt8: function(offset) {
- return this.view.getInt8(offset);
- },
- getUint16: function(offset, le) {
- return this.view.getUint16(offset,
- le !== undefined ? le : this.littleEndian);
- },
- getInt16: function(offset, le) {
- return this.view.getInt16(offset,
- le !== undefined ? le : this.littleEndian);
- },
- getUint32: function(offset, le) {
- return this.view.getUint32(offset,
- le !== undefined ? le : this.littleEndian);
- },
- getInt32: function(offset, le) {
- return this.view.getInt32(offset,
- le !== undefined ? le : this.littleEndian);
- },
- getFloat32: function(offset, le) {
- return this.view.getFloat32(offset,
- le !== undefined ? le : this.littleEndian);
- },
- getFloat64: function(offset, le) {
- return this.view.getFloat64(offset,
- le !== undefined ? le : this.littleEndian);
- },
-
- // These "read" methods read from the current position in the view and
- // update that position accordingly
- readByte: function() {
- return this.view.getInt8(this.index++);
- },
- readUnsignedByte: function() {
- return this.view.getUint8(this.index++);
- },
- readShort: function(le) {
- var val = this.view.getInt16(this.index,
- le !== undefined ? le : this.littleEndian);
- this.index += 2;
- return val;
- },
- readUnsignedShort: function(le) {
- var val = this.view.getUint16(this.index,
- le !== undefined ? le : this.littleEndian);
- this.index += 2;
- return val;
- },
- readInt: function(le) {
- var val = this.view.getInt32(this.index,
- le !== undefined ? le : this.littleEndian);
- this.index += 4;
- return val;
- },
- readUnsignedInt: function(le) {
- var val = this.view.getUint32(this.index,
- le !== undefined ? le : this.littleEndian);
- this.index += 4;
- return val;
- },
- readFloat: function(le) {
- var val = this.view.getFloat32(this.index,
- le !== undefined ? le : this.littleEndian);
- this.index += 4;
- return val;
- },
- readDouble: function(le) {
- var val = this.view.getFloat64(this.index,
- le !== undefined ? le : this.littleEndian);
- this.index += 8;
- return val;
- },
-
- // Methods to get and set the current position
- tell: function() {
- return this.index;
- },
- remaining: function() {
- return this.byteLength - this.index;
- },
- seek: function(index) {
- if (index < 0) {
- fail('negative index');
- }
- if (index > this.byteLength) {
- fail('index greater than buffer size');
- }
- this.index = index;
- },
- advance: function(n) {
- var index = this.index + n;
- if (index < 0) {
- fail('advance past beginning of buffer');
- }
- // It's usual that when we finished reading one target view,
- // the index is advanced to the start(previous end + 1) of next view,
- // and the new index will be equal to byte length(the last index + 1),
- // we will not fail on it because it means the reading is finished,
- // or do we have to warn here?
- if (index > this.byteLength) {
- fail('advance past end of buffer');
- }
- this.index = index;
- },
-
- // Additional methods to read other useful things
- getUnsignedByteArray: function(offset, n) {
- return new Uint8Array(this.buffer, offset + this.viewOffset, n);
- },
-
- // Additional methods to read other useful things
- readUnsignedByteArray: function(n) {
- var val = new Uint8Array(this.buffer, this.index + this.viewOffset, n);
- this.index += n;
- return val;
- },
-
- getBit: function(offset, bit) {
- var byte = this.view.getUint8(offset);
- return (byte & (1 << bit)) !== 0;
- },
-
- getUint24: function(offset, le) {
- var b1, b2, b3;
- if (le !== undefined ? le : this.littleEndian) {
- b1 = this.view.getUint8(offset);
- b2 = this.view.getUint8(offset + 1);
- b3 = this.view.getUint8(offset + 2);
- }
- else { // big end first
- b3 = this.view.getUint8(offset);
- b2 = this.view.getUint8(offset + 1);
- b1 = this.view.getUint8(offset + 2);
- }
-
- return (b3 << 16) + (b2 << 8) + b1;
- },
-
- readUint24: function(le) {
- var value = this.getUint24(this.index, le);
- this.index += 3;
- return value;
- },
-
- // There are lots of ways to read strings. We support binary (raw 8-bit
- // codepoints), Latin-1, UTF-8, and UTF-16. For the latter three, we also
- // support null-terminated versions.
-
- getBinaryText: function(offset, len) {
- var bytes = new Uint8Array(this.buffer, offset + this.viewOffset, len);
- return String.fromCharCode.apply(String, bytes);
- },
-
- readBinaryText: function(len) {
- var s = this.getBinaryText(this.index, len);
- this.index += len;
- return s;
- },
-
- getLatin1Text: function(offset, len) {
- var bytes = new Uint8Array(this.buffer, offset + this.viewOffset, len);
- return getDecoder('latin1').decode(bytes);
- },
-
- readLatin1Text: function(len) {
- var s = this.getLatin1Text(this.index, len);
- this.index += len;
- return s;
- },
-
- getUTF8Text: function(offset, len) {
- var bytes = new Uint8Array(this.buffer, offset + this.viewOffset, len);
- return getDecoder('utf-8').decode(bytes);
- },
-
- readUTF8Text: function(len) {
- var s = this.getUTF8Text(this.index, len);
- this.index += len;
- return s;
- },
-
- // Get UTF16 text. If le is not specified, expect a BOM to define
- // endianness. If le is true, read UTF16LE, if false, UTF16BE.
- getUTF16Text: function(offset, len, le) {
- if (len % 2) {
- fail('len must be a multiple of two');
- }
-
- var bytes = new Uint8Array(this.buffer, offset + this.viewOffset, len);
-
- if (le === null || le === undefined) {
- var BOM = (bytes[0] << 8) + bytes[1];
-
- if (BOM === 0xFEFF) {
- bytes = bytes.subarray(2);
- le = false;
- } else if (BOM === 0xFFFE) {
- bytes = bytes.subarray(2);
- le = true;
- } else {
- le = true;
- }
- }
-
- var encoding = le ? 'utf-16le' : 'utf-16be';
- return getDecoder(encoding).decode(bytes);
- },
-
- readUTF16Text: function(len, le) {
- var s = this.getUTF16Text(this.index, len, le);
- this.index += len;
- return s;
- },
-
- // Read 4 bytes, ignore the high bit and combine them into a 28-bit
- // big-endian unsigned integer.
- // This format is used by the ID3v2 metadata.
- getID3Uint28BE: function(offset) {
- var b1 = this.view.getUint8(offset) & 0x7f;
- var b2 = this.view.getUint8(offset + 1) & 0x7f;
- var b3 = this.view.getUint8(offset + 2) & 0x7f;
- var b4 = this.view.getUint8(offset + 3) & 0x7f;
- return (b1 << 21) | (b2 << 14) | (b3 << 7) | b4;
- },
-
- readID3Uint28BE: function() {
- var value = this.getID3Uint28BE(this.index);
- this.index += 4;
- return value;
- },
-
- // Read bytes up to and including a null terminator, but never
- // more than size bytes. And return as a Latin1 string. If advance_by_size
- // is true, this will always seek ahead by `size` bytes, even if a null
- // character was found earlier.
- readNullTerminatedLatin1Text: function(size, advance_by_size = false) {
- var bytes = new Uint8Array(this.buffer, this.viewOffset + this.index,
- size);
-
- var nil = bytes.indexOf(0);
- if (nil !== -1) {
- bytes = bytes.subarray(0, nil);
- }
-
- var s = getDecoder('latin1').decode(bytes);
- if (nil === -1 || advance_by_size) {
- this.index += size;
- } else {
- this.index += nil + 1;
- }
- return s;
- },
-
- // Read bytes up to and including a null terminator, but never
- // more than size bytes. And return as a UTF8 string. If advance_by_size
- // is true, this will always seek ahead by `size` bytes, even if a null
- // character was found earlier.
- readNullTerminatedUTF8Text: function(size, advance_by_size = false) {
- var bytes = new Uint8Array(this.buffer, this.viewOffset + this.index,
- size);
-
- var nil = bytes.indexOf(0);
- if (nil !== -1) {
- bytes = bytes.subarray(0, nil);
- }
-
- var s = getDecoder('utf-8').decode(bytes);
- if (nil === -1 || advance_by_size) {
- this.index += size;
- } else {
- this.index += nil + 1;
- }
- return s;
- },
-
- // Read UTF16 text. If le is not specified, expect a BOM to define
- // endianness. If le is true, read UTF16LE, if false, UTF16BE
- // Read until we find a null-terminator, but never more than size bytes.
- // If advance_by_size is true, this will always seek ahead by `size` bytes,
- // even if a null character was found earlier.
- readNullTerminatedUTF16Text: function(size, le, advance_by_size = false) {
- if (size % 2) {
- fail('size must be a multiple of two');
- }
-
- for (var len = 0; len < size; len += 2) {
- if (this.getUint16(this.index + len, le) === 0) {
- break;
- }
- }
-
- var s = this.getUTF16Text(this.index, len, le);
- if (len === size || advance_by_size) {
- this.index += size;
- } else {
- this.index += len + 2;
- }
- return s;
- }
- };
-
- // We don't want users of this library to accidentally call the constructor
- // instead of using one of the factory functions, so we return a dummy object
- // instead of the real constructor. If someone really needs to get at the
- // real constructor, the contructor property of the prototype refers to it.
- return {
- get: BlobView.get,
- getFromArrayBuffer: BlobView.getFromArrayBuffer
- };
-}());
-
-'use strict';
-
-/* global BlobView */
-/* exported parseJPEGMetadata */
-//
-// This file defines a single function that asynchronously reads a
-// JPEG file (or blob) to determine its width and height and find the
-// location and size of the embedded preview image, if it has one. If
-// it succeeds, it passes an object containing this data to the
-// specified callback function. If it fails, it passes an error message
-// to the specified error function instead.
-//
-// This function is capable of parsing and returning EXIF data for a
-// JPEG file, but for speed, it ignores all EXIF data except the embedded
-// preview image and the image orientation.
-//
-// This function requires the BlobView utility class
-//
-function parseJPEGMetadata(file, metadataCallback, metadataError) {
- // This is the object we'll pass to metadataCallback
- var metadata = {};
-
- // this is the start offset for the data in exif. Should be
- // where to find the TIFF magic header.
- var TIFFHeaderOffset = 0;
-
- // Start off reading a 16kb slice of the JPEG file.
- // Hopefully, this will be all we need and everything else will
- // be synchronous
- BlobView.get(file, 0, Math.min(16 * 1024, file.size), function(data) {
- if (data.byteLength < 2 ||
- data.getUint8(0) !== 0xFF ||
- data.getUint8(1) !== 0xD8) {
- metadataError('Not a JPEG file');
- return;
- }
-
- // Now start reading JPEG segments
- // getSegment() and segmentHandler() are defined below.
- getSegment(data, 2, segmentHandler);
- });
-
- // Read the JPEG segment at the specified offset and
- // pass it to the callback function.
- // Offset is relative to the current data offsets.
- // We assume that data has enough data in it that we can
- // can determine the size of the segment, and we guarantee that
- // we read extra bytes so the next call works
- function getSegment(data, offset, callback) {
- try {
- var header = data.getUint8(offset);
- if (header !== 0xFF) {
- metadataError('Malformed JPEG file: bad segment header');
- return;
- }
-
- var type = data.getUint8(offset + 1);
- var size = data.getUint16(offset + 2) + 2;
-
- // the absolute position of the segment
- var start = data.sliceOffset + data.viewOffset + offset;
- // If this isn't the last segment in the file, add 4 bytes
- // so we can read the size of the next segment
- var isLast = (start + size >= file.size);
- var length = isLast ? size : size + 4;
-
- data.getMore(start, length,
- function(data) {
- callback(type, size, data, isLast);
- });
- }
- catch (e) {
- metadataError(e.toString() + '\n' + e.stack);
- }
- }
-
- // This is a callback function for getNextSegment that handles the
- // various types of segments we expect to see in a jpeg file
- function segmentHandler(type, size, data, isLastSegment) {
- try {
- switch (type) {
- case 0xC0: // Some actual image data, including image dimensions
- case 0xC1:
- case 0xC2:
- case 0xC3:
- // Get image dimensions
- metadata.height = data.getUint16(5);
- metadata.width = data.getUint16(7);
-
- if (type === 0xC2) {
- // pjpeg files can't be efficiently downsampled while decoded
- // so we need to distinguish them from regular jpegs
- metadata.progressive = true;
- }
-
- // We're done. All the EXIF data will come before this segment
- // So call the callback
- metadataCallback(metadata);
- break;
-
- case 0xE1: // APP1 segment. Probably holds EXIF metadata
- parseAPP1(data);
-
- /* falls through */
- default:
- // A segment we don't care about, so just go on and read the next one
- if (isLastSegment) {
- metadataError('unexpected end of JPEG file');
- return;
- }
- getSegment(data, size, segmentHandler);
- }
- }
- catch (e) {
- metadataError(e.toString() + '\n' + e.stack);
- }
- }
-
- function parseAPP1(data) {
- if (data.getUint32(4, false) === 0x45786966) { // "Exif"
- var exif = parseEXIFData(data);
-
- if (exif.THUMBNAIL && exif.THUMBNAILLENGTH) {
- var start = data.sliceOffset + data.viewOffset + TIFFHeaderOffset +
- exif.THUMBNAIL;
- metadata.preview = {
- start: start,
- end: start + exif.THUMBNAILLENGTH
- };
- }
-
- // map exif orientation flags for easy transforms
- switch (exif.ORIENTATION) {
- case 2:
- metadata.rotation = 0;
- metadata.mirrored = true;
- break;
- case 3:
- metadata.rotation = 180;
- metadata.mirrored = false;
- break;
- case 4:
- metadata.rotation = 180;
- metadata.mirrored = true;
- break;
- case 5:
- metadata.rotation = 90;
- metadata.mirrored = true;
- break;
- case 6:
- metadata.rotation = 90;
- metadata.mirrored = false;
- break;
- case 7:
- metadata.rotation = 270;
- metadata.mirrored = true;
- break;
- case 8:
- metadata.rotation = 270;
- metadata.mirrored = false;
- break;
- default:
- // This is the default orientation. If it is properly encoded
- // we will get 1 here. But sometimes it is undefined and some
- // files have a 0 here as well.
- metadata.rotation = 0;
- metadata.mirrored = false;
- break;
- }
- }
- }
-
- // Parse an EXIF segment from a JPEG file and return an object
- // of metadata attributes. The argument must be a DataView object
- function parseEXIFData(data) {
- var exif = {};
-
- var byteorder = data.getUint8(10);
- if (byteorder === 0x4D) { // big endian
- byteorder = false;
- } else if (byteorder === 0x49) { // little endian
- byteorder = true;
- } else {
- throw Error('invalid byteorder in EXIF segment');
- }
-
- // The TIFF magic started at +10.
- TIFFHeaderOffset = 10;
-
- if (data.getUint16(12, byteorder) !== 42) { // magic number
- throw Error('bad magic number in EXIF segment');
- }
-
- var offset = data.getUint32(14, byteorder);
-
- // This is how we would parse all EXIF metadata more generally.
- // Especially need for iamge orientation
- parseIFD(data, offset + TIFFHeaderOffset, byteorder, exif, true);
-
- // I'm leaving this code in as a comment in case we need other EXIF
- // data in the future.
- // if (exif.EXIFIFD) {
- // parseIFD(data, exif.EXIFIFD + TIFFHeaderOffset, byteorder, exif);
- // delete exif.EXIFIFD;
- // }
-
- // if (exif.GPSIFD) {
- // parseIFD(data, exif.GPSIFD + TIFFHeaderOffset, byteorder, exif);
- // delete exif.GPSIFD;
- // }
-
- // Instead of a general purpose EXIF parse, we're going to drill
- // down directly to the thumbnail image.
- // We're in IFD0 here. We want the offset of IFD1
- var ifd0entries = data.getUint16(offset + TIFFHeaderOffset, byteorder);
- var ifd1 = data.getUint32(offset + 12 + 12 * ifd0entries, byteorder);
- // If there is an offset for IFD1, parse that
- if (ifd1 !== 0) {
- parseIFD(data, ifd1 + TIFFHeaderOffset, byteorder, exif, true);
- }
-
- return exif;
- }
-
- function parseIFD(data, offset, byteorder, exif, onlyParseOne) {
- var numentries = data.getUint16(offset, byteorder);
- for (var i = 0; i < numentries; i++) {
- parseEntry(data, offset + 2 + 12 * i, byteorder, exif);
- }
-
- if (onlyParseOne) {
- return;
- }
-
- var next = data.getUint32(offset + 2 + 12 * numentries, byteorder);
- if (next !== 0 && next < file.size) {
- parseIFD(data, next + TIFFHeaderOffset, byteorder, exif);
- }
- }
-
- // size, in bytes, of each TIFF data type
- var typesize = [
- 0, // Unused
- 1, // BYTE
- 1, // ASCII
- 2, // SHORT
- 4, // LONG
- 8, // RATIONAL
- 1, // SBYTE
- 1, // UNDEFINED
- 2, // SSHORT
- 4, // SLONG
- 8, // SRATIONAL
- 4, // FLOAT
- 8 // DOUBLE
- ];
-
- // This object maps EXIF tag numbers to their names.
- // Only list the ones we want to bother parsing and returning.
- // All others will be ignored.
- var tagnames = {
- /*
- * We don't currently use any of these EXIF tags for anything.
- *
- *
- '256': 'ImageWidth',
- '257': 'ImageHeight',
- '40962': 'PixelXDimension',
- '40963': 'PixelYDimension',
- '306': 'DateTime',
- '315': 'Artist',
- '33432': 'Copyright',
- '36867': 'DateTimeOriginal',
- '33434': 'ExposureTime',
- '33437': 'FNumber',
- '34850': 'ExposureProgram',
- '34867': 'ISOSpeed',
- '37377': 'ShutterSpeedValue',
- '37378': 'ApertureValue',
- '37379': 'BrightnessValue',
- '37380': 'ExposureBiasValue',
- '37382': 'SubjectDistance',
- '37383': 'MeteringMode',
- '37384': 'LightSource',
- '37385': 'Flash',
- '37386': 'FocalLength',
- '41986': 'ExposureMode',
- '41987': 'WhiteBalance',
- '41991': 'GainControl',
- '41992': 'Contrast',
- '41993': 'Saturation',
- '41994': 'Sharpness',
- // These are special tags that we handle internally
- '34665': 'EXIFIFD', // Offset of EXIF data
- '34853': 'GPSIFD', // Offset of GPS data
- */
- '274' : 'ORIENTATION',
- '513': 'THUMBNAIL', // Offset of thumbnail
- '514': 'THUMBNAILLENGTH' // Length of thumbnail
- };
-
- function parseEntry(data, offset, byteorder, exif) {
- var tag = data.getUint16(offset, byteorder);
- var tagname = tagnames[tag];
-
- // If we don't know about this tag type or already processed it, skip it
- if (!tagname || exif[tagname]) {
- return;
- }
-
- var type = data.getUint16(offset + 2, byteorder);
- var count = data.getUint32(offset + 4, byteorder);
-
- var total = count * typesize[type];
- var valueOffset = total <= 4 ? offset + 8 :
- data.getUint32(offset + 8, byteorder) + TIFFHeaderOffset;
- exif[tagname] = parseValue(data, valueOffset, type, count, byteorder);
- }
-
- function parseValue(data, offset, type, count, byteorder) {
- var i;
- if (type === 2) { // ASCII string
- var codes = [];
- for (i = 0; i < count - 1; i++) {
- codes[i] = data.getUint8(offset + i);
- }
- return String.fromCharCode.apply(String, codes);
- } else {
- if (count == 1) {
- return parseOneValue(data, offset, type, byteorder);
- } else {
- var values = [];
- var size = typesize[type];
- for (i = 0; i < count; i++) {
- values[i] = parseOneValue(data, offset + size * i, type, byteorder);
- }
- return values;
- }
- }
- }
-
- function parseOneValue(data, offset, type, byteorder) {
- switch (type) {
- case 1: // BYTE
- case 7: // UNDEFINED
- return data.getUint8(offset);
- case 2: // ASCII
- // This case is handed in parseValue
- return null;
- case 3: // SHORT
- return data.getUint16(offset, byteorder);
- case 4: // LONG
- return data.getUint32(offset, byteorder);
- case 5: // RATIONAL
- return data.getUint32(offset, byteorder) /
- data.getUint32(offset + 4, byteorder);
- case 6: // SBYTE
- return data.getInt8(offset);
- case 8: // SSHORT
- return data.getInt16(offset, byteorder);
- case 9: // SLONG
- return data.getInt32(offset, byteorder);
- case 10: // SRATIONAL
- return data.getInt32(offset, byteorder) /
- data.getInt32(offset + 4, byteorder);
- case 11: // FLOAT
- return data.getFloat32(offset, byteorder);
- case 12: // DOUBLE
- return data.getFloat64(offset, byteorder);
- }
- return null;
- }
-}
-
-'use strict';
-/* global BlobView */
-/* exported getVideoRotation */
-
-//
-// Given an MP4/Quicktime based video file as a blob, read through its
-// atoms to find the track header "tkhd" atom and extract the rotation
-// matrix from it. Convert the matrix value to rotation in degrees and
-// pass that number to the specified callback function. If no value is
-// found but the video file is valid, pass null to the callback. If
-// any errors occur, pass an error message (a string) callback.
-//
-// See also:
-// http://androidxref.com/4.0.4/xref/frameworks/base/media/libstagefright/MPEG4Writer.cpp
-// https://developer.apple.com/library/mac/#documentation/QuickTime/QTFF/QTFFChap2/qtff2.html
-//
-function getVideoRotation(blob, rotationCallback) {
-
- // A utility for traversing the tree of atoms in an MP4 file
- function MP4Parser(blob, handlers) {
- // Start off with a 1024 chunk from the start of the blob.
- BlobView.get(blob, 0, Math.min(1024, blob.size), function(data, error) {
- // Make sure that the blob is, in fact, some kind of MP4 file
- if (data.byteLength <= 8 || data.getBinaryText(4, 4) !== 'ftyp') {
- handlers.errorHandler('not an MP4 file');
- return;
- }
- parseAtom(data);
- });
-
- // Call this with a BlobView object that includes the first 16 bytes of
- // an atom. It doesn't matter whether the body of the atom is included.
- function parseAtom(data) {
- var offset = data.sliceOffset + data.viewOffset; // atom position in blob
- var size = data.readUnsignedInt(); // atom length
- var type = data.readBinaryText(4); // atom type
- var contentOffset = 8; // position of content
-
- if (size === 0) {
- // Zero size means the rest of the file
- size = blob.size - offset;
- }
- else if (size === 1) {
- // A size of 1 means the size is in bytes 8-15
- size = data.readUnsignedInt() * 4294967296 + data.readUnsignedInt();
- contentOffset = 16;
- }
-
- var handler = handlers[type] || handlers.defaultHandler;
- if (typeof handler === 'function') {
- // If the handler is a function, pass that function a
- // DataView object that contains the entire atom
- // including size and type. Then use the return value
- // of the function as instructions on what to do next.
- data.getMore(data.sliceOffset + data.viewOffset, size, function(atom) {
- // Pass the entire atom to the handler function
- var rv = handler(atom);
-
- // If the return value is 'done', stop parsing.
- // Otherwise, continue with the next atom.
- // XXX: For more general parsing we need a way to pop some
- // stack levels. A return value that is an atom name should mean
- // pop back up to this atom type and go on to the next atom
- // after that.
- if (rv !== 'done') {
- parseAtomAt(data, offset + size);
- }
- });
- }
- else if (handler === 'children') {
- // If the handler is this string, then assume that the atom is
- // a container atom and do its next child atom next
- var skip = (type === 'meta') ? 4 : 0; // special case for meta atoms
- parseAtomAt(data, offset + contentOffset + skip);
- }
- else if (handler === 'skip' || !handler) {
- // Skip the atom entirely and go on to the next one.
- // If there is no next one, call the eofHandler or just return
- parseAtomAt(data, offset + size);
- }
- else if (handler === 'done') {
- // Stop parsing
- return;
- }
- }
-
- function parseAtomAt(data, offset) {
- if (offset >= blob.size) {
- if (handlers.eofHandler) {
- handlers.eofHandler();
- }
- return;
- }
- else {
- data.getMore(offset, 16, parseAtom);
- }
- }
- }
-
- // We want to loop through the top-level atoms until we find the 'moov'
- // atom. Then, within this atom, there are one or more 'trak' atoms.
- // Each 'trak' should begin with a 'tkhd' atom. The tkhd atom has
- // a transformation matrix at byte 48. The matrix is 9 32 bit integers.
- // We'll interpret those numbers as a rotation of 0, 90, 180 or 270.
- // If the video has more than one track, we expect all of them to have
- // the same rotation, so we'll only look at the first 'trak' atom that
- // we find.
- MP4Parser(blob, {
- errorHandler: function(msg) { rotationCallback(msg); },
- eofHandler: function() { rotationCallback(null); },
- defaultHandler: 'skip', // Skip all atoms other than those listed below
- moov: 'children', // Enumerate children of the moov atom
- trak: 'children', // Enumerate children of the trak atom
- tkhd: function(data) { // Pass the tkhd atom to this function
- // The matrix begins at byte 48
- data.advance(48);
-
- var a = data.readUnsignedInt();
- var b = data.readUnsignedInt();
- data.advance(4); // we don't care about this number
- var c = data.readUnsignedInt();
- var d = data.readUnsignedInt();
-
- if (a === 0 && d === 0) { // 90 or 270 degrees
- if (b === 0x00010000 && c === 0xFFFF0000) {
- rotationCallback(90);
- }
- else if (b === 0xFFFF0000 && c === 0x00010000) {
- rotationCallback(270);
- }
- else {
- rotationCallback('unexpected rotation matrix');
- }
- }
- else if (b === 0 && c === 0) { // 0 or 180 degrees
- if (a === 0x00010000 && d === 0x00010000) {
- rotationCallback(0);
- }
- else if (a === 0xFFFF0000 && d === 0xFFFF0000) {
- rotationCallback(180);
- }
- else {
- rotationCallback('unexpected rotation matrix');
- }
- }
- else {
- rotationCallback('unexpected rotation matrix');
- }
- return 'done';
- }
- });
-}
-
-/* exported getImageSize */
-/* global BlobView */
-/* global parseJPEGMetadata */
-
-/*
- * Determine the pixel dimensions of an image without actually
- * decoding the image. Passes an object of metadata to the callback
- * function on success or an error message to the error function on
- * failure. The metadata object will include type, width and height
- * properties. Supported image types are GIF, PNG and JPEG. JPEG
- * metadata may also include information about an EXIF preview image.
- *
- * Because of shortcomings in the way Gecko handles images, the
- * Gallery app will crash with an OOM error if it attempts to decode
- * and display an image that is too big. Images require 4 bytes per
- * pixel, so a 10 megapixel photograph requires 40 megabytes of image
- * memory. This function gives the gallery app a way to reject images
- * that are too large.
- *
- * Requires the BlobView class from shared/js/blobview.js and the
- * parseJPEGMetadata() function from shared/js/media/jpeg_metadata_parser.js
- */
-function getImageSize(blob, callback, error) {
- 'use strict';
-
- BlobView.get(blob, 0, Math.min(1024, blob.size), function(data) {
- // Make sure we are at least 8 bytes long before reading the first 8 bytes
- if (data.byteLength <= 8) {
- error('corrupt image file');
- return;
- }
- var magic = data.getBinaryText(0, 8);
- if (magic.substring(0, 4) === 'GIF8') {
- try {
- callback({
- type: 'gif',
- width: data.getUint16(6, true),
- height: data.getUint16(8, true)
- });
- }
- catch (e) {
- error(e.toString());
- }
- }
- else if (magic.substring(0, 8) === '\x89PNG\r\n\x1A\n') {
- try {
- callback({
- type: 'png',
- width: data.getUint32(16, false),
- height: data.getUint32(20, false)
- });
- }
- catch (e) {
- error(e.toString());
- }
- }
- else if (magic.substring(0, 2) === 'BM' &&
- data.getUint32(2, true) === blob.size) {
- // This is a BMP file
- try {
- var width, height;
-
- if (data.getUint16(14, true) === 12) { // check format version
- width = data.getUint16(18, true); // 16-bit little endian width
- height = data.getUint16(20, true); // 16-bit little endian height
- }
- else { // newer versions of the format use 32-bit ints
- width = data.getUint32(18, true); // 32-bit little endian width
- height = data.getUint32(22, true); // 32-bit little endian height
- }
-
- callback({
- type: 'bmp',
- width: width,
- height: height
- });
- }
- catch (e) {
- error(e.toString());
- }
- }
- else if (magic.substring(0, 2) === '\xFF\xD8') {
- parseJPEGMetadata(blob,
- function(metadata) {
- if (metadata.progressive) {
- // If the jpeg parser tells us that this is
- // a progressive jpeg, then treat that as a
- // distinct image type because pjpegs have
- // such different memory requirements than
- // regular jpegs.
- delete metadata.progressive;
- metadata.type = 'pjpeg';
- }
- else {
- metadata.type = 'jpeg';
- }
- callback(metadata);
- },
- error);
- }
- else {
- error('unknown image type');
- }
- });
-}
-
-'use strict';
-/* global
- CONFIG_MAX_IMAGE_PIXEL_SIZE,
- CONFIG_REQUIRED_EXIF_PREVIEW_HEIGHT,
- CONFIG_REQUIRED_EXIF_PREVIEW_WIDTH,
- cropResizeRotate,
- Downsample,
- getImageSize,
- getVideoRotation,
- isPhone,
- parseJPEGMetadata,
- videostorage
-*/
-/* exported
- metadataParser
-*/
-
-//
-// This file defines a single metadataParsers object. The two
-// properties of this object are metadata parsing functions for image
-// files and video files, intended for use with the MediaDB class.
-//
-// This file depends on JPEGMetadataParser.js and blobview.js
-//
-var metadataParser = (function() {
- // If we generate our own thumbnails, aim for this size.
- // Calculate needed size from longer side of the screen.
- var THUMBNAIL_WIDTH = computeThumbnailWidth();
- var THUMBNAIL_HEIGHT = THUMBNAIL_WIDTH;
-
- function computeThumbnailWidth() {
- // Make sure this works regardless of current device orientation
- var width = window.innerWidth || screen.width;
- var height = window.innerHeight || screen.height;
- var portraitWidth = Math.max(1, Math.min(width, height));
- var landscapeWidth = Math.max(1, Math.max(width, height));
- var thumbnailsPerRowPortrait = isPhone ? 3 : 4;
- var thumbnailsPerRowLandscape = isPhone ? 4 : 6;
- return Math.round(window.devicePixelRatio *
- Math.max(portraitWidth / thumbnailsPerRowPortrait,
- landscapeWidth / thumbnailsPerRowLandscape));
- }
-
- var thumbnailSize = {
- width: THUMBNAIL_WIDTH,
- height: THUMBNAIL_HEIGHT
- };
-
- // Don't try to decode image files of unknown type if bigger than this
- var MAX_UNKNOWN_IMAGE_FILE_SIZE = 0.5 * 1024 * 1024; // half a megabyte
-
- // If a jpeg does not have a suitable EXIF preview and has more than
- // this number of pixels, then we create an external preview for it
- var MAX_PREVIEWLESS_JPEG_SIZE = 5 * 1024 * 1024; // 5 megapixels
-
- // An element for loading images
- var offscreenImage = new Image();
-
- // The screen size. Preview images must be at least this big
- // Note that we use screen.width instead of window.innerWidth because
- // the window size is different when we are invoked for a pick activity
- // (non-fullscreen) and when we are invoked normally (fullscreen).
- var sw = screen.width * window.devicePixelRatio;
- var sh = screen.height * window.devicePixelRatio;
-
- // Create a thumbnail size canvas, copy the or into it
- // cropping the edges as needed to make it fit, and then extract the
- // thumbnail image as a blob and pass it to the callback.
- // This utility function is used by both the image and video metadata parsers
- function createThumbnailFromElement(elt, video, rotation,
- mirrored, callback, error) {
- try {
- // Create a thumbnail image
- var canvas = document.createElement('canvas');
- canvas.width = THUMBNAIL_WIDTH;
- canvas.height = THUMBNAIL_HEIGHT;
- var context = canvas.getContext('2d', { willReadFrequently: true });
- var eltwidth = elt.width;
- var eltheight = elt.height;
- var scalex = canvas.width / eltwidth;
- var scaley = canvas.height / eltheight;
-
- // Take the larger of the two scales: we crop the image to the thumbnail
- var scale = Math.max(scalex, scaley);
-
- // Calculate the region of the image that will be copied to the
- // canvas to create the thumbnail
- var w = Math.round(THUMBNAIL_WIDTH / scale);
- var h = Math.round(THUMBNAIL_HEIGHT / scale);
- var x = Math.round((eltwidth - w) / 2);
- var y = Math.round((eltheight - h) / 2);
-
- var centerX = Math.floor(THUMBNAIL_WIDTH / 2);
- var centerY = Math.floor(THUMBNAIL_HEIGHT / 2);
-
- // If a orientation is specified, rotate/mirroring the canvas context.
- if (rotation || mirrored) {
- context.save();
- // All transformation are applied to the center of the thumbnail.
- context.translate(centerX, centerY);
- }
- if (mirrored) {
- context.scale(-1, 1);
- }
- if (rotation) {
- switch (rotation) {
- case 90:
- context.rotate(Math.PI / 2);
- break;
- case 180:
- context.rotate(Math.PI);
- break;
- case 270:
- context.rotate(-Math.PI / 2);
- break;
- }
- }
-
- if (rotation || mirrored) {
- context.translate(-centerX, -centerY);
- }
-
- // Draw that region of the image into the canvas, scaling it down
- context.drawImage(elt, x, y, w, h,
- 0, 0, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT);
-
- // Restore the default rotation so the play arrow comes out correctly
- if (rotation || mirrored) {
- context.restore();
- }
-
- // If this is a video, superimpose a translucent play button over
- // the captured video frame to distinguish it from a still photo thumbnail
- if (video) {
- // First draw a transparent gray circle
- context.fillStyle = 'rgba(0, 0, 0, .2)';
- context.beginPath();
- context.arc(THUMBNAIL_WIDTH / 2, THUMBNAIL_HEIGHT / 2,
- THUMBNAIL_HEIGHT / 5, 0, 2 * Math.PI, false);
- context.fill();
-
- // Now outline the circle in white
- context.strokeStyle = 'rgba(255,255,255,.6)';
- context.lineWidth = 2;
- context.stroke();
-
- // And add a white play arrow.
- context.beginPath();
- context.fillStyle = 'rgba(255,255,255,.6)';
- // The height of an equilateral triangle is sqrt(3)/2 times the side
- var side = THUMBNAIL_HEIGHT / 5;
- var triangle_height = side * Math.sqrt(3) / 2;
- context.moveTo(THUMBNAIL_WIDTH / 2 + triangle_height * 2 / 3,
- THUMBNAIL_HEIGHT / 2);
- context.lineTo(THUMBNAIL_WIDTH / 2 - triangle_height / 3,
- THUMBNAIL_HEIGHT / 2 - side / 2);
- context.lineTo(THUMBNAIL_WIDTH / 2 - triangle_height / 3,
- THUMBNAIL_HEIGHT / 2 + side / 2);
- context.closePath();
- context.fill();
- }
-
- canvas.toBlob(function(blob) {
- context = null;
- canvas.width = canvas.height = 0;
- canvas = null;
- callback(blob);
- }, 'image/jpeg');
- } catch (ex) {
- // An error may be thrown when the drawImage decodes a broken/trancated
- // image.
- // The elt may be a offscreen image. So, the image metadata is parsed, and
- // the image data is loaded but not decoded. The drawImage triggers the
- // image decoder to decode the image data. And an error may be thrown.
- error('createThumbnailFromElement:' + ex.message);
- }
- }
-
- var VIDEOFILE = /DCIM\/\d{3}MZLLA\/VID_\d{4}\.jpg/;
-
- function metadataParser(file, metadataCallback, metadataError, bigFile) {
- // If the file is a poster image for a video file, then we've want
- // video metadata, not image metadata
- if (VIDEOFILE.test(file.name)) {
- videoMetadataParser(file, metadataCallback, metadataError);
- return;
- }
-
- // Figure out how big the image is if we can. For JPEG files this
- // calls the JPEG parser and returns the EXIF preview if there is one.
- getImageSize(file, gotImageSize, gotImageSizeError);
-
- function gotImageSizeError(errmsg) {
- // The image is not a JPEG, PNG, GIF or BMP file. We may still be
- // able to decode and display it but we don't know the image
- // size, so we won't even try if the file is too big.
- if (file.size > MAX_UNKNOWN_IMAGE_FILE_SIZE) {
- metadataError('Ignoring large file ' + file.name);
- return;
- }
-
- // If the file is too small to be an image, ignore it
- if (file.size < 32) {
- metadataError('Ignoring small file ' + file.name);
- return;
- }
-
- // If the error message is anything other than unknown image type
- // it means we've got a corrupt image file, or the image metdata parser
- // can't handle the file for some reason. Log a warning but keep going
- // in case the image is good and the metadata parser is buggy.
- if (errmsg !== 'unknown image type') {
- console.warn('getImageSize', errmsg, file.name);
- }
-
- // If it is not too big create a preview and thumbnail.
- createThumbnailAndPreview(file,
- metadataCallback,
- metadataError,
- false,
- bigFile,
- {});
- }
-
- function gotImageSize(metadata) {
- //
- // If the image is too big, reject it now so we don't have
- // memory trouble later.
- //
- // CONFIG_MAX_IMAGE_PIXEL_SIZE is maximum image resolution we
- // can handle. It's from config.js which is generated at build
- // time (see build/application-data.js).
- //
- // For jpeg images we can downsample while decoding which means that
- // we can handle images that are quite a bit larger.
- //
- // getImageSize() distinguishes pjpeg files from jpeg, because
- // progressive jpeg files are quite different than regular jpegs.
- // Not only do pjpegs not benefit from using #-moz-samplesize, they
- // actually require more memory to decode than other images of the same
- // resolution, so we actually need to reduce the image size limit for
- // this image type.
- //
- var imagesizelimit = CONFIG_MAX_IMAGE_PIXEL_SIZE;
- if (metadata.type === 'jpeg') {
- imagesizelimit *= Downsample.MAX_AREA_REDUCTION;
- }
- else if (metadata.type === 'pjpeg') {
- // As a semi-educated guess, we'll say that we can handle pjpegs
- // 2/3rds the size of the largest PNG we can handle.
- imagesizelimit *= 2 / 3;
- }
-
- //
- // Even if we can downsample an image while decoding it, we still
- // have to read the entire image file. If the file is particularly
- // large we might also have memory problems. (See bug 1008834: a 20mb
- // 80mp jpeg file will cause an OOM on Tarako even though we can
- // decode it at < 2mp). Rather than adding another build-time config
- // variable to specify the maximum file size, however, we'll just
- // base the file size limit on CONFIG_MAX_IMAGE_PIXEL_SIZE.
- // So if that variable is set to 2M, then we might use up to 12Mb of
- // memory. 2 * 2M bytes for the image file and 4 bytes times 2M pixels
- // for the decoded image. A 4mb file size limit should accomodate
- // most JPEG files up to 12 or 16 megapixels.
- //
- // Note that this size limit is just a guess. If we continue to see
- // OOMs with large files then we should change the number 2 below
- // to something smaller.
- //
- var filesizelimit = 2 * CONFIG_MAX_IMAGE_PIXEL_SIZE;
-
- if (metadata.width * metadata.height > imagesizelimit ||
- file.size > filesizelimit) {
- metadataError('Ignoring high-resolution image ' + file.name);
- return;
- }
-
- // If the file included a preview image, see if it is big enough
- if (metadata.preview) {
- // Create a blob that is just the preview image
- var previewblob = file.slice(metadata.preview.start,
- metadata.preview.end,
- 'image/jpeg');
-
- // Check to see if the preview is big enough to use in MediaFrame
- parseJPEGMetadata(previewblob, previewsuccess, previewerror);
- }
- else {
- // If there wasn't a preview image, then generate a preview and
- // thumbnail from the full size image.
- useFullsizeImage();
- }
-
- function previewerror(msg) {
- // The preview isn't a valid jpeg file, so use the full image to
- // create a preview and a thumbnail
- console.error(msg);
- useFullsizeImage();
- }
-
- // If we can't find a valid embedded EXIF preview image then we come here
- function useFullsizeImage() {
- // If the full size image is a JPEG then we'll just skip the
- // preview generation because we can downsample and decode it
- // at preview size when needed. So in this case, we just need
- // to create a thumbnail for the image. On the other hand, if
- // this is not a JPEG image then we have to create both a
- // thumbnail and an external preview image. Note that progressive
- // jpegs will have a type 'pjpeg' and will be handled below
- // like png images.
-
- // For jpeg images with image sizes more than 5MP, we need
- // to create preview on sdcard to avoid flash while opening image.
- // See bug 1181290.
- // This constraint can be removed when gecko supports downscale during
- // decode by canvas size for offscreen images. This will
- // facilitate removing #moz-sample-size and using ddd.
- // See bug 1206206.
-
- if (metadata.type === 'jpeg') {
- if (metadata.width * metadata.height <= MAX_PREVIEWLESS_JPEG_SIZE) {
- // If the jpeg image isn't too big we don't need a preview
- // because we can decode it quickly enough at screen size.
- // So in this case we just need to create a thumbnail.
- cropResizeRotate(file, null, thumbnailSize, null, metadata,
- function gotThumbnail(error, thumbnailBlob) {
- if (error) {
- var msg = 'Failed to create thumbnail for' +
- file.name;
- console.error(msg);
- metadataError(msg);
- return;
- }
-
- metadata.preview = null;
- metadata.thumbnail = thumbnailBlob;
- metadataCallback(metadata);
- });
- }
- else {
- // Otherwise, this is a high-resolution jpeg that does not
- // have an EXIF preview big enough for the screen. We need
- // to create both a thumbnail and a preview image but we can
- // do that with efficient downsampling using cropResizeRotate().
- createJPEGThumbnailAndPreview(file, metadata,
- metadataCallback,
- metadataError);
- }
- }
- else {
- // In this case we have no preview and the image is not a jpeg
- // that can be downsampled with #-moz-samplesize
- createThumbnailAndPreview(file,
- metadataCallback,
- metadataError,
- false,
- bigFile,
- metadata);
- }
- }
-
- function previewsuccess(previewmetadata) {
- // Size of the preview image
- var pw = previewmetadata.width;
- var ph = previewmetadata.height;
- // optional configuration specifying minimum size
- var mw = CONFIG_REQUIRED_EXIF_PREVIEW_WIDTH;
- var mh = CONFIG_REQUIRED_EXIF_PREVIEW_HEIGHT;
-
- var bigenough;
-
- // If config.js specifies a minimum required preview size,
- // then this preview is big enough if both dimensions are
- // larger than that configured minimum. Otherwise, the preview
- // is big enough if at least one dimension is >= the screen
- // size in both portait and landscape mode.
- if (mw && mh) {
- bigenough =
- Math.max(pw, ph) >= Math.max(mw, mh) &&
- Math.min(pw, ph) >= Math.min(mw, mh);
- }
- else {
- bigenough = (pw >= sw || ph >= sh) && (pw >= sh || ph >= sw);
- }
-
- // Does the aspect ratio of the preview match the aspect ratio of
- // the full-size image? If not we have to assume it is distorted
- // and should not be used.
- var aspectRatioMatches =
- Math.abs(pw / ph - metadata.width / metadata.height) < 0.01;
-
- // If the preview is good, use it to create a thumbnail.
- if (bigenough && aspectRatioMatches) {
- metadata.preview.width = pw;
- metadata.preview.height = ph;
-
- // This is the most common case. We've got a JPEG image with a
- // JPEG preview. All we need is a thumbnail, and we can create that
- // most efficiently with cropResizeRotate which will use
- // #-moz-samplesize to save memory while decoding the image.
- // Note that we apply the EXIF orientation information from
- // the full size image to the preview image.
- previewmetadata.rotation = metadata.rotation;
- previewmetadata.mirrored = metadata.mirrored;
- cropResizeRotate(previewblob, null, thumbnailSize, null,
- previewmetadata,
- function gotThumbnailBlob(error, thumbnailBlob) {
- if (error) {
- previewerror(error);
- return;
- }
- metadata.thumbnail = thumbnailBlob;
- metadataCallback(metadata);
- });
- } else {
- // Preview isn't big enough so get one the hard way
- useFullsizeImage();
- }
- }
- }
- }
-
- // This function is called for JPEG images that do not have a suitable
- // EXIF preview image and that have more than MAX_PREVIEWLESS_JPEG_SIZE
- // pixels. It creates and saves a preview image and a thumbnail.
- // Unlike createThumbnailAndPreview(), it uses cropResizeRotate() which
- // downsamples jpeg images while decoding them to save memory.
- function createJPEGThumbnailAndPreview(file, metadata,
- metadataCallback, metadataError)
- {
-
- // Figure out the preview size.
- var scale = Math.max(Math.min(sw / metadata.width,
- sh / metadata.height, 1),
- Math.min(sh / metadata.width,
- sw / metadata.height, 1));
- var previewSize = {
- width: Math.round(metadata.width * scale),
- height: Math.round(metadata.height * scale)
- };
-
- // Create a preview image.
- // Note that we can't use the full metadata object here
- // because we don't want the preview image to be rotated.
- // We want the same orientation as the original image
- cropResizeRotate(file, null, previewSize, null,
- { width: metadata.width, height: metadata.height },
- function gotPreview(error, previewBlob) {
- if (error) {
- var msg = 'Failed to create preview for' +
- file.name;
- console.error(msg);
- metadataError(msg);
- return;
- }
-
- // Save this preview to the sdcard
- savePreview(file, previewBlob, metadataError, function(filename) {
- // Record the preview image in the metadata
- metadata.preview = {
- filename: filename,
- width: previewSize.width,
- height: previewSize.height
- };
-
- // Now create the thumbnail from this smaller preview image
- // In this case we need to pass the size of the preview
- // and the rotation of the original image.
- cropResizeRotate(previewBlob, null, thumbnailSize, null,
- {
- width: previewSize.width,
- height: previewSize.height,
- rotation: metadata.rotation
- },
- function gotThumbnail(error, thumbnailBlob) {
- // If thumbnail creation failed report the error
- if (error) {
- metadataError(error);
- return;
- }
-
- // Otherwise, remember thumbnail and we're done
- metadata.thumbnail = thumbnailBlob;
- metadataCallback(metadata);
- });
- });
- });
- }
-
- // Load an image from a file into an tag, and then use that
- // to get its dimensions and create a thumbnail. Store these values in
- // a metadata object, and pass the object to the callback function.
- // If anything goes wrong, pass an error message to the error function.
- // If it is a large image, create and save a preview for it as well.
- //
- // We used to call this function for every image. Now that gecko supports
- // the #-moz-samplesize media fragment, however, we handle jpeg images
- // with cropResizeRotate() for memory efficiency, and this function is
- // used only for non-jpeg files.
- function createThumbnailAndPreview(file, callback, error, nopreview,
- bigFile, metadata) {
- var url = URL.createObjectURL(file);
- offscreenImage.src = url;
-
- offscreenImage.onerror = function() {
- URL.revokeObjectURL(url);
- offscreenImage.onload = offscreenImage.onerror = null; offscreenImage.removeAttribute('src');
- error('createThumbnailAndPreview: Image failed to load');
- };
-
- offscreenImage.onload = function() {
- URL.revokeObjectURL(url);
-
- var iw = offscreenImage.width;
- var ih = offscreenImage.height;
-
- // Don't overwrite the metadata in the case we read a previewblob.
- if (!nopreview) {
- metadata.width = iw;
- metadata.height = ih;
- }
-
- // If this is a big image, then decoding it takes a lot of memory.
- // We set this flag to prevent the user from zooming in on other
- // images at the same time because that also takes a lot of memory
- // and we don't want to crash with an OOM. If we find one big image
- // we assume that there may be others, so the flag remains set until
- // the current scan is complete.
- //
- // XXX: When bug 854795 is fixed, we'll be able to create previews
- // for large images without using so much memory, and we can remove
- // this flag then.
- if (iw * ih > 2 * 1024 * 1024 && bigFile) {
- bigFile();
- }
-
- // If the image was already thumbnail size, it is its own thumbnail
- // and it does not need a preview
- if (metadata.width <= THUMBNAIL_WIDTH &&
- metadata.height <= THUMBNAIL_HEIGHT) {
- offscreenImage.onload = offscreenImage.onerror = null; offscreenImage.removeAttribute('src');
- metadata.thumbnail = file;
- callback(metadata);
- }
- else {
- createThumbnailFromElement(
- offscreenImage,
- false,
- metadata.rotation || 0,
- metadata.mirrored || false,
- gotThumbnail,
- error);
- }
-
- function gotThumbnail(thumbnail) {
- metadata.thumbnail = thumbnail;
- // If no preview was requested, or if if the image was less
- // than half a megapixel then it does not need a preview
- // image, and we can call the callback right away
- if (nopreview || metadata.width * metadata.height < 512 * 1024) {
- offscreenImage.onload = offscreenImage.onerror = null; offscreenImage.removeAttribute('src');
- callback(metadata);
- }
- else {
- // Otherwise, this was a big image and we need to create a
- // preview for it so we can avoid decoding the full size
- // image again when possible
- createAndSavePreview();
- }
- }
-
- function createAndSavePreview() {
- // Figure out the preview size.
- // Make sure the size is big enough for both landscape and portrait
- var scale = Math.max(Math.min(sw / iw, sh / ih, 1),
- Math.min(sh / iw, sw / ih, 1));
- // preview width and height
- var pw = Math.round(iw * scale);
- var ph = Math.round(ih * scale);
-
- // Create the preview in a canvas
- var canvas = document.createElement('canvas');
- canvas.width = pw;
- canvas.height = ph;
- var context = canvas.getContext('2d', { willReadFrequently: true });
- context.drawImage(offscreenImage, 0, 0, iw, ih, 0, 0, pw, ph);
- canvas.toBlob(function(blob) {
- offscreenImage.onload = offscreenImage.onerror = null; offscreenImage.removeAttribute('src');
- canvas.width = canvas.height = 0;
- savePreview(file, blob, error, function(filename) {
- metadata.preview = {
- filename: filename,
- width: pw,
- height: ph
- };
- callback(metadata);
- });
- }, 'image/jpeg');
- }
- };
- }
-
- // Given an image file and a preview blob for it, this function saves
- // the preview to a file in .gallery/previews/ and then passes the filename
- // to the specified callback. Calls errorCallback if an error occurs.
- // This is used by createThumbnailAndPreview() and by
- // createJPEGThumbnailAndPreview().
- function savePreview(file, previewblob, errorCallback, filenameCallback) {
- var storage = navigator.getDeviceStorage('pictures');
- var filename;
- if (file.name[0] === '/') {
- // We expect file.name to be a fully qualified name (perhaps
- // something like /sdcard/DCIM/100MZLLA/IMG_0001.jpg).
- var slashIndex = file.name.indexOf('/', 1);
- if (slashIndex < 0) {
- errorCallback('savePreview: Bad filename: \'' + file.name + '\'');
- return;
- }
- filename =
- file.name.substring(0, slashIndex) + // storageName (i.e. /sdcard)
-
- '/.gallery/previews' +
- file.name.substring(slashIndex); // rest of path (i,e, /DCIM/...)
- } else {
- // On non-composite storage areas (e.g. desktop), file.name will be
- // a relative path.
- filename = '.gallery/previews/' + file.name;
- }
-
- // Add a '.jpeg' extension to all preview files. This is necessary
- // because all previews get saved as jpegs even if the original
- // image is not a jpeg. But DeviceStorage uses the filename extension
- // to determine the file type.
- filename += '.jpeg';
-
- // Delete any existing preview by this name
- var delreq = storage.delete(filename);
- delreq.onsuccess = delreq.onerror = save;
-
- function save() {
- var savereq = storage.addNamed(previewblob, filename);
- savereq.onerror = function() {
- console.error('Could not save preview image', filename);
- };
-
- // Don't actually wait for the save to complete.
- filenameCallback(filename);
- }
- }
-
- function videoMetadataParser(file, metadataCallback, errorCallback) {
- var metadata = {};
- var extensions = ['webm', '3gp', 'mp4'];
- function findVideo() {
- var videofilename = file.name.replace(/\.jpg$/, '.' + extensions.shift());
- var getreq = videostorage.get(videofilename);
- getreq.onerror = function() {
- if (extensions.length) findVideo();
- else errorCallback('cannot get video file: ' + videofilename);
- };
- getreq.onsuccess = function() {
- metadata.video = videofilename;
- if (/\.webm$/.test(videofilename)) {
- metadata.rotation = 0;
- getVideoThumbnailAndSize();
- return;
- }
- getVideoRotation(getreq.result, function(rotation) {
- if (typeof rotation === 'number') {
- metadata.rotation = rotation;
- getVideoThumbnailAndSize();
- } else errorCallback('Video rotation: ' + rotation);
- });
- };
- }
- findVideo();
-
-function getVideoThumbnailAndSize() {
- var url = URL.createObjectURL(file);
- offscreenImage.src = url;
-
- offscreenImage.onerror = function() {
- URL.revokeObjectURL(url);
- offscreenImage.onload = offscreenImage.onerror = null; offscreenImage.removeAttribute('src');
- errorCallback('getVideoThumanailAndSize: Image failed to load');
- };
-
- offscreenImage.onload = function() {
- URL.revokeObjectURL(url);
-
- // We store the unrotated size of the poster image. The video
- // itself might be larger or smaller than this, but it will
- // have the same orientation and aspect ratio.
- metadata.width = offscreenImage.width;
- metadata.height = offscreenImage.height;
-
- createThumbnailFromElement(offscreenImage,
- true,
- metadata.rotation,
- false,
- function(thumbnail) {
- metadata.thumbnail = thumbnail;
- offscreenImage.onload = offscreenImage.onerror = null; offscreenImage.removeAttribute('src');
- metadataCallback(metadata);
- },
- errorCallback);
- };
- }
- }
-
- return metadataParser;
-}());
diff --git a/overrides/keyboard.gaiamobile.org/gaia_build_defer_settings.js b/overrides/keyboard.gaiamobile.org/gaia_build_defer_settings.js
deleted file mode 100644
index fb7b4e10..00000000
--- a/overrides/keyboard.gaiamobile.org/gaia_build_defer_settings.js
+++ /dev/null
@@ -1,966 +0,0 @@
-;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
deleted file mode 100644
index f680450b..00000000
--- a/overrides/settings.gaiamobile.org/js/main.js
+++ /dev/null
@@ -1,756 +0,0 @@
-
-require.config({baseUrl:'/js',paths:{'modules':'modules','panels':'panels','shared':'../shared/js','views':'../views'},waitSeconds:0,shim:{'dsds_settings':{exports:'DsdsSettings'},'settings':{exports:'Settings'},'shared/addons/match_pattern':{exports:'MatchPattern'},'shared/airplane_mode_helper':{exports:'AirplaneModeHelper'},'shared/apn_helper':{exports:'ApnHelper'},'shared/async_storage':{exports:'asyncStorage'},'shared/bluetooth_helper':{exports:'BluetoothHelper'},'shared/device_storage/enumerate_all':{exports:'enumerateAll'},'shared/download/download_formatter':{exports:'DownloadFormatter'},'shared/download/download_store':{exports:'DownloadStore'},'shared/download/download_ui':{exports:'DownloadUI'},'shared/download/download_helper':{exports:'DownloadHelper'},'shared/fxa_iac_client':{exports:'FxAccountsIACHelper'},'shared/homescreens/homescreen_settings':{exports:'homescreenSettings'},'shared/icc_helper':{exports:'IccHelper'},'shared/keyboard_helper':{exports:'KeyboardHelper',deps:['shared/input_mgmt/input_app_list']},'shared/language_list':{exports:'LanguageList'},'shared/lazy_loader':{exports:'LazyLoader'},'shared/manifest_helper':{exports:'ManifestHelper'},'shared/mime_mapper':{exports:'MimeMapper'},'shared/mobile_operator':{exports:'MobileOperator'},'shared/omadrm/fl':{exports:'ForwardLock'},'shared/passcode_helper':{exports:'PasscodeHelper'},'shared/sanitizer':{exports:'Sanitizer'},'shared/screen_layout':{exports:'ScreenLayout'},'shared/search_provider':{exports:'SearchProvider'},'shared/settings_helper':{exports:'SettingsHelper'},'shared/settings_listener':{exports:'SettingsListener'},'shared/settings_url':{exports:'SettingsURL'},'shared/sim_settings_helper':{exports:'SimSettingsHelper'},'shared/simslot':{exports:'SIMSlot'},'shared/simslot_manager':{exports:'SIMSlotManager',deps:['shared/simslot']},'shared/stk_helper':{exports:'STKHelper'},'shared/text_normalizer':{exports:'Normalizer'},'shared/toaster':{exports:'Toaster'},'shared/tz_select':{exports:'tzSelect',deps:['shared/icc_helper']},'shared/uuid':{exports:'uuid'},'shared/findmydevice_iac_api':{exports:'wakeUpFindMyDevice'},'shared/wifi_helper':{exports:'WifiHelper'},'vendor/jszip':{exports:'JSZip'}},modules:[{name:'main'},{name:'modules/apn/apn_settings_manager',exclude:['main','modules/async_storage']},{name:'modules/dialog_service',exclude:['main']},{name:'panels/about/panel',exclude:['main']},{name:'panels/about_more_info/panel',exclude:['main','modules/bluetooth/bluetooth_context']},{name:'panels/app_permissions_detail/panel',exclude:['main']},{name:'panels/app_permissions_list/panel',exclude:['main','modules/apps_cache']},{name:'panels/app_storage/panel',exclude:['main','modules/app_storage','modules/storage_helper']},{name:'views/phone/bluetooth/panel',exclude:['main','modules/mvvm/list_view','modules/dialog_service','modules/bluetooth/bluetooth_context']},{name:'panels/browsing_privacy/panel',exclude:['main','modules/dialog_service']},{name:'panels/call_barring/panel',exclude:['main']},{name:'panels/call_barring_passcode_change/panel',exclude:['main']},{name:'panels/date_time/panel',exclude:['main','modules/date_time']},{name:'panels/developer/panel',exclude:['main','modules/dialog_service','modules/apps_cache']},{name:'panels/developer_hud/panel',exclude:['main']},{name:'panels/display/panel',exclude:['main']},{name:'panels/feedback_choose/panel',exclude:['main']},{name:'panels/feedback_send/panel',exclude:['main']},{name:'panels/findmydevice/panel',exclude:['main','modules/settings_utils']},{name:'panels/firefox_accounts/panel',exclude:['main']},{name:'panels/firefox_sync/panel',exclude:['main','modules/settings_utils']},{name:'panels/frame/panel',exclude:['main']},{name:'panels/help/panel',exclude:['main']},{name:'panels/homescreens/panel',exclude:['main','modules/apps_cache']},{name:'panels/hotspot/panel',exclude:['main','modules/dialog_service']},{name:'panels/hotspot_wifi_settings/panel',exclude:['main']},{name:'panels/keyboard/panel',exclude:['main','modules/mvvm/list_view','modules/keyboard_context']},{name:'panels/keyboard_add_layouts/panel',exclude:['main','modules/mvvm/list_view','modules/keyboard_context','shared/keyboard_helper']},{name:'panels/languages/panel',exclude:['main','shared/keyboard_helper','modules/date_time']},{name:'panels/messaging/panel',exclude:['main','modules/messaging','modules/settings_utils']},{name:'panels/messaging_details/panel',exclude:['main','modules/messaging','modules/settings_utils']},{name:'panels/operator_settings/panel',exclude:['main','dsds_settings','modules/defer','modules/state_model','modules/mvvm/list_view','modules/dialog_service','modules/customized_network_type_map','modules/mobile/supported_network_info']},{name:'panels/root/panel',exclude:['main','panels/root/low_priority_items','modules/apps_cache','modules/addon_manager','modules/storage_helper']},{name:'panels/root/low_priority_items',exclude:['main','modules/app_storage','modules/battery','modules/bluetooth/bluetooth_context','modules/media_storage','modules/sim_security','modules/wifi_context']},{name:'panels/screen_lock/panel',exclude:['main']},{name:'panels/screen_lock_passcode/panel',exclude:['main','modules/settings_utils']},{name:'panels/search/panel',exclude:['main']},{name:'panels/simcard_manager/panel',exclude:['main']},{name:'panels/simpin/panel',exclude:['main','modules/sim_security']},{name:'panels/sound/panel',exclude:['main','modules/mobile/supported_network_info']},{name:'panels/usb_storage/panel',exclude:['main','modules/media_storage']},{name:'panels/wifi/panel',exclude:['main','modules/dialog_service']},{name:'panels/wifi_auth/panel',exclude:['main']},{name:'panels/wifi_enter_certificate_nickname/panel',exclude:['main']},{name:'panels/wifi_join_hidden/panel',exclude:['main']},{name:'panels/wifi_manage_certificates/panel',exclude:['main','modules/settings_utils']},{name:'panels/wifi_manage_networks/panel',exclude:['main','modules/dialog_service']},{name:'panels/wifi_select_certificate_file/panel',exclude:['main','modules/settings_utils']},{name:'panels/wifi_status/panel',exclude:['main']},{name:'panels/wifi_wps/panel',exclude:['main']}]});define("config/require",function(){});define('modules/base/module',[],function(){'use strict';const LOG_LEVEL={NONE:0,DEBUG:1,INFO:2,WARN:3,ERROR:4,ALL:5};var _constructorMap=(function(){var _ctorMap=new Map();var _getConstructor=function(Module){return _ctorMap.get(Module)||function(){};};var _registerConstructor=function(Module,constructor){_ctorMap.set(Module,constructor||function(){});};return{getConstructor:_getConstructor,registerConstructor:_registerConstructor};})();var _emptyFunction=function(){};var _createLogger=function(name){switch(name){case'DEBUG':return function(msg){console.log(this._msgPrefix+msg);};case'INFO':return function(msg){console.info(this._msgPrefix+msg);};case'WARN':return function(msg){console.warn(this._msgPrefix+msg);};case'ERROR':return function(msg){console.error(this._msgPrefix+msg);};}};var ModulePrototype={get _msgPrefix(){return'['+this.$name+']: ';},debug:_emptyFunction,info:_emptyFunction,warn:_emptyFunction,error:_emptyFunction,throw:function(msg){throw new Error(this._msgPrefix+msg);},set _logLevel(value){Object.keys(LOG_LEVEL).forEach((name)=>{var level=LOG_LEVEL[name];if(value>=level&&value>0){this[name.toLowerCase()]=_createLogger(name);}});},super:_constructorMap.getConstructor};var _extend=function(){switch(arguments.length){case 0:break;case 1:var Module=arguments[0];for(var prop in Module.prototype){if(prop==='$name'){continue;}
-var pd=Object.getOwnPropertyDescriptor(Module.prototype,prop);if(pd){Object.defineProperty(this.prototype,prop,pd);}}
-break;default:Array.prototype.forEach.call(arguments,(Module)=>{_extend.call(this,Module);});break;}
-return this;};function _create(constructor){if(constructor&&typeof constructor!=='function'){throw new Error('[Module]: Invalid constructor');}
-var ModuleFunc=function(){};ModuleFunc.prototype=Object.create(ModulePrototype);ModuleFunc.prototype.$name=constructor&&constructor.name||'';var Module=function(){var instance=new ModuleFunc();if(constructor){constructor.apply(instance,arguments);}
-return instance;};_constructorMap.registerConstructor(Module,constructor);Module.extend=_extend;Module.prototype=ModuleFunc.prototype;return Module;}
-return{LOG_LEVEL:LOG_LEVEL,create:_create};});define('modules/base/dependency_graph',['require','modules/base/module'],function(require){'use strict';var Module=require('modules/base/module');var DependencyGraph=Module.create(function DependencyGraph(dpGraph){this._nodes=dpGraph?JSON.parse(JSON.stringify(dpGraph._nodes)):{};});DependencyGraph.prototype._printNodes=function dg_printNodes(){Object.keys(this._nodes).forEach((name)=>{var node=this._nodes[name];console.log('name: '+name);console.log('children: '+
-node.children.reduce((result,name)=>{return result+' '+name;},''));console.log('dependent nodes: '+
-node.dependentNodes.reduce((result,name)=>{return result+' '+name;},''));console.log('======================');});};DependencyGraph.prototype._addDependentNodes=function dg_addDependentNodes(node,dependentNodes,traversedNodes){traversedNodes=traversedNodes||[];if(traversedNodes.indexOf(node.name)>=0){traversedNodes.push(node.name);var error=traversedNodes.join(' -> ');this.throw('circular dependency detected! '+error);}
-if(dependentNodes){dependentNodes.forEach((name)=>{if(node.dependentNodes.indexOf(name)<0){node.dependentNodes.push(name);}});}else{dependentNodes=Array.prototype.slice.call(node.dependentNodes);}
-dependentNodes.push(node.name);traversedNodes.push(node.name);node.children.forEach((name)=>{this._addDependentNodes(this._nodes[name],dependentNodes,traversedNodes);});dependentNodes.pop();traversedNodes.pop();};DependencyGraph.prototype.addDependency=function dg_addDep(name1,name2){var node1=this._nodes[name1];var node2=this._nodes[name2];if(!node1){node1=this._nodes[name1]={name:name1,children:[],dependentNodes:[]};}
-if(!node2){node2=this._nodes[name2]={name:name2,children:[],dependentNodes:[]};}
-if(node1.children.indexOf(name2)<0){node1.children.push(name2);this._addDependentNodes(node1);}};DependencyGraph.prototype.getAllDependent=function dg_getAllDep(name){var node=this._nodes[name];return node&&node.dependentNodes;};return DependencyGraph;});define('modules/mvvm/observable',['require','modules/base/module','modules/base/dependency_graph'],function(require){'use strict';var Module=require('modules/base/module');var DependencyGraph=require('modules/base/dependency_graph');var OP_PREFIX=(name)=>{return'$OP_'+name;};var Observable=Module.create(function Observable(object){this._observers={};if(object){this._initWithObject(object);}});Observable.prototype._initWithObject=function o_init(object){for(var name in object){if(typeof object[name]==='function'){this[name]=object[name];}else{_defineObservableProperty(this,name,{value:object[name]});}}};Observable.prototype._notify=function o__notify(name,newValue,oldValue){var observers=this._observers[name];if(observers){observers.forEach(function(observer){observer(newValue,oldValue);});}};Observable.prototype._removeObserver=function o__removeObserver(observer,name){var observers=this._observers[name];if(observers){var index=observers.indexOf(observer);if(index>=0){observers.splice(index,1);}}};Observable.prototype.observe=function o_observe(name,observer){if(typeof observer!=='function'){return;}
-(this._observers[name]=this._observers[name]||[]).push(observer);};Observable.prototype.unobserve=function o_unobserve(name,observer){if(typeof name==='function'){Object.keys(this._observers).forEach(this._removeObserver.bind(this,name));}else{if(observer){this._removeObserver(observer,name);}else if(name in this._observers){this._observers[name]=[];}}};var _dependencyGraphs=new Map();function _getDependencyGraph(modulePrototype){var dependencyGraph=_dependencyGraphs.get(modulePrototype);if(!dependencyGraph){dependencyGraph=DependencyGraph(modulePrototype._dependencyGraph);modulePrototype._dependencyGraph=dependencyGraph;_dependencyGraphs.set(modulePrototype,dependencyGraph);}
-return dependencyGraph;}
-function _getAllDependentValues(observable,sourceProperty){var dependentList=observable._dependencyGraph&&observable._dependencyGraph.getAllDependent(sourceProperty);if(dependentList&&dependentList.length){return dependentList.map((name)=>{return{name:name,value:observable[name]};});}else{return null;}}
-function _getterTemplate(name,defaultValue){return function(){var value=this[OP_PREFIX(name)];if(typeof value==='undefined'){value=this[OP_PREFIX(name)]=defaultValue;}
-return value;};}
-function _setterTemplate(name){return function(value){var oldValue=this[name];if(oldValue!==value){var dependentValues=_getAllDependentValues(this,name);this[OP_PREFIX(name)]=value;this._notify(name,value,oldValue);if(dependentValues){dependentValues.forEach((obj)=>{this._notify(obj.name,this[obj.name],obj.value);});}}};}
-function _defineObservablePropertyCore(object,name,options){var dependency=options&&options.dependency;if(dependency){var dependencyGraph=_getDependencyGraph(object);dependency.forEach((dependentName)=>{dependencyGraph.addDependency(name,dependentName);});}
-Object.defineProperty(object,name,{enumerable:true,get:options.get,set:options.set});}
-function _defineObservableProperty(object,name,options){if(options&&options.readonly){var internalName='_'+name;_defineObservablePropertyCore(object,name,{dependency:[internalName],get:function(){return this[internalName];}});_defineObservablePropertyCore(object,internalName,{get:_getterTemplate(internalName,options&&options.value),set:_setterTemplate(internalName)});}else if(options&&options.dependency&&options.dependency.length){if(typeof options.get!=='function'){throw new Error('Observable: getter of '+name+' is invalid');}
-_defineObservablePropertyCore(object,name,{dependency:options.dependency,get:options.get});}else{_defineObservablePropertyCore(object,name,{get:_getterTemplate(name,options&&options.value),set:_setterTemplate(name)});}}
-Object.defineProperty(Observable,'defineObservableProperty',{get:function(){return _defineObservableProperty;}});return Observable;});define('modules/base/event_emitter',['require','modules/base/module'],function(require){'use strict';var Module=require('modules/base/module');var EventEmitter=Module.create(function EventEmitter(eventNames){if(eventNames&&eventNames.length){this._eventListeners=eventNames.reduce((result,eventName)=>{result[eventName]=[];return result;},{});}else{this.throw('no valid registered events');}});EventEmitter.prototype._emitEvent=function(eventName,value){var listeners=this._eventListeners[eventName];if(!listeners){this.throw('invalid event name: '+eventName);}
-this.debug('_emitEvent:'+eventName+' '+value);var eventObj={type:eventName,detail:value};listeners.forEach((listener)=>{listener.call(this,eventObj);});};EventEmitter.prototype.addEventListener=function(eventName,listener){var listeners=this._eventListeners[eventName];if(listener&&listeners){switch(typeof listener){case'function':break;case'object':if(typeof listener.handleEvent==='function'){listener=listener.handleEvent;}else{listener=null;}
-break;default:listener=null;break;}
-if(listener&&listeners.indexOf(listener)<0){listeners.push(listener);}}else{this.error('addEventListener: invalid listener for '+eventName);}};EventEmitter.prototype.removeEventListener=function(eventName,listener){var listeners=this._eventListeners[eventName];if(listener&&listeners){var index=-1;var type=typeof listener;switch(type){case'function':index=listeners.indexOf(listener);break;case'object':index=listeners.indexOf(listener.handleEvent);break;}
-if(index>=0){listeners.splice(index,1);}}else{this.error('removeEventListener: invalid listener for '+eventName);}};return EventEmitter;});define('modules/mvvm/observable_array',['require','modules/base/module','modules/base/event_emitter','modules/mvvm/observable'],function(require){'use strict';var Module=require('modules/base/module');var EventEmitter=require('modules/base/event_emitter');var Observable=require('modules/mvvm/observable');var Events=['insert','remove','replace','reset'];var ReadOnlyMethods=['forEach','map','every','some','indexOf','lastIndexOf','reduce','reduceRight'];var ObservableArray=Module.create(function ObservableArray(array){this.super(Observable).call(this);this.super(EventEmitter).call(this,Events);this._array=array||[];this._length=this._array.length;}).extend(Observable,EventEmitter);Observable.defineObservableProperty(ObservableArray.prototype,'length',{readonly:true});Observable.defineObservableProperty(ObservableArray.prototype,'array',{readonly:true});ReadOnlyMethods.forEach(function(op){ObservableArray.prototype[op]=function(){return this._array[op].apply(this._array,arguments);};});ObservableArray.prototype.push=function(item){this._array.push(item);this._length=this._array.length;this._emitEvent('insert',{index:this._array.length-1,count:1,items:[item]});};ObservableArray.prototype.pop=function(){if(!this._array.length){return null;}
-var item=this._array.pop();this._length=this._array.length;this._emitEvent('remove',{index:this._array.length,count:1,items:[item]});return item;};ObservableArray.prototype.splice=function(index,count){if(arguments.length<2){return[];}
-if(index>=this._length){index=this._length-1;}else if(index<0){index=this._length+index;}
-if(count<0){count=0;}
-var addedItems=Array.prototype.slice.call(arguments,2);var removedItems=this._array.splice.apply(this._array,arguments);this._length=this._array.length;if(removedItems.length){this._emitEvent('remove',{index:index,count:count,items:removedItems});}
-if(addedItems.length){this._emitEvent('insert',{index:index,count:addedItems.length,items:addedItems});}
-return removedItems;};ObservableArray.prototype.set=function(index,value){if(index<0||index>=this._array.length){this.throw('set: out of range');}
-var oldValue=this._array[index];this._array[index]=value;this._emitEvent('replace',{index:index,oldValue:oldValue,newValue:value});};ObservableArray.prototype.get=function(index){return this._array[index];};ObservableArray.prototype.reset=function(array){this._array=array||[];this._length=this._array.length;this._emitEvent('reset',{items:this._array});};return ObservableArray;});/**
- * PageTransitions provides transition functions used when navigating panels.
- *
- * @module PageTransitions
- */
-/**
- * PageTransitions provides transition functions used when navigating panels.
- *
- * @module PageTransitions
- */
-/**
- * PageTransitions provides transition functions used when navigating panels.
- *
- * @module PageTransitions
- */
-/**
- * PageTransitions provides transition functions used when navigating panels.
- *
- * @module PageTransitions
- */
-/**
- * PageTransitions provides transition functions used when navigating panels.
- *
- * @module PageTransitions
- */
-define('modules/page_transitions',[],function() {
- 'use strict';
-
- var _sendPanelReady = function _send_panel_ready(oldPanelHash, newPanelHash) {
- var detail = {
- previous: oldPanelHash,
- current: newPanelHash
- };
- var event = new CustomEvent('panelready', {detail: detail});
- window.dispatchEvent(event);
- };
-
- return {
- /**
- * Typically used with phone size device layouts.
- *
- * @alias module:PageTransitions#oneColumn
- * @param {String} oldPanel
- * @param {String} newPanel
- * @param {Function} callback
- */
- oneColumn: function pt_one_column(oldPanel, newPanel, callback) {
- if (oldPanel === newPanel) {
- callback();
- return;
- }
-
- // switch previous/current classes
- if (oldPanel) {
- oldPanel.className = newPanel.className ? '' : 'previous';
- }
- if (newPanel.className === 'current') {
- _sendPanelReady(oldPanel && '#' + oldPanel.id, '#' + newPanel.id);
-
- if (callback) {
- callback();
- }
- return;
- }
-
- // A hidden or newly inserted panel may not animate. Complete navigation
- // in that case too, otherwise its onShow hook never runs.
- var completed = false;
- var fallback;
- function paintWait(event) {
- if (event && event.target !== newPanel) {
- return;
- }
- if (completed) {
- return;
- }
- completed = true;
- clearTimeout(fallback);
- newPanel.removeEventListener('transitionend', paintWait);
- newPanel.removeEventListener('transitioncancel', paintWait);
- _sendPanelReady(oldPanel && '#' + oldPanel.id, '#' + newPanel.id);
- if (callback) {
- callback();
- }
- }
- newPanel.addEventListener('transitionend', paintWait);
- newPanel.addEventListener('transitioncancel', paintWait);
- newPanel.className = 'current';
- var style = window.getComputedStyle(newPanel);
- var seconds = function(value) {
- return parseFloat(value) * (value.trim().endsWith('ms') ? 1 : 1000) || 0;
- };
- var durations = style.transitionDuration.split(',').map(seconds);
- var delays = style.transitionDelay.split(',').map(seconds);
- var duration = Math.max.apply(Math, durations.map(function(value, index) {
- return value + delays[index % delays.length];
- }));
- fallback = setTimeout(paintWait, Math.max(0, duration) + 100);
-
- /**
- * Most browsers now scroll content into view taking CSS transforms into
- * account. That's not what we want when moving between s,
- * because the being-moved-to section is offscreen when we navigate to its
- * #hash. The transitions assume the viewport is always at document 0,0.
- * So add a hack here to make that assumption true again.
- * https://bugzilla.mozilla.org/show_bug.cgi?id=803170
- */
- if ((window.scrollX !== 0) || (window.scrollY !== 0)) {
- window.scrollTo(0, 0);
- }
-
- },
-
- /**
- * Typically used with tablet size device layouts.
- *
- * @alias module:PageTransitions#twoColumn
- * @param {String} oldPanel
- * @param {String} newPanel
- * @param {Function} callback
- */
- twoColumn: function pt_two_column(oldPanel, newPanel, callback) {
- if (oldPanel === newPanel) {
- callback();
- return;
- }
-
- if (oldPanel) {
- oldPanel.className = newPanel.className ? '' : 'previous';
- newPanel.className = 'current';
- _sendPanelReady('#' + oldPanel.id, '#' + newPanel.id);
- } else {
- newPanel.className = 'current';
- _sendPanelReady(null, '#' + newPanel.id);
- }
-
- if (callback) {
- callback();
- }
- }
- };
-});
-define('modules/panel',[],function(){'use strict';var _emptyFunc=function panel_emptyFunc(){};var Panel=function ctor_panel(options){var _initialized=false;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{get initialized(){return _initialized;},init:function(panel,initOptions){if(_initialized){return;}
-_initialized=true;return options.onInit(panel,initOptions);},uninit:function(){if(!_initialized){return;}
-_initialized=false;options.onUninit();},show:function(panel,showOptions){return Promise.resolve(this.init(panel,showOptions)).then(function(){return options.onShow(panel,showOptions);});},hide:function(){return options.onHide();},beforeShow:function(panel,beforeShowOptions){return Promise.resolve(this.init(panel,beforeShowOptions)).then(function(){return options.onBeforeShow(panel,beforeShowOptions);});},beforeHide:function(){return options.onBeforeHide();}};};return Panel;});define('modules/settings_cache',[],function(){'use strict';var _settings=window.navigator.mozSettings;var _settingsCache=null;var _settingsCacheRequestSent=null;var _pendingSettingsCallbacks=[];var _callbacks=[];var _onSettingsChange=function sc_onSettingsChange(event){var key=event.settingName;var value=event.settingValue;if(_settingsCache){_settingsCache[key]=value;}
-_callbacks.forEach(function(callback){callback(event);});};if(_settings){_settings.onsettingchange=_onSettingsChange;}
-var SettingsCache={reset:function sc_reset(){_settings=window.navigator.mozSettings;if(_settings){_settings.onsettingchange=_onSettingsChange;}
-_settingsCache=null;_settingsCacheRequestSent=null;_pendingSettingsCallbacks=[];_callbacks=[];},get cache(){return _settingsCache;},getSettings:function sc_getSettings(callback){if(!_settings){return;}
-if(_settingsCache&&callback){callback(_settingsCache);return;}
-if(!_settingsCacheRequestSent&&!_settingsCache){_settingsCacheRequestSent=true;var lock=_settings.createLock();var request=lock.get('*');request.onsuccess=function(e){var result=request.result;var cachedResult={};for(var attr in result){cachedResult[attr]=result[attr];}
-_settingsCache=cachedResult;var cbk;while((cbk=_pendingSettingsCallbacks.pop())){cbk(result);}};}
-if(callback){_pendingSettingsCallbacks.push(callback);}},addEventListener:function sc_addEventListener(eventName,callback){if(eventName!=='settingsChange'){return;}
-var index=_callbacks.indexOf(callback);if(index===-1){_callbacks.push(callback);}},removeEventListener:function sc_removeEventListsner(eventName,callback){if(eventName!=='settingsChange'){return;}
-var index=_callbacks.indexOf(callback);if(index!==-1){_callbacks.splice(index,1);}}};SettingsCache.getSettings(null);return SettingsCache;});define('modules/panel_utils',['require','settings','modules/settings_cache','shared/lazy_loader'],function(require){'use strict';var Settings=require('settings');var SettingsCache=require('modules/settings_cache');var LazyLoader=require('shared/lazy_loader');var _settings=navigator.mozSettings;function openDialog(dialogID,onSubmit,onReset){if('#'+dialogID===Settings.currentPanel){return;}
-var origin=Settings.currentPanel;Settings.currentPanel=dialogID;var dialog=document.getElementById(dialogID);var submit=dialog.querySelector('[type=submit]');if(submit){submit.onclick=function onsubmit(){if(typeof onSubmit==='function'){(onSubmit.bind(dialog))();}
-Settings.currentPanel=origin;};}
-var reset=dialog.querySelector('[type=reset]');if(reset){reset.onclick=function onreset(){if(typeof onReset==='function'){(onReset.bind(dialog))();}
-Settings.currentPanel=origin;};}}
-function openLink(url){if(url.startsWith('tel:')){var telActivity=new window.MozActivity({name:'dial',data:{type:'webtelephony/number',number:url.substr(4)}});telActivity.onsuccess=function(){};}else if(!url.startsWith('#')){var linkActivity=new window.MozActivity({name:'view',data:{type:'url',url:url}});linkActivity.onsuccess=function(){};}}
-var _openDialog=function pu_openDialog(dialogID){var dialog=document.getElementById(dialogID);var fields=Array.prototype.slice.call(dialog.querySelectorAll('[data-setting]:not([data-ignore])'));var updateInput=function(lock,input){var key=input.dataset.setting;var request=lock.get(key);request.onsuccess=function(){switch(input.type){case'gaia-radio':input.checked=(input.value==request.result[key]);break;case'checkbox':case'gaia-checkbox':input.checked=request.result[key]||false;break;case'select-one':input.value=request.result[key]||'';break;default:input.value=request.result[key]||'';break;}};};function reset(){if(_settings){var lock=_settings.createLock();fields.forEach(updateInput.bind(null,lock));}}
-function submit(){if(_settings){fields=Array.prototype.slice.call(dialog.querySelectorAll('[data-setting]:not([data-ignore])'));var cset={},key;var lock=_settings.createLock();fields.forEach(function(input){key=input.dataset.setting;switch(input.type){case'gaia-radio':if(input.checked){cset[key]=input.value;}
-break;case'checkbox':case'gaia-checkbox':cset[key]=input.checked;break;default:cset[key]=input.value;break;}});lock.set(cset);}}
-reset();openDialog(dialogID,submit);};return{activate:function pu_activate(panel){var scripts=panel.getElementsByTagName('script');var scripts_src=Array.prototype.map.call(scripts,function(script){return script.getAttribute('src');});LazyLoader.load(scripts_src);var _onclick=function(evt){if(!this.dataset.href){this.dataset.href=this.href;this.href='#';}
-var href=this.dataset.href;if(!href.startsWith('#')){evt.target.blur();openLink(href);}else if(!href.endsWith('Settings')){openDialog(href.substr(1));}else{_openDialog(href.substr(1));}
-return false;};var rule='a[href^="http"], a[href^="tel"], [data-href]';var links=panel.querySelectorAll(rule);var i,count;for(i=0,count=links.length;i{switch(input.type){case'gaia-switch':case'gaia-checkbox':case'checkbox':case'switch':value=!!value;if(input.checked===value){return;}
-input.checked=value;break;case'range':if(input.value===value){return;}
-input.value=value;break;case'select':for(i=0,count=input.options.length;i gaia-header';
- var header = document.querySelector(headerSelector);
- return (header && header.dataset.href || '').replace('#', '');
- };
-
- var _shallCloseActivity = function ss_shallCloseActivity(panelId) {
- // If we're handling an activity and the 'back' button is hit, close the
- // activity if the panel id to be navigated equals the parent panel id.
-
- // This is for the root panel
- if (panelId === 'close') {
- return true;
- }
-
- if (!_currentNavigation) {
- return false;
- }
-
- // Get the parent panel id of the current panel.
- var parentPanelId = _retriveParentPanelId(_currentNavigation.panelId);
-
- // Close the activity if the current panel is the original target panel,
- // and the new panel is the parent panel of the current panel.
- return _currentNavigation.panelId === _activityHandler.targetPanelId &&
- panelId === parentPanelId;
- };
-
- var _transit = function ss_transit(oldPanel, newPanel, callback) {
- var promise = new Promise(function(resolve) {
- var wrappedCallback = function() {
- if (typeof callback === 'function') {
- callback();
- }
- resolve();
- };
-
- if (_isTabletAndLandscape()) {
- PageTransitions.twoColumn(oldPanel, newPanel, wrappedCallback);
- } else {
- PageTransitions.oneColumn(oldPanel, newPanel, wrappedCallback);
- }
- });
- return promise;
- };
-
- var _loadPanel = function ss_loadPanel(panelId, callback) {
- var panelElement = document.getElementById(panelId);
- if (panelElement.dataset.rendered) { // already initialized
- callback();
- return;
- }
- panelElement.dataset.rendered = true;
-
- // XXX remove SubPanel loader once sub panel are modulized
- if (panelElement.dataset.requireSubPanels) {
- // load the panel and its sub-panels (dependencies)
- // (load the main panel last because it contains the scripts)
- var selector = 'section[id^="' + panelElement.id + '-"]';
- var subPanels = document.querySelectorAll(selector);
- for (var i = 0, il = subPanels.length; i < il; i++) {
- LazyLoader.load([subPanels[i]]);
- }
- LazyLoader.load([panelElement], callback);
- } else {
- LazyLoader.load([panelElement], callback);
- }
- };
-
-
- var _loadModulesForSubPanels = function ss_loadModules(panelId) {
- if (panelId === _rootPanelId) {
- return Promise.resolve();
- } else {
- if (!_loadModulesForSubPanelsPromise) {
- _loadModulesForSubPanelsPromise = new Promise(function(resolve) {
- require(['shared/async_storage'], resolve);
- });
- }
- return _loadModulesForSubPanelsPromise;
- }
- };
-
- var _onVisibilityChange = function ss_onVisibilityChange() {
- _handleVisibilityChange(!document.hidden);
- };
-
- /**
- * When the app becomes invisible, we should call to beforeHide and hide
- * functions of the current panel. When the app becomes visible, we should
- * call to beforeShow and show functions of the current panel with the
- * cached options.
- */
- var _handleVisibilityChange = function ss_onVisibilityChange(visible) {
- if (!_currentNavigation) {
- return;
- }
-
- var panel = _currentNavigation.panel;
- var element = _currentNavigation.panelElement;
- var options = _currentNavigation.options;
-
- if (!panel) {
- return;
- }
-
- if (visible) {
- panel.beforeShow(element, options);
- panel.show(element, options);
- } else {
- panel.beforeHide();
- panel.hide();
- }
- };
-
- var _navigate = function ss_navigate(panelId, options, callback) {
- // Early return if the panel to be navigated is the same as the
- // current one.
- if (_currentNavigation && _currentNavigation.panelId === panelId) {
- callback();
- return;
- }
-
- _loadPanel(panelId, function() {
- 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;
- var currentPanelElement =
- _currentNavigation && _currentNavigation.panelElement;
- var currentPanel = _currentNavigation && _currentNavigation.panel;
-
- // Keep these to make sure we can use when going back
- _cachedNavigation = _currentNavigation;
- _cachedNavigationOptions = options;
-
- // Prepare options and calls to the panel object's before
- // show function.
- options = options || {};
-
- // 0. start the chain
- _loadModulesForSubPanels(panelId)
- // 1. beforeHide previous panel
- .then(function() {
- // We don't deactivate the root panel.
- if (currentPanel && currentPanelId !== _rootPanelId) {
- return currentPanel.beforeHide();
- }
- })
- // 2. beforeShow next panel
- .then(function() {
- return panel.beforeShow(newPanelElement, options);
- })
- // 3. do the transition
- .then(function() {
- return _transit(currentPanelElement, newPanelElement);
- })
- // 4. hide previous panel
- .then(function() {
- // We don't deactivate the root panel.
- if (currentPanel && currentPanelId !== _rootPanelId) {
- return currentPanel.hide();
- }
- })
- // 5. show next panel
- .then(function() {
- return panel.show(newPanelElement, options);
- })
- // 6. keep information
- .then(function() {
- // Update the current navigation object
- _currentNavigation = {
- panelId: panelId,
- panelElement: newPanelElement,
- panel: panel,
- options: options
- };
-
- // XXX we need to remove this line in the future
- // to make sure we won't manipulate Settings
- // directly
- Settings._currentPanel = '#' + panelId;
- callback();
- }).catch(function(error) {
- console.error('Settings panel ' + panelId + ':', error);
- newPanelElement.classList.remove('current');
- if (currentPanelElement) currentPanelElement.classList.add('current');
- // Release the navigation lock even when a hardware query fails.
- callback(error);
- if (window.VulpesGaiaUI) {
- window.VulpesGaiaUI.notice(document.documentElement.lang.indexOf('fr') === 0 ?
- 'Ce panneau n’a pas pu être chargé. Vous pouvez revenir aux autres paramètres.' :
- 'This panel could not be loaded. You can return to the other settings.');
- }
- });
- });
- });
- };
-
- return {
- reset: function ss_reset() {
- _rootPanelId = null;
- _currentNavigation = null;
- _cachedNavigation = null;
- _cachedNavigationOptions = {};
- _activityHandler = null;
- _navigating = false;
- _pendingNavigationRequest = null;
- window.removeEventListener('visibilitychange', _onVisibilityChange);
- },
-
- /**
- * Init SettingsService.
- *
- * @alias module:SettingsService#init
- * @param {Object} options
- * @param {String} options.rootPanelId
- * Panel with the specified id is assumed to be be kept on
- * on the screen always. We don't call to its hide and
- * beforeHide functions.
- * @param {Object} options.context
- * The launch context specifying the default panel and the
- * activity handler if the app is invoked by web
- * activities.
- * @param {String} options.context.initialPanelId
- * @param {ActivityHandler} options.context.activityHandler
- */
- init: function ss_init(options) {
- if (options) {
- _rootPanelId = options.rootPanelId || 'root';
- _activityHandler = options.context && options.context.activityHandler;
- }
-
- window.addEventListener('visibilitychange', _onVisibilityChange);
- },
-
- /**
- * Navigate to a panel with options. The navigation transition is
- * determined based on the current screen size and orientation.
- *
- * @alias module:SettingsService#navigate
- * @param {String} panelId
- * @param {Object} options
- * @param {Function} callback
- */
- navigate: function ss_navigate(panelId, options, callback) {
- // Check if the app is invoked by web activity and shall post result.
- if (_activityHandler && _shallCloseActivity(panelId)) {
- _activityHandler.postResult();
- return;
- }
-
- // Cache the navigation request if it is navigating.
- if (_navigating) {
- _pendingNavigationRequest = arguments;
- return;
- }
-
- // If we find out the link includes information about app's name,
- // it means that we are going to embed the app into our app.
- //
- // In this way, we have to navigate to `frame` panel and embed it.
- var appName = _getAppNameToLink(panelId);
- if (appName) {
- var appInfo = _getAppInfo(appName);
-
- if (!appInfo) {
- console.error('We only embed trust apps.');
- return;
- }
-
- panelId = 'frame';
- options = options || {};
- options.mozapp = appInfo.mozapp;
- options.src = appInfo.src;
- }
-
- _navigating = true;
- _navigate(panelId, options, (function() {
- _navigating = false;
-
- // Navigate to the pending navigation if any.
- if (_pendingNavigationRequest) {
- var args = _pendingNavigationRequest;
- _pendingNavigationRequest = null;
- this.navigate.apply(this, args);
- }
-
- if (callback) {
- callback();
- }
- }).bind(this));
- },
-
- /**
- * Go back to previous panel
- *
- * @alias module:SettingsService#back
- */
- back: function ss_back() {
- if (_activityHandler) {
- _activityHandler.postResult();
- } else if (_cachedNavigation) {
- this.navigate(_cachedNavigation.panelId, _cachedNavigationOptions);
- _cachedNavigation = null;
- _cachedNavigationOptions = {};
- }
- }
- };
-});
-require(['config/require'],function(){'use strict';define('boot',['require','shared/settings_listener','modules/mvvm/observable','modules/mvvm/observable_array','modules/base/event_emitter','modules/settings_service','shared/screen_layout','settings'],function(require){require('shared/settings_listener');require('modules/mvvm/observable');require('modules/mvvm/observable_array');require('modules/base/event_emitter');var SettingsService=require('modules/settings_service');var ScreenLayout=require('shared/screen_layout');var Settings=require('settings');function isInitialPanel(panel){if(Settings.isTabletAndLandscape()){return panel===Settings.initialPanelForTablet;}else{return panel===('#'+window.LaunchContext.initialPanelId);}}
-window.addEventListener('panelready',function onPanelReady(e){if(!isInitialPanel(e.detail.current)){return;}
-var initialPanelHandler=window.LaunchContext.initialPanelHandler;if(initialPanelHandler){initialPanelHandler.release();var pendingTargetPanel=initialPanelHandler.pendingTargetPanel;switch(pendingTargetPanel){case'call':var mozMobileConnections=navigator.mozMobileConnections;if(mozMobileConnections&&mozMobileConnections.length>1){pendingTargetPanel='call-iccs';}
-SettingsService.navigate(pendingTargetPanel);break;default:if(pendingTargetPanel){SettingsService.navigate(pendingTargetPanel);}
-break;}}
-window.removeEventListener('panelready',onPanelReady);window.performance.mark('visuallyLoaded');document.body.dataset.ready=true;},false);window.addEventListener('telephony-settings-loaded',function onTelephonySettingsLoaded(){window.removeEventListener('telephony-settings-loaded',onTelephonySettingsLoaded);window.performance.mark('fullyLoaded');});SettingsService.init({rootPanelId:'root',context:window.LaunchContext});var options={SettingsService:SettingsService,ScreenLayout:ScreenLayout};Settings.init(options);if(navigator.mozAudioChannelManager){navigator.mozAudioChannelManager.volumeControlChannel='notification';}});require(['boot']);});define("main",function(){});
\ No newline at end of file
diff --git a/overrides/settings.gaiamobile.org/js/modules/date_time.js b/overrides/settings.gaiamobile.org/js/modules/date_time.js
deleted file mode 100644
index b2532240..00000000
--- a/overrides/settings.gaiamobile.org/js/modules/date_time.js
+++ /dev/null
@@ -1,419 +0,0 @@
-/**
- * DateTime is a singleton that caches date, time, timezone values for
- * Date & Time panel fast access
- *
- * @module DateTime
- */
-/**
- * DateTime is a singleton that caches date, time, timezone values for
- * Date & Time panel fast access
- *
- * @module DateTime
- */
-/**
- * DateTime is a singleton that caches date, time, timezone values for
- * Date & Time panel fast access
- *
- * @module DateTime
- */
-/**
- * DateTime is a singleton that caches date, time, timezone values for
- * Date & Time panel fast access
- *
- * @module DateTime
- */
-/**
- * DateTime is a singleton that caches date, time, timezone values for
- * Date & Time panel fast access
- *
- * @module DateTime
- */
-/**
- * DateTime is a singleton that caches date, time, timezone values for
- * Date & Time panel fast access
- *
- * @module DateTime
- */
-/**
- * DateTime is a singleton that caches date, time, timezone values for
- * Date & Time panel fast access
- *
- * @module DateTime
- */
-/**
- * DateTime is a singleton that caches date, time, timezone values for
- * Date & Time panel fast access
- *
- * @module DateTime
- */
-define('modules/date_time',["require", "modules/settings_cache", "modules/base/module", "modules/mvvm/observable"],function(require) {
- 'use strict';
-
- var SettingsCache = require('modules/settings_cache');
- var Module = require('modules/base/module');
- var Observable = require('modules/mvvm/observable');
- var settings = window.navigator.mozSettings;
-
- // keys
- var _kClockAutoEnabled = 'time.clock.automatic-update.enabled';
- var _kTimezoneAutoEnabled = 'time.timezone.automatic-update.enabled';
- var _kClockAutoAvailable = 'time.clock.automatic-update.available';
- var _kTimezoneAutoAvailable = 'time.timezone.automatic-update.available';
- var _kTimezone = 'time.timezone';
- var _kUserSelected = 'time.timezone.user-selected';
- var _kLocaleTime = 'locale.hour12';
-
- // handler
- var _updateDateTimeout = null;
- var _updateTimeTimeout = null;
-
- var DateTime = Module.create(function DateTime() {
- this.super(Observable).call(this);
- this._init();
- }).extend(Observable);
-
-
- // public observers
- /**
- * Auto setting Date & Time.
- *
- * @access public
- * @memberOf DateTime
- * @type {Boolean}
- */
- Observable.defineObservableProperty(DateTime.prototype, 'clockAutoEnabled', {
- readonly: true,
- value: true
- });
-
- /**
- * SIM provide Auto clock function.
- *
- * @access public
- * @memberOf DateTime
- * @type {Boolean}
- */
- Observable.defineObservableProperty(DateTime.prototype,
- 'clockAutoAvailable', {
- readonly: true,
- value: true
- });
-
- /**
- * SIM provide Auto timezone function.
- *
- * @access public
- * @memberOf DateTime
- * @type {Boolean}
- */
- Observable.defineObservableProperty(DateTime.prototype,
- 'timezoneAutoAvailable', {
- readonly: true,
- value: true
- });
-
- /**
- * Current Timezone.
- *
- * @access public
- * @memberOf DateTime
- * @type {String}
- */
- Observable.defineObservableProperty(DateTime.prototype, 'timezone', {
- readonly: true,
- value: ''
- });
-
- /**
- * User Selected Timezone.
- *
- * @access public
- * @memberOf DateTime
- * @type {String}
- */
- Observable.defineObservableProperty(DateTime.prototype,
- 'userSelectedTimezone', {
- readonly: true,
- value: ''
- });
-
- /**
- * Current Date string.
- *
- * @access public
- * @memberOf DateTime
- * @type {String}
- */
- Observable.defineObservableProperty(DateTime.prototype, 'date', {
- readonly: true,
- value: ''
- });
-
- /**
- * Localized current time string.
- *
- * @access public
- * @memberOf DateTime
- * @type {String}
- */
- Observable.defineObservableProperty(DateTime.prototype, 'time', {
- readonly: true,
- value: ''
- });
-
- /**
- * Current AM/PM or 24 state.
- *
- * @access public
- * @memberOf DateTime
- * @type {Boolean}
- */
- Observable.defineObservableProperty(DateTime.prototype, 'currentHour12', {
- readonly: true,
- value: true
- });
-
- /**
- * Init DateTime module.
- *
- * @access private
- * @memberOf DateTime
- */
- DateTime.prototype._init = function() {
- this._mozTime = window.navigator.mozTime;
-
- this._boundSetTimeAutoEnabled = function(event) {
- this._clockAutoEnabled = event.settingValue;
- // also set timezoneAutoEnabled
- this._setTimezoneAutoEnabled(event.settingValue);
- }.bind(this);
- this._boundSetClockAutoAvailable = function(event) {
- this._clockAutoAvailable = event.settingValue;
- }.bind(this);
- this._boundSetTimezoneAutoAvailable = function(event) {
- this._timezoneAutoAvailable = event.settingValue;
- }.bind(this);
- this._boundSetTimezone = function(event) {
- this._timezone = event.settingValue;
- }.bind(this);
- this._boundUserSelectedTimezone = function(event) {
- this._userSelectedTimezone = event.settingValue;
- }.bind(this);
- this._boundCurrentHour12 = function(event) {
- this._currentHour12 = event.settingValue;
-
- this._autoUpdateDateTime();
- }.bind(this);
-
- this._getDefaults();
- this._attachListeners();
- };
-
- DateTime.prototype._attachListeners = function() {
- window.navigator.mozSettings.addObserver(_kClockAutoEnabled,
- this._boundSetTimeAutoEnabled);
- window.navigator.mozSettings.addObserver(_kClockAutoAvailable,
- this._boundSetClockAutoAvailable);
- window.navigator.mozSettings.addObserver(_kTimezoneAutoAvailable,
- this._boundSetTimezoneAutoAvailable);
- window.navigator.mozSettings.addObserver(_kTimezone,
- this._boundSetTimezone);
- window.navigator.mozSettings.addObserver(_kUserSelected,
- this._boundUserSelectedTimezone);
- window.navigator.mozSettings.addObserver(_kLocaleTime,
- this._boundCurrentHour12);
- // Listen to 'DOMRetranslated' to get the latest l10n resource.
- document.addEventListener('DOMRetranslated', this);
- // Listen to 'moztimechange' to update clock
- window.addEventListener('moztimechange', this);
- };
-
- DateTime.prototype.handleEvent = function(evt) {
- switch (evt.type) {
- case 'moztimechange':
- this._autoUpdateDateTime();
- break;
- case 'DOMRetranslated':
- var d = new Date();
- this._date = this._formatDate(d);
- this._time = this._formatTime(d);
- break;
- }
- };
-
- /**
- * fill in default values to public observers
- *
- * @access private
- * @memberOf DateTime
- */
- DateTime.prototype._getDefaults = function() {
- SettingsCache.getSettings(function(results) {
- this._clockAutoEnabled = results[_kClockAutoEnabled];
- this._clockAutoAvailable = results[_kClockAutoAvailable];
- this._timezoneAutoAvailable = results[_kTimezoneAutoAvailable];
- this._timezone = results[_kTimezone];
- this._currentHour12 = results[_kLocaleTime];
- // render date/time after get proper format
- this._autoUpdateDateTime();
- }.bind(this));
- };
-
- /**
- * Auto update date and time
- *
- * @access private
- * @memberOf DateTime
- */
- DateTime.prototype._autoUpdateDateTime = function() {
- window.clearTimeout(_updateDateTimeout);
- window.clearTimeout(_updateTimeTimeout);
- this._autoUpdateDate();
- this._autoUpdateTime();
- };
-
- /**
- * Update Date periodically.
- *
- * DONT call this function directly,
- * call _autoUpdateDateTime instead.
- *
- * @access private
- * @memberOf DateTime
- */
- DateTime.prototype._autoUpdateDate = function() {
- var d = new Date();
- this._date = this._formatDate(d);
-
- var remainMillisecond = (24 - d.getHours()) * 3600 * 1000 -
- d.getMinutes() * 60 * 1000 - d.getMilliseconds();
- _updateDateTimeout = window.setTimeout(
- function updateDateTimeout() {
- this._autoUpdateDate();
- }.bind(this), remainMillisecond);
- };
-
- /**
- * Update Time periodically.
- *
- * DONT call this function directly,
- * call _autoUpdateDateTime instead.
- *
- * @access private
- * @memberOf DateTime
- */
- DateTime.prototype._autoUpdateTime = function() {
- var d = new Date();
- this._time = this._formatTime(d);
-
- var remainMillisecond = (60 - d.getSeconds()) * 1000;
- _updateTimeTimeout = window.setTimeout(
- function updateTimeTimeout() {
- this._autoUpdateTime();
- }.bind(this), remainMillisecond);
- };
-
- /**
- * Return preffered date format.
- *
- * @access private
- * @memberOf DateTime
- * @param {Date} input as Date
- * @returns {String}
- */
- DateTime.prototype._formatDate = function(d) {
- return d.toLocaleString(navigator.languages, {
- year: 'numeric',
- month: 'long',
- day: '2-digit',
- });
- };
-
- /**
- * Return preffered time format.
- *
- * @access private
- * @memberOf DateTime
- * @param {Date} input as Date
- * @returns {String}
- */
- DateTime.prototype._formatTime = function(d) {
- return d.toLocaleString(navigator.languages, {
- hour12: navigator.mozHour12,
- hour: 'numeric',
- minute: 'numeric',
- });
- };
-
- /**
- * Change system time
- *
- * @access public
- * @memberOf DateTime
- */
- DateTime.prototype.setTime = function(type, value) {
- var newDate = new Date();
- var tokens;
-
- switch (type) {
- case 'date':
- tokens = value.split('-');
- newDate.setFullYear(Number(tokens[0]), Number(tokens[1]) - 1, Number(tokens[2]));
- break;
-
- case 'time':
- tokens = value.split(':');
- newDate.setHours(parseInt(tokens[0]));
- newDate.setMinutes(parseInt(tokens[1]));
- break;
- }
- if (!this._mozTime) return;
- return this._mozTime.set(newDate);
- };
-
- /**
- * Change TimezoneAutoEnabled settings.
- *
- * @access private
- * @memberOf DateTime
- */
- DateTime.prototype._setTimezoneAutoEnabled = function(enabled) {
- var cset = {};
- cset[_kTimezoneAutoEnabled] = enabled;
- settings.createLock().set(cset);
- },
-
- /**
- * Change UserSelectedTimezone settings.
- *
- * @access public
- * @memberOf DateTime
- */
- DateTime.prototype.setUserSelectedTimezone = function(selected) {
- var cset = {};
- cset[_kTimezone] = selected;
- settings.createLock().set(cset);
- };
-
- /**
- * Change hour12 settings.
- * this valie is used to determine 12 or 24 time format.
- *
- * @access public
- * @memberOf DateTime
- */
- DateTime.prototype.setCurrentHour12 = function(selected) {
- var cset = {};
- cset[_kLocaleTime] = selected;
- settings.createLock().set(cset);
- };
-
- DateTime.prototype.getHour12ForCurrentLocale = function() {
- var format = new Intl.DateTimeFormat(navigator.languages, {
- hour: 'numeric'
- });
- return format.resolvedOptions().hour12;
- };
-
- return DateTime();
-});
diff --git a/overrides/settings.gaiamobile.org/js/modules/dialog_service.js b/overrides/settings.gaiamobile.org/js/modules/dialog_service.js
deleted file mode 100644
index 25f129a7..00000000
--- a/overrides/settings.gaiamobile.org/js/modules/dialog_service.js
+++ /dev/null
@@ -1,483 +0,0 @@
-
-define('modules/dialog_manager',["require", "modules/panel_cache", "shared/lazy_loader"],function(require) {
- 'use strict';
-
- var PanelCache = require('modules/panel_cache');
- var LazyLoader = require('shared/lazy_loader');
-
- var DialogManager = function() {
- this.OVERLAY_SELECTOR = '.settings-dialog-overlay';
-
- this._overlayDOM = document.querySelector(this.OVERLAY_SELECTOR);
- };
-
- DialogManager.prototype = {
- /**
- * load panel based on passed in panelId
- *
- * @memberOf DialogManager
- * @access private
- * @param {String} panelId
- * @return {Promise}
- */
- _loadPanel: function dm__loadPanel(panelId) {
- var promise = new Promise(function(resolve, reject) {
- var panelElement = document.getElementById(panelId);
- if (panelElement.dataset.rendered) { // already initialized
- resolve();
- return;
- }
-
- panelElement.dataset.rendered = true;
-
- // XXX remove SubPanel loader once sub panel are modulized
- if (panelElement.dataset.requireSubPanels) {
- // load the panel and its sub-panels (dependencies)
- // (load the main panel last because it contains the scripts)
- var selector = 'section[id^="' + panelElement.id + '-"]';
- var subPanels = document.querySelectorAll(selector);
- for (var i = 0, il = subPanels.length; i < il; i++) {
- LazyLoader.load([subPanels[i]]);
- }
- LazyLoader.load([panelElement], resolve);
- } else {
- LazyLoader.load([panelElement], resolve);
- }
- });
- return promise;
- },
-
- /**
- * promised version of PanelCache.get()
- *
- * @memberOf DialogManager
- * @access private
- * @param {String} panelId
- * @return {Promise}
- */
- _getPanel: function dm__getPanel(panelId) {
- var promise = new Promise(function(resolve) {
- PanelCache.get(panelId, function(panel) {
- resolve(panel);
- });
- });
- return promise;
- },
-
- /**
- * this is used to control visibility of overlayDOM
- *
- * @memberOf DialogManager
- * @access private
- * @param {Boolean} show
- */
- _showOverlay: function dm__showOverlay(show) {
- this._overlayDOM.hidden = !show;
- },
-
- /**
- * It is used to control the timing of transitions so that we can make sure
- * whether animation is done or not.
- *
- * @memberOf DialogManager
- * @access private
- * @param {String} method
- * @param {BaseDialog} dialog
- * @param {Object} options
- * @return {Promise}
- */
- _transit: function dm__transit(method, dialog, options) {
- var promise = new Promise(function(resolve) {
- var panel = dialog.panel;
- var completed = false;
- var fallback;
- function finish(event) {
- if (event && (event.target !== panel || event.propertyName !== 'visibility')) return;
- if (completed) return;
- completed = true;
- clearTimeout(fallback);
- panel.removeEventListener('transitionend', finish);
- panel.removeEventListener('transitioncancel', finish);
- if (method === 'close') panel.hidden = true;
- resolve();
- }
- if (method === 'open') panel.hidden = false;
- setTimeout(function() {
- panel.addEventListener('transitionend', finish);
- panel.addEventListener('transitioncancel', finish);
- panel.classList.toggle('current', method === 'open');
- var style = window.getComputedStyle(panel);
- function milliseconds(value) {
- return parseFloat(value) * (value.trim().endsWith('ms') ? 1 : 1000) || 0;
- }
- var durations = style.transitionDuration.split(',').map(milliseconds);
- var delays = style.transitionDelay.split(',').map(milliseconds);
- var duration = Math.max.apply(Math, durations.map(function(value, index) {
- return value + delays[index % delays.length];
- }));
- // Reduced motion, display changes or interrupted transitions may
- // omit the visibility event. Always release the dialog queue.
- fallback = setTimeout(finish, Math.max(0, duration) + 100);
- }, 150);
- });
- return promise;
- },
-
- /**
- * Do necessary works to open panel like loading panel, doing transition
- * and call related functions.
- *
- * @memberOf DialogManager
- * @access private
- * @param {BaseDialog} dialog
- * @param {Object} options
- * @return {Promise}
- */
- _open: function dm__open(dialog, options) {
- var self = this;
- var foundPanel;
-
- return Promise.resolve()
- .then(function() {
- // 1: load panel
- return self._loadPanel(dialog.panel.id);
- })
- .then(function() {
- // 2: Get that panel
- return self._getPanel(dialog.panel.id);
- })
- .then(function(panel) {
- // 3: call beforeShow
- foundPanel = panel;
- return foundPanel.beforeShow(dialog.panel, options);
- })
- .then(function() {
- // 4. UI stuffs + transition
- dialog.init();
- dialog.initUI();
- dialog.bindEvents();
-
- if (dialog.TRANSITION_CLASS === 'zoom-in-80') {
- self._showOverlay(true);
- }
-
- return self._transit('open', dialog, options);
- })
- .then(function() {
- // 5. show that panel as a dialog
- return foundPanel.show(dialog.panel, options);
- });
- },
-
- /**
- * Do necessary works to close panel like loading panel, doing transition
- * and call related functions.
- *
- * @memberOf DialogManager
- * @access private
- * @param {BaseDialog} dialog
- * @param {Object} options
- * @return {Promise}
- */
- _close: function dm__close(dialog, options) {
- var self = this;
- var foundPanel;
- var cachedResult;
-
- return Promise.resolve()
- .then(function() {
- // 1: Get that panel
- return self._getPanel(dialog.panel.id);
- })
- .then(function(panel) {
- // 2: Let's validate to see whether we can close this dialog or not.
- foundPanel = panel;
-
- var promise;
- // custom dialog - onSubmit
- if (foundPanel.onSubmit && options._type === 'submit') {
- promise = foundPanel.onSubmit();
- // custom dialog - onCancel
- } else if (foundPanel.onCancel && options._type === 'cancel') {
- promise = foundPanel.onCancel();
- // if no onSubmit & onCancel, pass directly
- } else {
- promise = Promise.resolve();
- }
-
- return promise;
- })
- .then(function(result) {
- cachedResult = result;
-
- // 3: call beforeHide
- return foundPanel.beforeHide();
- })
- .then(function() {
- // 4. transition
- return self._transit('close', dialog, options);
- })
- .then(function() {
- // 5. call hide
- return foundPanel.hide();
- })
- .then(function() {
- // 6. Get result and cleanup dialog
- var result;
-
- // for prompt dialog, we have to get its own result from input text.
- if (dialog.DIALOG_CLASS === 'prompt-dialog') {
- result = dialog.getResult();
- } else if (cachedResult) {
- result = cachedResult;
- }
-
- dialog.cleanup();
-
- if (dialog.TRANSITION_CLASS === 'zoom-in-80') {
- self._showOverlay(false);
- }
-
- return result;
- });
- },
-
- /**
- * It is a bridge to call open or close function.
- *
- * @memberOf DialogManager
- * @access private
- * @param {String} method
- * @param {BaseDialog} dialog
- * @param {Object} options
- * @return {Promise}
- */
- _navigate: function dm__navigate(method, dialog, options) {
- method = (method === 'open') ? '_open' : '_close';
- return this[method](dialog, options);
- },
-
- /**
- * DialogService would use this exposed API to open dialog.
- *
- * @memberOf DialogManager
- * @access public
- * @param {BaseDialog} dialog
- * @param {Object} options
- * @return {Promise}
- */
- open: function dm_open(dialog, options) {
- return this._navigate('open', dialog, options);
- },
-
- /**
- * DialogService would use this exposed API to close dialog.
- *
- * @memberOf DialogManager
- * @access public
- * @param {BaseDialog} dialog
- * @param {Object} options
- * @return {Promise}
- */
- close: function dm_close(dialog, type, options) {
- options._type = type;
- return this._navigate('close', dialog, options);
- }
- };
-
- var dialogManager = new DialogManager();
- return dialogManager;
-});
-define('modules/dialog/base_dialog',['require'],function(require){'use strict';var BaseDialog=function(panelDOM,options){this.panel=panelDOM;this._options=options||{};};BaseDialog.prototype.DIALOG_CLASS='dialog';BaseDialog.prototype.TRANSITION_CLASS='fade';BaseDialog.prototype.SUBMIT_BUTTON_SELECTOR='[type="submit"]';BaseDialog.prototype.CANCEL_BUTTON_SELECTOR='[type="reset"]';BaseDialog.prototype.MESSAGE_SELECTOR='.settings-dialog-message';BaseDialog.prototype.TITLE_SELECTOR='.settings-dialog-title';BaseDialog.prototype.init=function bd_init(){this.TRANSITION_CLASS=this._options.transition||this.TRANSITION_CLASS;this.panel.classList.add(this.DIALOG_CLASS);this.panel.classList.add(this.TRANSITION_CLASS);};BaseDialog.prototype.initUI=function bd_initUI(){var message=this._options.message;var title=this._options.title;var submitButton=this._options.submitButton;var cancelButton=this._options.cancelButton;this._updateMessage(message);this._updateTitle(title);this._updateSubmitButton(submitButton);this._updateCancelButton(cancelButton);};BaseDialog.prototype.bindEvents=function bd_bindEvent(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};BaseDialog.prototype._updateMessage=function bd__updateMessage(message){var messageDOM=this.panel.querySelector(this.MESSAGE_SELECTOR);if(messageDOM&&message){message=this._getWrapL10nObject(message);document.l10n.setAttributes(messageDOM,message.id,message.args);}};BaseDialog.prototype._updateTitle=function bd__updateTitle(title){var titleDOM=this.panel.querySelector(this.TITLE_SELECTOR);if(titleDOM&&title){title=this._getWrapL10nObject(title);document.l10n.setAttributes(titleDOM,title.id,title.args);}};BaseDialog.prototype._updateSubmitButton=function bd__update(options){var buttonDOM=this.getSubmitButton();if(buttonDOM&&options){options=this._getWrapL10nObject(options);document.l10n.setAttributes(buttonDOM,options.id,options.args);buttonDOM.className=options.style||'recommend';}};BaseDialog.prototype._updateCancelButton=function bd__updateText(options){var buttonDOM=this.getCancelButton();if(buttonDOM&&options){options=this._getWrapL10nObject(options);document.l10n.setAttributes(buttonDOM,options.id,options.args);buttonDOM.className=options.style||'';}};BaseDialog.prototype._getWrapL10nObject=function bd__getWrapL10nObject(input){if(typeof input==='string'){return{id:input,args:null};}else if(typeof input==='object'){if(typeof input.id==='undefined'){throw new Error('You forgot to put l10nId - '+
-JSON.stringify(input));}else{return{id:input.id,args:input.args||null,style:input.style};}}else{throw new Error('You are using the wrong L10nObject, '+'please check its format again');}};BaseDialog.prototype.getDOM=function bd_getDOM(){return this.panel;};BaseDialog.prototype.getSubmitButton=function bd_getSubmitButton(){return this.panel.querySelector(this.SUBMIT_BUTTON_SELECTOR);};BaseDialog.prototype.getCancelButton=function bd_getCancelButton(){return this.panel.querySelector(this.CANCEL_BUTTON_SELECTOR);};BaseDialog.prototype.cleanup=function bd_cleanup(){if(this.DIALOG_CLASS!=='panel-dialog'){this._updateTitle('settings-'+this.DIALOG_CLASS+'-header');this._updateSubmitButton('ok');this._updateCancelButton('cancel');}
-this.panel.classList.remove(this.DIALOG_CLASS);this.panel.classList.remove(this.TRANSITION_CLASS);};return BaseDialog;});define('modules/dialog/panel_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var PanelDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};PanelDialog.prototype=Object.create(BaseDialog.prototype);PanelDialog.prototype.constructor=PanelDialog;PanelDialog.prototype.DIALOG_CLASS='panel-dialog';PanelDialog.prototype.TRANSITION_CLASS='fade';return function ctor_PanelDialog(panelDOM,options){return new PanelDialog(panelDOM,options);};});define('modules/dialog/alert_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var AlertDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};AlertDialog.prototype=Object.create(BaseDialog.prototype);AlertDialog.prototype.constructor=AlertDialog;AlertDialog.prototype.DIALOG_CLASS='alert-dialog';AlertDialog.prototype.TRANSITION_CLASS='fade';AlertDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};};return function ctor_alertDialog(panelDOM,options){return new AlertDialog(panelDOM,options);};});define('modules/dialog/confirm_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var ConfirmDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};ConfirmDialog.prototype=Object.create(BaseDialog.prototype);ConfirmDialog.prototype.constructor=ConfirmDialog;ConfirmDialog.prototype.DIALOG_CLASS='confirm-dialog';ConfirmDialog.prototype.TRANSITION_CLASS='fade';ConfirmDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};return function ctor_confirmDialog(panelDOM,options){return new ConfirmDialog(panelDOM,options);};});define('modules/dialog/prompt_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var PromptDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};PromptDialog.prototype=Object.create(BaseDialog.prototype);PromptDialog.prototype.constructor=PromptDialog;PromptDialog.prototype.DIALOG_CLASS='prompt-dialog';PromptDialog.prototype.TRANSITION_CLASS='fade';PromptDialog.prototype.INPUT_SELECTOR='.settings-dialog-input';PromptDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};PromptDialog.prototype.initUI=function(){BaseDialog.prototype.initUI.call(this);this.getInput().value=this._options.defaultValue||'';};PromptDialog.prototype.getInput=function(){return this.panel.querySelector(this.INPUT_SELECTOR);};PromptDialog.prototype.getResult=function(){return this.getInput().value;};return function ctor_promptDialog(panelDOM,options){return new PromptDialog(panelDOM,options);};});define('modules/dialog_service',["require", "settings", "modules/defer", "modules/dialog_manager", "modules/dialog/panel_dialog", "modules/dialog/alert_dialog", "modules/dialog/confirm_dialog", "modules/dialog/prompt_dialog"],function(require) {
- 'use strict';
-
- var Settings = require('settings');
- var Defer = require('modules/defer');
- var DialogManager = require('modules/dialog_manager');
-
- var PanelDialog = require('modules/dialog/panel_dialog');
- var AlertDialog = require('modules/dialog/alert_dialog');
- var ConfirmDialog = require('modules/dialog/confirm_dialog');
- var PromptDialog = require('modules/dialog/prompt_dialog');
-
- var DialogService = function() {
- this._navigating = false;
- this._pendingRequests = [];
- this._settingsAlertDialogId = 'settings-alert-dialog';
- this._settingsBaseDialogId = 'settings-base-dialog';
- this._settingsConfirmDialogId = 'settings-confirm-dialog';
- this._settingsPromptDialogId = 'settings-prompt-dialog';
- };
-
- DialogService.prototype = {
- /**
- * Alert dialog with more controls.
- *
- * @memberOf DialogService
- * @access public
- * @param {String} message
- * @param {Object} userOptions
- * @return {Promise}
- */
- alert: function(message, userOptions) {
- var options = userOptions || {};
- return this.show(this._settingsAlertDialogId, {
- type: 'alert',
- message: message,
- title: options.title,
- submitButton: options.submitButton
- });
- },
-
- /**
- * Confirm dialog with more controls.
- *
- * @memberOf DialogService
- * @access public
- * @param {String} message
- * @param {Object} userOptions
- * @return {Promise}
- */
- confirm: function(message, userOptions) {
- var options = userOptions || {};
- return this.show(this._settingsConfirmDialogId, {
- type: 'confirm',
- message: message,
- title: options.title,
- submitButton: options.submitButton,
- cancelButton: options.cancelButton
- });
- },
-
- /**
- * Prompt dialog with more controls.
- *
- * @memberOf DialogService
- * @access public
- * @param {String} message
- * @param {Object} userOptions
- * @return {Promise}
- */
- prompt: function(message, userOptions) {
- var options = userOptions || {};
- return this.show(this._settingsPromptDialogId, {
- type: 'prompt',
- message: message,
- title: options.title,
- defaultValue: options.defaultValue,
- submitButton: options.submitButton,
- cancelButton: options.cancelButton
- });
- },
-
- /**
- * Panel dialog. If you are going to show any panel as a dialog,
- * you have to use this method to show them.
- *
- * @memberOf DialogService
- * @access public
- * @param {String} panelId
- * @param {Object} userOptions
- * @return {Promise}
- */
- show: function dm_show(panelId, userOptions, _pendingDefer) {
- var self = this;
- var defer;
- var dialog;
- var dialogDOM = document.getElementById(panelId);
- var currentPanel = Settings.currentPanel;
- var options = userOptions || {};
-
- if (_pendingDefer) {
- defer = _pendingDefer;
- } else {
- defer = Defer();
- }
-
- if (this._navigating) {
- this._pendingRequests.push({
- defer: defer,
- panelId: panelId,
- userOptions: userOptions
- });
- } else {
- if ('#' + panelId === currentPanel) {
- defer.reject('You are showing the same panel #' + panelId);
- } else {
- var opening;
- var closing = false;
- function close(type) {
- if (closing) return;
- closing = true;
- // A tap during the opening animation must not run hide/show
- // concurrently, or a queued dialog can inherit stale form values.
- Promise.resolve(opening).then(function() {
- return DialogManager.close(dialog, type, options);
- }).then(function(result) {
- self._navigating = false;
- defer.resolve({type: type, value: result});
- self._execPendingRequest();
- }, function(error) {
- // Panel validation rejects to keep the form open. Allow a
- // correction or cancellation without rejecting the show promise.
- closing = false;
- dialogDOM.dispatchEvent(new CustomEvent('dialogvalidationfailed', {
- detail: error
- }));
- });
- }
- options.onWrapSubmit = function() { close('submit'); };
- options.onWrapCancel = function() { close('cancel'); };
-
- switch (options.type) {
- case 'alert':
- dialog = AlertDialog(dialogDOM, options);
- break;
- case 'confirm':
- dialog = ConfirmDialog(dialogDOM, options);
- break;
- case 'prompt':
- dialog = PromptDialog(dialogDOM, options);
- break;
- default:
- dialog = PanelDialog(dialogDOM, options);
- break;
- }
- this._navigating = true;
- opening = DialogManager.open(dialog, options);
- opening.catch(function(error) {
- // A missing host API must not lock every later Settings dialog.
- dialogDOM.classList.remove('current');
- dialogDOM.hidden = true;
- var overlay = document.querySelector('.settings-dialog-overlay');
- if (overlay) overlay.hidden = true;
- self._navigating = false;
- defer.reject(error);
- self._execPendingRequest();
- });
- }
- }
-
- return defer.promise;
- },
-
- /**
- * This method can help us pop up any pending request and would try to
- * show it after previous request was done.
- *
- * @memberOf DialogService
- * @access private
- */
- _execPendingRequest: function() {
- var request = this._pendingRequests.pop();
- if (request) {
- this.show(request.panelId, request.userOptions, request.defer);
- }
- }
- };
-
- var dialogService = new DialogService();
- return dialogService;
-});
diff --git a/overrides/settings.gaiamobile.org/js/panels/display/panel.js b/overrides/settings.gaiamobile.org/js/panels/display/panel.js
index e2da4234..d0a8914c 100644
--- a/overrides/settings.gaiamobile.org/js/panels/display/panel.js
+++ b/overrides/settings.gaiamobile.org/js/panels/display/panel.js
@@ -1,52 +1,4 @@
-/**
- * Update setting value based on slider position.
- * Base on sound panel's implementation.
- *
- * @module SliderHandler
- */
-/**
- * Update setting value based on slider position.
- * Base on sound panel's implementation.
- *
- * @module SliderHandler
- */
-/**
- * Update setting value based on slider position.
- * Base on sound panel's implementation.
- *
- * @module SliderHandler
- */
-/**
- * Update setting value based on slider position.
- * Base on sound panel's implementation.
- *
- * @module SliderHandler
- */
-/**
- * Update setting value based on slider position.
- * Base on sound panel's implementation.
- *
- * @module SliderHandler
- */
-/**
- * Update setting value based on slider position.
- * Base on sound panel's implementation.
- *
- * @module SliderHandler
- */
-/**
- * Update setting value based on slider position.
- * Base on sound panel's implementation.
- *
- * @module SliderHandler
- */
-/**
- * Update setting value based on slider position.
- * Base on sound panel's implementation.
- *
- * @module SliderHandler
- */
/**
* Update setting value based on slider position.
* Base on sound panel's implementation.
@@ -176,5 +128,6 @@ define('panels/display/slider_handler',["require"],function(require) {
return new SliderHandler();
};
});
+
define('panels/display/display',['require','shared/settings_listener','panels/display/slider_handler'],function(require){'use strict';var SettingsListener=require('shared/settings_listener');var SliderHandler=require('panels/display/slider_handler');const AUTO_BRIGHTNESS_SETTING='screen.automatic-brightness';const SCREEN_BRIGHTNESS='screen.brightness';var Display=function(){this.elements=null;};Display.prototype={init:function d_init(elements,data){this.elements=elements;this.initBrightnessItems(data);SliderHandler().init(elements.brightnessManualInput,SCREEN_BRIGHTNESS);},initBrightnessItems:function d_init_brightness_items(data){var brightnessAuto=this.elements.brightnessAuto;var brightnessAutoCheckbox=this.elements.brightnessAutoCheckbox;var brightnessManual=this.elements.brightnessManual;var brightnessManualInput=this.elements.brightnessManualInput;if(data.ambientLight){brightnessAuto.hidden=false;SettingsListener.observe(AUTO_BRIGHTNESS_SETTING,false,function(value){brightnessAutoCheckbox.checked=value;brightnessManual.hidden=value;}.bind(this));}else{brightnessAuto.hidden=true;brightnessManual.hidden=false;var cset={};cset[AUTO_BRIGHTNESS_SETTING]=false;SettingsListener.getSettingsLock().set(cset);}
SettingsListener.observe(SCREEN_BRIGHTNESS,0.5,function(value){brightnessManualInput.value=value;}.bind(this));}};return function ctor_display(){return new Display();};});define('panels/display/panel',['require','modules/settings_panel','panels/display/display','shared/lazy_loader'],function(require){'use strict';var SettingsPanel=require('modules/settings_panel');var DisplayModule=require('panels/display/display');var LazyLoader=require('shared/lazy_loader');var displayElements={};return function ctor_display_panel(){var display=DisplayModule();return SettingsPanel({onInit:function dp_onInit(panel){displayElements={brightnessManual:panel.querySelector('.brightness-manual'),brightnessManualInput:panel.querySelector('.brightness-manual input'),brightnessAuto:panel.querySelector('.brightness-auto'),brightnessAutoCheckbox:panel.querySelector('.brightness-auto gaia-checkbox')};LazyLoader.getJSON('/resources/device-features.json').then(function(data){display.init(displayElements,data);});}});};});
\ No newline at end of file
diff --git a/overrides/settings.gaiamobile.org/js/panels/feedback_send/panel.js b/overrides/settings.gaiamobile.org/js/panels/feedback_send/panel.js
deleted file mode 100644
index 84a028a3..00000000
--- a/overrides/settings.gaiamobile.org/js/panels/feedback_send/panel.js
+++ /dev/null
@@ -1,492 +0,0 @@
-
-define('modules/dialog_manager',["require", "modules/panel_cache", "shared/lazy_loader"],function(require) {
- 'use strict';
-
- var PanelCache = require('modules/panel_cache');
- var LazyLoader = require('shared/lazy_loader');
-
- var DialogManager = function() {
- this.OVERLAY_SELECTOR = '.settings-dialog-overlay';
-
- this._overlayDOM = document.querySelector(this.OVERLAY_SELECTOR);
- };
-
- DialogManager.prototype = {
- /**
- * load panel based on passed in panelId
- *
- * @memberOf DialogManager
- * @access private
- * @param {String} panelId
- * @return {Promise}
- */
- _loadPanel: function dm__loadPanel(panelId) {
- var promise = new Promise(function(resolve, reject) {
- var panelElement = document.getElementById(panelId);
- if (panelElement.dataset.rendered) { // already initialized
- resolve();
- return;
- }
-
- panelElement.dataset.rendered = true;
-
- // XXX remove SubPanel loader once sub panel are modulized
- if (panelElement.dataset.requireSubPanels) {
- // load the panel and its sub-panels (dependencies)
- // (load the main panel last because it contains the scripts)
- var selector = 'section[id^="' + panelElement.id + '-"]';
- var subPanels = document.querySelectorAll(selector);
- for (var i = 0, il = subPanels.length; i < il; i++) {
- LazyLoader.load([subPanels[i]]);
- }
- LazyLoader.load([panelElement], resolve);
- } else {
- LazyLoader.load([panelElement], resolve);
- }
- });
- return promise;
- },
-
- /**
- * promised version of PanelCache.get()
- *
- * @memberOf DialogManager
- * @access private
- * @param {String} panelId
- * @return {Promise}
- */
- _getPanel: function dm__getPanel(panelId) {
- var promise = new Promise(function(resolve) {
- PanelCache.get(panelId, function(panel) {
- resolve(panel);
- });
- });
- return promise;
- },
-
- /**
- * this is used to control visibility of overlayDOM
- *
- * @memberOf DialogManager
- * @access private
- * @param {Boolean} show
- */
- _showOverlay: function dm__showOverlay(show) {
- this._overlayDOM.hidden = !show;
- },
-
- /**
- * It is used to control the timing of transitions so that we can make sure
- * whether animation is done or not.
- *
- * @memberOf DialogManager
- * @access private
- * @param {String} method
- * @param {BaseDialog} dialog
- * @param {Object} options
- * @return {Promise}
- */
- _transit: function dm__transit(method, dialog, options) {
- var promise = new Promise(function(resolve) {
- var panel = dialog.panel;
- var completed = false;
- var fallback;
- function finish(event) {
- if (event && (event.target !== panel || event.propertyName !== 'visibility')) return;
- if (completed) return;
- completed = true;
- clearTimeout(fallback);
- panel.removeEventListener('transitionend', finish);
- panel.removeEventListener('transitioncancel', finish);
- if (method === 'close') panel.hidden = true;
- resolve();
- }
- if (method === 'open') panel.hidden = false;
- setTimeout(function() {
- panel.addEventListener('transitionend', finish);
- panel.addEventListener('transitioncancel', finish);
- panel.classList.toggle('current', method === 'open');
- var style = window.getComputedStyle(panel);
- function milliseconds(value) {
- return parseFloat(value) * (value.trim().endsWith('ms') ? 1 : 1000) || 0;
- }
- var durations = style.transitionDuration.split(',').map(milliseconds);
- var delays = style.transitionDelay.split(',').map(milliseconds);
- var duration = Math.max.apply(Math, durations.map(function(value, index) {
- return value + delays[index % delays.length];
- }));
- // Reduced motion, display changes or interrupted transitions may
- // omit the visibility event. Always release the dialog queue.
- fallback = setTimeout(finish, Math.max(0, duration) + 100);
- }, 150);
- });
- return promise;
- },
-
- /**
- * Do necessary works to open panel like loading panel, doing transition
- * and call related functions.
- *
- * @memberOf DialogManager
- * @access private
- * @param {BaseDialog} dialog
- * @param {Object} options
- * @return {Promise}
- */
- _open: function dm__open(dialog, options) {
- var self = this;
- var foundPanel;
-
- return Promise.resolve()
- .then(function() {
- // 1: load panel
- return self._loadPanel(dialog.panel.id);
- })
- .then(function() {
- // 2: Get that panel
- return self._getPanel(dialog.panel.id);
- })
- .then(function(panel) {
- // 3: call beforeShow
- foundPanel = panel;
- return foundPanel.beforeShow(dialog.panel, options);
- })
- .then(function() {
- // 4. UI stuffs + transition
- dialog.init();
- dialog.initUI();
- dialog.bindEvents();
-
- if (dialog.TRANSITION_CLASS === 'zoom-in-80') {
- self._showOverlay(true);
- }
-
- return self._transit('open', dialog, options);
- })
- .then(function() {
- // 5. show that panel as a dialog
- return foundPanel.show(dialog.panel, options);
- });
- },
-
- /**
- * Do necessary works to close panel like loading panel, doing transition
- * and call related functions.
- *
- * @memberOf DialogManager
- * @access private
- * @param {BaseDialog} dialog
- * @param {Object} options
- * @return {Promise}
- */
- _close: function dm__close(dialog, options) {
- var self = this;
- var foundPanel;
- var cachedResult;
-
- return Promise.resolve()
- .then(function() {
- // 1: Get that panel
- return self._getPanel(dialog.panel.id);
- })
- .then(function(panel) {
- // 2: Let's validate to see whether we can close this dialog or not.
- foundPanel = panel;
-
- var promise;
- // custom dialog - onSubmit
- if (foundPanel.onSubmit && options._type === 'submit') {
- promise = foundPanel.onSubmit();
- // custom dialog - onCancel
- } else if (foundPanel.onCancel && options._type === 'cancel') {
- promise = foundPanel.onCancel();
- // if no onSubmit & onCancel, pass directly
- } else {
- promise = Promise.resolve();
- }
-
- return promise;
- })
- .then(function(result) {
- cachedResult = result;
-
- // 3: call beforeHide
- return foundPanel.beforeHide();
- })
- .then(function() {
- // 4. transition
- return self._transit('close', dialog, options);
- })
- .then(function() {
- // 5. call hide
- return foundPanel.hide();
- })
- .then(function() {
- // 6. Get result and cleanup dialog
- var result;
-
- // for prompt dialog, we have to get its own result from input text.
- if (dialog.DIALOG_CLASS === 'prompt-dialog') {
- result = dialog.getResult();
- } else if (cachedResult) {
- result = cachedResult;
- }
-
- dialog.cleanup();
-
- if (dialog.TRANSITION_CLASS === 'zoom-in-80') {
- self._showOverlay(false);
- }
-
- return result;
- });
- },
-
- /**
- * It is a bridge to call open or close function.
- *
- * @memberOf DialogManager
- * @access private
- * @param {String} method
- * @param {BaseDialog} dialog
- * @param {Object} options
- * @return {Promise}
- */
- _navigate: function dm__navigate(method, dialog, options) {
- method = (method === 'open') ? '_open' : '_close';
- return this[method](dialog, options);
- },
-
- /**
- * DialogService would use this exposed API to open dialog.
- *
- * @memberOf DialogManager
- * @access public
- * @param {BaseDialog} dialog
- * @param {Object} options
- * @return {Promise}
- */
- open: function dm_open(dialog, options) {
- return this._navigate('open', dialog, options);
- },
-
- /**
- * DialogService would use this exposed API to close dialog.
- *
- * @memberOf DialogManager
- * @access public
- * @param {BaseDialog} dialog
- * @param {Object} options
- * @return {Promise}
- */
- close: function dm_close(dialog, type, options) {
- options._type = type;
- return this._navigate('close', dialog, options);
- }
- };
-
- var dialogManager = new DialogManager();
- return dialogManager;
-});
-define('modules/dialog/base_dialog',['require'],function(require){'use strict';var BaseDialog=function(panelDOM,options){this.panel=panelDOM;this._options=options||{};};BaseDialog.prototype.DIALOG_CLASS='dialog';BaseDialog.prototype.TRANSITION_CLASS='fade';BaseDialog.prototype.SUBMIT_BUTTON_SELECTOR='[type="submit"]';BaseDialog.prototype.CANCEL_BUTTON_SELECTOR='[type="reset"]';BaseDialog.prototype.MESSAGE_SELECTOR='.settings-dialog-message';BaseDialog.prototype.TITLE_SELECTOR='.settings-dialog-title';BaseDialog.prototype.init=function bd_init(){this.TRANSITION_CLASS=this._options.transition||this.TRANSITION_CLASS;this.panel.classList.add(this.DIALOG_CLASS);this.panel.classList.add(this.TRANSITION_CLASS);};BaseDialog.prototype.initUI=function bd_initUI(){var message=this._options.message;var title=this._options.title;var submitButton=this._options.submitButton;var cancelButton=this._options.cancelButton;this._updateMessage(message);this._updateTitle(title);this._updateSubmitButton(submitButton);this._updateCancelButton(cancelButton);};BaseDialog.prototype.bindEvents=function bd_bindEvent(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};BaseDialog.prototype._updateMessage=function bd__updateMessage(message){var messageDOM=this.panel.querySelector(this.MESSAGE_SELECTOR);if(messageDOM&&message){message=this._getWrapL10nObject(message);document.l10n.setAttributes(messageDOM,message.id,message.args);}};BaseDialog.prototype._updateTitle=function bd__updateTitle(title){var titleDOM=this.panel.querySelector(this.TITLE_SELECTOR);if(titleDOM&&title){title=this._getWrapL10nObject(title);document.l10n.setAttributes(titleDOM,title.id,title.args);}};BaseDialog.prototype._updateSubmitButton=function bd__update(options){var buttonDOM=this.getSubmitButton();if(buttonDOM&&options){options=this._getWrapL10nObject(options);document.l10n.setAttributes(buttonDOM,options.id,options.args);buttonDOM.className=options.style||'recommend';}};BaseDialog.prototype._updateCancelButton=function bd__updateText(options){var buttonDOM=this.getCancelButton();if(buttonDOM&&options){options=this._getWrapL10nObject(options);document.l10n.setAttributes(buttonDOM,options.id,options.args);buttonDOM.className=options.style||'';}};BaseDialog.prototype._getWrapL10nObject=function bd__getWrapL10nObject(input){if(typeof input==='string'){return{id:input,args:null};}else if(typeof input==='object'){if(typeof input.id==='undefined'){throw new Error('You forgot to put l10nId - '+
-JSON.stringify(input));}else{return{id:input.id,args:input.args||null,style:input.style};}}else{throw new Error('You are using the wrong L10nObject, '+'please check its format again');}};BaseDialog.prototype.getDOM=function bd_getDOM(){return this.panel;};BaseDialog.prototype.getSubmitButton=function bd_getSubmitButton(){return this.panel.querySelector(this.SUBMIT_BUTTON_SELECTOR);};BaseDialog.prototype.getCancelButton=function bd_getCancelButton(){return this.panel.querySelector(this.CANCEL_BUTTON_SELECTOR);};BaseDialog.prototype.cleanup=function bd_cleanup(){if(this.DIALOG_CLASS!=='panel-dialog'){this._updateTitle('settings-'+this.DIALOG_CLASS+'-header');this._updateSubmitButton('ok');this._updateCancelButton('cancel');}
-this.panel.classList.remove(this.DIALOG_CLASS);this.panel.classList.remove(this.TRANSITION_CLASS);};return BaseDialog;});define('modules/dialog/panel_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var PanelDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};PanelDialog.prototype=Object.create(BaseDialog.prototype);PanelDialog.prototype.constructor=PanelDialog;PanelDialog.prototype.DIALOG_CLASS='panel-dialog';PanelDialog.prototype.TRANSITION_CLASS='fade';return function ctor_PanelDialog(panelDOM,options){return new PanelDialog(panelDOM,options);};});define('modules/dialog/alert_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var AlertDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};AlertDialog.prototype=Object.create(BaseDialog.prototype);AlertDialog.prototype.constructor=AlertDialog;AlertDialog.prototype.DIALOG_CLASS='alert-dialog';AlertDialog.prototype.TRANSITION_CLASS='fade';AlertDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};};return function ctor_alertDialog(panelDOM,options){return new AlertDialog(panelDOM,options);};});define('modules/dialog/confirm_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var ConfirmDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};ConfirmDialog.prototype=Object.create(BaseDialog.prototype);ConfirmDialog.prototype.constructor=ConfirmDialog;ConfirmDialog.prototype.DIALOG_CLASS='confirm-dialog';ConfirmDialog.prototype.TRANSITION_CLASS='fade';ConfirmDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};return function ctor_confirmDialog(panelDOM,options){return new ConfirmDialog(panelDOM,options);};});define('modules/dialog/prompt_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var PromptDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};PromptDialog.prototype=Object.create(BaseDialog.prototype);PromptDialog.prototype.constructor=PromptDialog;PromptDialog.prototype.DIALOG_CLASS='prompt-dialog';PromptDialog.prototype.TRANSITION_CLASS='fade';PromptDialog.prototype.INPUT_SELECTOR='.settings-dialog-input';PromptDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};PromptDialog.prototype.initUI=function(){BaseDialog.prototype.initUI.call(this);this.getInput().value=this._options.defaultValue||'';};PromptDialog.prototype.getInput=function(){return this.panel.querySelector(this.INPUT_SELECTOR);};PromptDialog.prototype.getResult=function(){return this.getInput().value;};return function ctor_promptDialog(panelDOM,options){return new PromptDialog(panelDOM,options);};});define('modules/dialog_service',["require", "settings", "modules/defer", "modules/dialog_manager", "modules/dialog/panel_dialog", "modules/dialog/alert_dialog", "modules/dialog/confirm_dialog", "modules/dialog/prompt_dialog"],function(require) {
- 'use strict';
-
- var Settings = require('settings');
- var Defer = require('modules/defer');
- var DialogManager = require('modules/dialog_manager');
-
- var PanelDialog = require('modules/dialog/panel_dialog');
- var AlertDialog = require('modules/dialog/alert_dialog');
- var ConfirmDialog = require('modules/dialog/confirm_dialog');
- var PromptDialog = require('modules/dialog/prompt_dialog');
-
- var DialogService = function() {
- this._navigating = false;
- this._pendingRequests = [];
- this._settingsAlertDialogId = 'settings-alert-dialog';
- this._settingsBaseDialogId = 'settings-base-dialog';
- this._settingsConfirmDialogId = 'settings-confirm-dialog';
- this._settingsPromptDialogId = 'settings-prompt-dialog';
- };
-
- DialogService.prototype = {
- /**
- * Alert dialog with more controls.
- *
- * @memberOf DialogService
- * @access public
- * @param {String} message
- * @param {Object} userOptions
- * @return {Promise}
- */
- alert: function(message, userOptions) {
- var options = userOptions || {};
- return this.show(this._settingsAlertDialogId, {
- type: 'alert',
- message: message,
- title: options.title,
- submitButton: options.submitButton
- });
- },
-
- /**
- * Confirm dialog with more controls.
- *
- * @memberOf DialogService
- * @access public
- * @param {String} message
- * @param {Object} userOptions
- * @return {Promise}
- */
- confirm: function(message, userOptions) {
- var options = userOptions || {};
- return this.show(this._settingsConfirmDialogId, {
- type: 'confirm',
- message: message,
- title: options.title,
- submitButton: options.submitButton,
- cancelButton: options.cancelButton
- });
- },
-
- /**
- * Prompt dialog with more controls.
- *
- * @memberOf DialogService
- * @access public
- * @param {String} message
- * @param {Object} userOptions
- * @return {Promise}
- */
- prompt: function(message, userOptions) {
- var options = userOptions || {};
- return this.show(this._settingsPromptDialogId, {
- type: 'prompt',
- message: message,
- title: options.title,
- defaultValue: options.defaultValue,
- submitButton: options.submitButton,
- cancelButton: options.cancelButton
- });
- },
-
- /**
- * Panel dialog. If you are going to show any panel as a dialog,
- * you have to use this method to show them.
- *
- * @memberOf DialogService
- * @access public
- * @param {String} panelId
- * @param {Object} userOptions
- * @return {Promise}
- */
- show: function dm_show(panelId, userOptions, _pendingDefer) {
- var self = this;
- var defer;
- var dialog;
- var dialogDOM = document.getElementById(panelId);
- var currentPanel = Settings.currentPanel;
- var options = userOptions || {};
-
- if (_pendingDefer) {
- defer = _pendingDefer;
- } else {
- defer = Defer();
- }
-
- if (this._navigating) {
- this._pendingRequests.push({
- defer: defer,
- panelId: panelId,
- userOptions: userOptions
- });
- } else {
- if ('#' + panelId === currentPanel) {
- defer.reject('You are showing the same panel #' + panelId);
- } else {
- var opening;
- var closing = false;
- function close(type) {
- if (closing) return;
- closing = true;
- // A tap during the opening animation must not run hide/show
- // concurrently, or a queued dialog can inherit stale form values.
- Promise.resolve(opening).then(function() {
- return DialogManager.close(dialog, type, options);
- }).then(function(result) {
- self._navigating = false;
- defer.resolve({type: type, value: result});
- self._execPendingRequest();
- }, function(error) {
- // Panel validation rejects to keep the form open. Allow a
- // correction or cancellation without rejecting the show promise.
- closing = false;
- dialogDOM.dispatchEvent(new CustomEvent('dialogvalidationfailed', {
- detail: error
- }));
- });
- }
- options.onWrapSubmit = function() { close('submit'); };
- options.onWrapCancel = function() { close('cancel'); };
-
- switch (options.type) {
- case 'alert':
- dialog = AlertDialog(dialogDOM, options);
- break;
- case 'confirm':
- dialog = ConfirmDialog(dialogDOM, options);
- break;
- case 'prompt':
- dialog = PromptDialog(dialogDOM, options);
- break;
- default:
- dialog = PanelDialog(dialogDOM, options);
- break;
- }
- this._navigating = true;
- opening = DialogManager.open(dialog, options);
- opening.catch(function(error) {
- // A missing host API must not lock every later Settings dialog.
- dialogDOM.classList.remove('current');
- dialogDOM.hidden = true;
- var overlay = document.querySelector('.settings-dialog-overlay');
- if (overlay) overlay.hidden = true;
- self._navigating = false;
- defer.reject(error);
- self._execPendingRequest();
- });
- }
- }
-
- return defer.promise;
- },
-
- /**
- * This method can help us pop up any pending request and would try to
- * show it after previous request was done.
- *
- * @memberOf DialogService
- * @access private
- */
- _execPendingRequest: function() {
- var request = this._pendingRequests.pop();
- if (request) {
- this.show(request.panelId, request.userOptions, request.defer);
- }
- }
- };
-
- var dialogService = new DialogService();
- return dialogService;
-});
-define('panels/feedback_send/feedback_send',['require','modules/dialog_service','modules/settings_service','modules/settings_cache','shared/async_storage'],function(require){'use strict';var DialogService=require('modules/dialog_service');var SettingsService=require('modules/settings_service');var SettingsCache=require('modules/settings_cache');require('shared/async_storage');var SendFeedback=function(){};SendFeedback.prototype={_SettingsCache:SettingsCache,_SettingsService:SettingsService,init:function(elements){this.elements=elements;this.options={};this._sendData={product:'Firefox OS',platform:'Firefox OS'};this._showEmail=false;},_isHappy:function(){var happy=this.options&&this.options.feel;return happy==='feedback-happy';},updateTitle:function(){var id='feedback_whyfeel_'+(this._isHappy()?'happy':'sad');this.elements.title.setAttribute('data-l10n-id',id);},getPreviousInputs:function(){window.asyncStorage.getItem('feedback',function(value){this._inputData=value||{};}.bind(this));},keepAllInputs:function(){window.asyncStorage.setItem('feedback',this._inputData);},get _inputData(){return{description:this.elements.description.value,email:this.elements.emailInput.value,emailEnable:this._showEmail};},set _inputData(data){this.elements.description.value=data.description||'';this.elements.emailInput.value=data.email||'';this._showEmail=!data.emailEnable;this.enableEmail();},send:function(){this.elements.sendBtn.disabled=true;if(!navigator.onLine){this._messageHandler('connect-error');return;}
-var emailBar=this.elements.emailColumn;var emailInput=this.elements.emailInput;var contextInput=this.elements.description;if(contextInput.textLength===0){this._messageHandler('empty-comment');return;}else{this._sendData.description=contextInput.value;}
-if(!emailBar.hidden){this._sendData.email=emailInput.value;}else{delete this._sendData.email;}
-if(!emailBar.hidden&&(!emailInput.value.length||!emailInput.validity.valid)){this._messageHandler('wrong-email');return;}
-var currentSetting=this._SettingsCache.cache;var feedbackUrl=currentSetting['feedback.url'];this._sendData.version=currentSetting['deviceinfo.os'];this._sendData.device=currentSetting['deviceinfo.hardware'];this._sendData.locale=currentSetting['language.current'];this._sendData.happy=this._isHappy();this._xhr=new XMLHttpRequest({mozSystem:true});this._xhr.open('POST',feedbackUrl,true);this._xhr.setRequestHeader('Content-type','application/json');this._xhr.timeout=5000;this._xhr.onload=this._responseHandler.bind(this);this._xhr.ontimeout=function(){this._messageHandler('timeout');}.bind(this);this._xhr.send(JSON.stringify(this._sendData));},enableEmail:function(){var original=this._showEmail;this._showEmail=!original;this.elements.emailEnable.checked=!original;this.elements.emailColumn.hidden=original;},back:function(){this.keepAllInputs();this._SettingsService.navigate('improveBrowserOS-chooseFeedback');},_responseHandler:function(){switch(this._xhr.status){case 201:this._messageHandler('success');break;case 429:this._messageHandler('success');break;case 404:this._messageHandler('server-off');break;case 400:this._messageHandler('unknown-error');break;default:this._messageHandler('unknown-error');break;}},_messageHandler:function(type){if(type==='success'){this._openDoneDialog();}else{this.keepAllInputs();this._openAlertDialog(type);}
-this.elements.sendBtn.disabled=false;},_openAlertDialog:function(type){DialogService.alert({id:'feedback-errormessage-'+type},{submitButton:{id:'ok',style:'full'}});},_openDoneDialog:function(){DialogService.alert({id:'feedback-complete-msg'},{title:'sendFeedbackTitle',submitButton:{id:'done',style:'full'}}).then(function(){this._SettingsService.navigate('improveBrowserOS');}.bind(this));}};return function ctor_send_feedback(){return new SendFeedback();};});define('panels/feedback_send/panel',['require','modules/settings_panel','panels/feedback_send/feedback_send'],function(require){'use strict';var SettingsPanel=require('modules/settings_panel');var SendFeedback=require('panels/feedback_send/feedback_send');return function ctor_sendFeedbackPanel(){var elements={};var sendFeedback=SendFeedback();var eventMapping=[{elementName:'sendBtn',eventType:'click',methodName:'send'},{elementName:'emailEnable',eventType:'change',methodName:'enableEmail'},{elementName:'header',eventType:'action',methodName:'back'}];function bindEvents(elements){eventMapping.forEach(function(map){map.method=sendFeedback[map.methodName].bind(sendFeedback);elements[map.elementName].addEventListener(map.eventType,map.method);});}
-function unbindEvents(elements){eventMapping.forEach(function(map){if(!map.method){return;}
-elements[map.elementName].removeEventListener(map.eventType,map.method);});}
-return SettingsPanel({onInit:function(panel){elements={title:panel.querySelector('#feedback-title'),description:panel.querySelector('#feedback-description'),emailInput:panel.querySelector('#feedback-email'),emailColumn:panel.querySelector('#feedback-emailbar'),emailEnable:panel.querySelector('#email-enable'),sendBtn:panel.querySelector('#feedback-send-btn'),header:panel.querySelector('#feedback-header')};sendFeedback.init(elements);},onBeforeShow:function(panel,options){bindEvents(elements);sendFeedback.options=options;sendFeedback.updateTitle();sendFeedback.getPreviousInputs();},onBeforeHide:function(){unbindEvents(elements);if(document.hidden){sendFeedback.keepAllInputs();}}});};});
\ No newline at end of file
diff --git a/overrides/settings.gaiamobile.org/js/panels/firefox_accounts/panel.js b/overrides/settings.gaiamobile.org/js/panels/firefox_accounts/panel.js
deleted file mode 100644
index 1710566e..00000000
--- a/overrides/settings.gaiamobile.org/js/panels/firefox_accounts/panel.js
+++ /dev/null
@@ -1,508 +0,0 @@
-
-var Normalizer={initAsciiNormalizer:function normalizer_init(){var equivalentChars={'a':'áăǎâäȧạȁàảȃāąåḁⱥãǽǣæ','A':'ÁĂǍÂÄȦẠȀÀẢȂĀĄÅḀȺÃǼǢÆ','b':'ḃḅɓḇƀƃ','B':'ḂḄƁḆɃƂ','c':'ćčçĉċƈȼ','C':'ĆČÇĈĊƇȻ','d':'ďḑḓḋḍɗḏđƌð','D':'ĎḐḒḊḌƊḎĐƋ','e':'éĕěȩêḙëėẹȅèẻȇēę','E':'ÉĔĚȨÊḘËĖẸȄÈẺȆĒĘ','f':'ḟƒ','F':'ḞƑ','g':'ǵğǧģĝġɠḡǥ','G':'ǴĞǦĢĜĠƓḠǤ','h':'ḫȟḩĥⱨḧḣḥħ','H':'ḪȞḨĤⱧḦḢḤĦ','i':'íĭǐîïịȉìỉȋīįɨĩḭı','I':'ÍĬǏÎÏỊȈÌỈȊĪĮƗĨḬ','j':'ĵɉ','J':'ĴɈ','k':'ḱǩķⱪꝃḳƙḵꝁ','K':'ḰǨĶⱩꝂḲƘḴꝀ','l':'ĺƚľļḽḷⱡꝉḻŀɫł','L':'ĹȽĽĻḼḶⱠꝈḺĿⱢŁ','m':'ḿṁṃɱ','M':'ḾṀṂⱮ','n':'ńňņṋṅṇǹɲṉƞñ','N':'ŃŇŅṊṄṆǸƝṈȠÑ','o':'óŏǒôöȯọőȍòỏơȏꝋꝍōǫøõœ','O':'ÓŎǑÔÖȮỌŐȌÒỎƠȎꝊꝌŌǪØÕŒ','p':'ṕṗꝓƥᵽꝑ','P':'ṔṖꝒƤⱣꝐ','q':'ꝗ','Q':'Ꝗ','r':'ŕřŗṙṛȑȓṟɍɽ','R':'ŔŘŖṘṚȐȒṞɌⱤ','s':'śšşŝșṡṣß','S':'ŚŠŞŜȘṠṢ','t':'ťţṱțⱦṫṭƭṯʈŧ','T':'ŤŢṰȚȾṪṬƬṮƮŦ','u':'úŭǔûṷüṳụűȕùủưȗūųůũṵ','U':'ÚŬǓÛṶÜṲỤŰȔÙỦƯȖŪŲŮŨṴ','v':'ṿʋṽ','V':'ṾƲṼ','w':'ẃŵẅẇẉẁⱳ','W':'ẂŴẄẆẈẀⱲ','x':'ẍẋ','X':'ẌẊ','y':'ýŷÿẏỵỳƴỷỿȳɏỹ','Y':'ÝŶŸẎỴỲƳỶỾȲɎỸ','z':'źžẑⱬżẓȥẕƶ','Z':'ŹŽẐⱫŻẒȤẔƵ'};this._toAsciiForm={};for(var letter in equivalentChars){var accentedForms=equivalentChars[letter];for(var i=accentedForms.length-1;i>=0;i--){this._toAsciiForm[accentedForms[i]]=letter;}}},toAscii:function normalizer_toAscii(str){if(!str||typeof str!='string'){return'';}
-if(!this._toAsciiForm){Normalizer.initAsciiNormalizer();}
-var result='';for(var i=0,len=str.length;i/g,'>');if(escapeQuotes){return escaped.replace(/"/g,'"').replace(/'/g,''');}
-return escaped;},unescapeHTML:function normalizer_unescapeHTML(text){return text.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,'\'');},escapeRegExp:function normalizer_escapeRegExp(str){return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g,'\\$&');}};define("shared/text_normalizer",(function(global){return function(){var ret,fn;return ret||global.Normalizer;};}(this)));var FxAccountsIACHelper=function FxAccountsIACHelper(){var DEFAULT_CONNECTION_STRING='fxa-mgmt';var default_rules={'manifestURLs':['app://system.gaiamobile.org/manifest.webapp','app://smart-system.gaiamobile.org/manifest.webapp']};var CONNECTION_STRING=DEFAULT_CONNECTION_STRING;var rules=default_rules;var port;var callbacks={};var eventListeners={};var init=function init(options){if(!options){return;}
-if(options.keyword&&typeof(options.keyword)=='string'&&options.keyword.length>0){CONNECTION_STRING=options.keyword;}
-if(options.rules&&typeof(options.rules)=='object'){rules=options.rules;}};var addEventListener=function addEventListener(eventName,listener){if(!eventListeners[eventName]){eventListeners[eventName]=[];}
-eventListeners[eventName].push(listener);};var removeEventListener=function removeEventListener(eventName,listener){var listeners=eventListeners[eventName];if(!listeners){return;}
-var index=listeners.indexOf(listener);if(index===-1){return;}
-listeners.splice(index,1);};var reset=function reset(){CONNECTION_STRING=DEFAULT_CONNECTION_STRING;rules=default_rules;eventListeners={};callbacks={};port=null;};var onMessage=function onMessage(evt){if(evt&&evt.data){var message=evt.data;if(message.eventName){var listeners=eventListeners[message.eventName];if(!listeners){return;}
-for(var listener in listeners){if(listeners[listener]&&typeof listeners[listener]==='function'){listeners[listener]();}}
-return;}
-var cbs,cb;if(message.methodName){cbs=callbacks[message.methodName];if(!cbs||!cbs.length){console.warn('No callbacks for method '+message.methodName);return;}
-while(cbs.length){cb=cbs.shift();if(!message.error){cb.successCb(message.data);}else{cb.errorCb(message.error);}}}}else{console.error('Unknown');}};var getSelf=function getSelf(cb,error){var request=navigator.mozApps.getSelf();request.onsuccess=function onSuccess(evt){cb(evt.target.result);};request.onerror=function onError(){if(error&&typeof(error)==='function'){error();}};};var requestQueue=[];var isConnecting=false;var connect=function connect(cb){if(isConnecting){return requestQueue.push(cb);}
-isConnecting=true;_connect(function onConnect(err){var next;isConnecting=false;if(typeof cb==='function'){cb(err);}
-while(!!(next=requestQueue.shift())){next&&next(err);}});};var _connect=function _connect(callback){getSelf(function onApp(app){if(!app){return;}
-app.connect(CONNECTION_STRING,rules).then(function(ports){if(!ports||ports.length!==1){return;}
-port=ports[0];callback&&callback();});});};var sendMessage=function sendMessage(message,successCb,errorCb){if(port){_sendMessage(message,successCb,errorCb);return;}
-connect(function(){_sendMessage(message,successCb,errorCb);});};var _sendMessage=function _sendMessage(message,successCb,errorCb){var name=message.name;if(!name){return;}
-if(!callbacks[name]){callbacks[name]=[];}
-callbacks[name].push({successCb:successCb,errorCb:errorCb});port.onmessage=onMessage;port.postMessage(message);};var getAccount=function getAccount(successCb,errorCb){sendMessage({'name':'getAccount'},successCb,errorCb);};var openFlow=function openFlow(successCb,errorCb){sendMessage({'name':'openFlow'},successCb,errorCb);};var logout=function logout(successCb,errorCb){sendMessage({'name':'logout'},successCb,errorCb);};var refreshAuthentication=function refreshAuthentication(email,successCb,errorCb){sendMessage({'name':'refreshAuthentication','email':email},successCb,errorCb);};var resendVerificationEmail=function resendVerificationEmail(email,successCb,errorCb){sendMessage({'name':'resendVerificationEmail','email':email},successCb,errorCb);};connect();return{'addEventListener':addEventListener,'getAccount':getAccount,'init':init,'logout':logout,'openFlow':openFlow,'refreshAuthentication':refreshAuthentication,'removeEventListener':removeEventListener,'resendVerificationEmail':resendVerificationEmail,'reset':reset};}();define("shared/fxa_iac_client",(function(global){return function(){var ret,fn;return ret||global.FxAccountsIACHelper;};}(this)));define('modules/dialog_manager',["require", "modules/panel_cache", "shared/lazy_loader"],function(require) {
- 'use strict';
-
- var PanelCache = require('modules/panel_cache');
- var LazyLoader = require('shared/lazy_loader');
-
- var DialogManager = function() {
- this.OVERLAY_SELECTOR = '.settings-dialog-overlay';
-
- this._overlayDOM = document.querySelector(this.OVERLAY_SELECTOR);
- };
-
- DialogManager.prototype = {
- /**
- * load panel based on passed in panelId
- *
- * @memberOf DialogManager
- * @access private
- * @param {String} panelId
- * @return {Promise}
- */
- _loadPanel: function dm__loadPanel(panelId) {
- var promise = new Promise(function(resolve, reject) {
- var panelElement = document.getElementById(panelId);
- if (panelElement.dataset.rendered) { // already initialized
- resolve();
- return;
- }
-
- panelElement.dataset.rendered = true;
-
- // XXX remove SubPanel loader once sub panel are modulized
- if (panelElement.dataset.requireSubPanels) {
- // load the panel and its sub-panels (dependencies)
- // (load the main panel last because it contains the scripts)
- var selector = 'section[id^="' + panelElement.id + '-"]';
- var subPanels = document.querySelectorAll(selector);
- for (var i = 0, il = subPanels.length; i < il; i++) {
- LazyLoader.load([subPanels[i]]);
- }
- LazyLoader.load([panelElement], resolve);
- } else {
- LazyLoader.load([panelElement], resolve);
- }
- });
- return promise;
- },
-
- /**
- * promised version of PanelCache.get()
- *
- * @memberOf DialogManager
- * @access private
- * @param {String} panelId
- * @return {Promise}
- */
- _getPanel: function dm__getPanel(panelId) {
- var promise = new Promise(function(resolve) {
- PanelCache.get(panelId, function(panel) {
- resolve(panel);
- });
- });
- return promise;
- },
-
- /**
- * this is used to control visibility of overlayDOM
- *
- * @memberOf DialogManager
- * @access private
- * @param {Boolean} show
- */
- _showOverlay: function dm__showOverlay(show) {
- this._overlayDOM.hidden = !show;
- },
-
- /**
- * It is used to control the timing of transitions so that we can make sure
- * whether animation is done or not.
- *
- * @memberOf DialogManager
- * @access private
- * @param {String} method
- * @param {BaseDialog} dialog
- * @param {Object} options
- * @return {Promise}
- */
- _transit: function dm__transit(method, dialog, options) {
- var promise = new Promise(function(resolve) {
- var panel = dialog.panel;
- var completed = false;
- var fallback;
- function finish(event) {
- if (event && (event.target !== panel || event.propertyName !== 'visibility')) return;
- if (completed) return;
- completed = true;
- clearTimeout(fallback);
- panel.removeEventListener('transitionend', finish);
- panel.removeEventListener('transitioncancel', finish);
- if (method === 'close') panel.hidden = true;
- resolve();
- }
- if (method === 'open') panel.hidden = false;
- setTimeout(function() {
- panel.addEventListener('transitionend', finish);
- panel.addEventListener('transitioncancel', finish);
- panel.classList.toggle('current', method === 'open');
- var style = window.getComputedStyle(panel);
- function milliseconds(value) {
- return parseFloat(value) * (value.trim().endsWith('ms') ? 1 : 1000) || 0;
- }
- var durations = style.transitionDuration.split(',').map(milliseconds);
- var delays = style.transitionDelay.split(',').map(milliseconds);
- var duration = Math.max.apply(Math, durations.map(function(value, index) {
- return value + delays[index % delays.length];
- }));
- // Reduced motion, display changes or interrupted transitions may
- // omit the visibility event. Always release the dialog queue.
- fallback = setTimeout(finish, Math.max(0, duration) + 100);
- }, 150);
- });
- return promise;
- },
-
- /**
- * Do necessary works to open panel like loading panel, doing transition
- * and call related functions.
- *
- * @memberOf DialogManager
- * @access private
- * @param {BaseDialog} dialog
- * @param {Object} options
- * @return {Promise}
- */
- _open: function dm__open(dialog, options) {
- var self = this;
- var foundPanel;
-
- return Promise.resolve()
- .then(function() {
- // 1: load panel
- return self._loadPanel(dialog.panel.id);
- })
- .then(function() {
- // 2: Get that panel
- return self._getPanel(dialog.panel.id);
- })
- .then(function(panel) {
- // 3: call beforeShow
- foundPanel = panel;
- return foundPanel.beforeShow(dialog.panel, options);
- })
- .then(function() {
- // 4. UI stuffs + transition
- dialog.init();
- dialog.initUI();
- dialog.bindEvents();
-
- if (dialog.TRANSITION_CLASS === 'zoom-in-80') {
- self._showOverlay(true);
- }
-
- return self._transit('open', dialog, options);
- })
- .then(function() {
- // 5. show that panel as a dialog
- return foundPanel.show(dialog.panel, options);
- });
- },
-
- /**
- * Do necessary works to close panel like loading panel, doing transition
- * and call related functions.
- *
- * @memberOf DialogManager
- * @access private
- * @param {BaseDialog} dialog
- * @param {Object} options
- * @return {Promise}
- */
- _close: function dm__close(dialog, options) {
- var self = this;
- var foundPanel;
- var cachedResult;
-
- return Promise.resolve()
- .then(function() {
- // 1: Get that panel
- return self._getPanel(dialog.panel.id);
- })
- .then(function(panel) {
- // 2: Let's validate to see whether we can close this dialog or not.
- foundPanel = panel;
-
- var promise;
- // custom dialog - onSubmit
- if (foundPanel.onSubmit && options._type === 'submit') {
- promise = foundPanel.onSubmit();
- // custom dialog - onCancel
- } else if (foundPanel.onCancel && options._type === 'cancel') {
- promise = foundPanel.onCancel();
- // if no onSubmit & onCancel, pass directly
- } else {
- promise = Promise.resolve();
- }
-
- return promise;
- })
- .then(function(result) {
- cachedResult = result;
-
- // 3: call beforeHide
- return foundPanel.beforeHide();
- })
- .then(function() {
- // 4. transition
- return self._transit('close', dialog, options);
- })
- .then(function() {
- // 5. call hide
- return foundPanel.hide();
- })
- .then(function() {
- // 6. Get result and cleanup dialog
- var result;
-
- // for prompt dialog, we have to get its own result from input text.
- if (dialog.DIALOG_CLASS === 'prompt-dialog') {
- result = dialog.getResult();
- } else if (cachedResult) {
- result = cachedResult;
- }
-
- dialog.cleanup();
-
- if (dialog.TRANSITION_CLASS === 'zoom-in-80') {
- self._showOverlay(false);
- }
-
- return result;
- });
- },
-
- /**
- * It is a bridge to call open or close function.
- *
- * @memberOf DialogManager
- * @access private
- * @param {String} method
- * @param {BaseDialog} dialog
- * @param {Object} options
- * @return {Promise}
- */
- _navigate: function dm__navigate(method, dialog, options) {
- method = (method === 'open') ? '_open' : '_close';
- return this[method](dialog, options);
- },
-
- /**
- * DialogService would use this exposed API to open dialog.
- *
- * @memberOf DialogManager
- * @access public
- * @param {BaseDialog} dialog
- * @param {Object} options
- * @return {Promise}
- */
- open: function dm_open(dialog, options) {
- return this._navigate('open', dialog, options);
- },
-
- /**
- * DialogService would use this exposed API to close dialog.
- *
- * @memberOf DialogManager
- * @access public
- * @param {BaseDialog} dialog
- * @param {Object} options
- * @return {Promise}
- */
- close: function dm_close(dialog, type, options) {
- options._type = type;
- return this._navigate('close', dialog, options);
- }
- };
-
- var dialogManager = new DialogManager();
- return dialogManager;
-});
-define('modules/dialog/base_dialog',['require'],function(require){'use strict';var BaseDialog=function(panelDOM,options){this.panel=panelDOM;this._options=options||{};};BaseDialog.prototype.DIALOG_CLASS='dialog';BaseDialog.prototype.TRANSITION_CLASS='fade';BaseDialog.prototype.SUBMIT_BUTTON_SELECTOR='[type="submit"]';BaseDialog.prototype.CANCEL_BUTTON_SELECTOR='[type="reset"]';BaseDialog.prototype.MESSAGE_SELECTOR='.settings-dialog-message';BaseDialog.prototype.TITLE_SELECTOR='.settings-dialog-title';BaseDialog.prototype.init=function bd_init(){this.TRANSITION_CLASS=this._options.transition||this.TRANSITION_CLASS;this.panel.classList.add(this.DIALOG_CLASS);this.panel.classList.add(this.TRANSITION_CLASS);};BaseDialog.prototype.initUI=function bd_initUI(){var message=this._options.message;var title=this._options.title;var submitButton=this._options.submitButton;var cancelButton=this._options.cancelButton;this._updateMessage(message);this._updateTitle(title);this._updateSubmitButton(submitButton);this._updateCancelButton(cancelButton);};BaseDialog.prototype.bindEvents=function bd_bindEvent(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};BaseDialog.prototype._updateMessage=function bd__updateMessage(message){var messageDOM=this.panel.querySelector(this.MESSAGE_SELECTOR);if(messageDOM&&message){message=this._getWrapL10nObject(message);document.l10n.setAttributes(messageDOM,message.id,message.args);}};BaseDialog.prototype._updateTitle=function bd__updateTitle(title){var titleDOM=this.panel.querySelector(this.TITLE_SELECTOR);if(titleDOM&&title){title=this._getWrapL10nObject(title);document.l10n.setAttributes(titleDOM,title.id,title.args);}};BaseDialog.prototype._updateSubmitButton=function bd__update(options){var buttonDOM=this.getSubmitButton();if(buttonDOM&&options){options=this._getWrapL10nObject(options);document.l10n.setAttributes(buttonDOM,options.id,options.args);buttonDOM.className=options.style||'recommend';}};BaseDialog.prototype._updateCancelButton=function bd__updateText(options){var buttonDOM=this.getCancelButton();if(buttonDOM&&options){options=this._getWrapL10nObject(options);document.l10n.setAttributes(buttonDOM,options.id,options.args);buttonDOM.className=options.style||'';}};BaseDialog.prototype._getWrapL10nObject=function bd__getWrapL10nObject(input){if(typeof input==='string'){return{id:input,args:null};}else if(typeof input==='object'){if(typeof input.id==='undefined'){throw new Error('You forgot to put l10nId - '+
-JSON.stringify(input));}else{return{id:input.id,args:input.args||null,style:input.style};}}else{throw new Error('You are using the wrong L10nObject, '+'please check its format again');}};BaseDialog.prototype.getDOM=function bd_getDOM(){return this.panel;};BaseDialog.prototype.getSubmitButton=function bd_getSubmitButton(){return this.panel.querySelector(this.SUBMIT_BUTTON_SELECTOR);};BaseDialog.prototype.getCancelButton=function bd_getCancelButton(){return this.panel.querySelector(this.CANCEL_BUTTON_SELECTOR);};BaseDialog.prototype.cleanup=function bd_cleanup(){if(this.DIALOG_CLASS!=='panel-dialog'){this._updateTitle('settings-'+this.DIALOG_CLASS+'-header');this._updateSubmitButton('ok');this._updateCancelButton('cancel');}
-this.panel.classList.remove(this.DIALOG_CLASS);this.panel.classList.remove(this.TRANSITION_CLASS);};return BaseDialog;});define('modules/dialog/panel_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var PanelDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};PanelDialog.prototype=Object.create(BaseDialog.prototype);PanelDialog.prototype.constructor=PanelDialog;PanelDialog.prototype.DIALOG_CLASS='panel-dialog';PanelDialog.prototype.TRANSITION_CLASS='fade';return function ctor_PanelDialog(panelDOM,options){return new PanelDialog(panelDOM,options);};});define('modules/dialog/alert_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var AlertDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};AlertDialog.prototype=Object.create(BaseDialog.prototype);AlertDialog.prototype.constructor=AlertDialog;AlertDialog.prototype.DIALOG_CLASS='alert-dialog';AlertDialog.prototype.TRANSITION_CLASS='fade';AlertDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};};return function ctor_alertDialog(panelDOM,options){return new AlertDialog(panelDOM,options);};});define('modules/dialog/confirm_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var ConfirmDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};ConfirmDialog.prototype=Object.create(BaseDialog.prototype);ConfirmDialog.prototype.constructor=ConfirmDialog;ConfirmDialog.prototype.DIALOG_CLASS='confirm-dialog';ConfirmDialog.prototype.TRANSITION_CLASS='fade';ConfirmDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};return function ctor_confirmDialog(panelDOM,options){return new ConfirmDialog(panelDOM,options);};});define('modules/dialog/prompt_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var PromptDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};PromptDialog.prototype=Object.create(BaseDialog.prototype);PromptDialog.prototype.constructor=PromptDialog;PromptDialog.prototype.DIALOG_CLASS='prompt-dialog';PromptDialog.prototype.TRANSITION_CLASS='fade';PromptDialog.prototype.INPUT_SELECTOR='.settings-dialog-input';PromptDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};PromptDialog.prototype.initUI=function(){BaseDialog.prototype.initUI.call(this);this.getInput().value=this._options.defaultValue||'';};PromptDialog.prototype.getInput=function(){return this.panel.querySelector(this.INPUT_SELECTOR);};PromptDialog.prototype.getResult=function(){return this.getInput().value;};return function ctor_promptDialog(panelDOM,options){return new PromptDialog(panelDOM,options);};});define('modules/dialog_service',["require", "settings", "modules/defer", "modules/dialog_manager", "modules/dialog/panel_dialog", "modules/dialog/alert_dialog", "modules/dialog/confirm_dialog", "modules/dialog/prompt_dialog"],function(require) {
- 'use strict';
-
- var Settings = require('settings');
- var Defer = require('modules/defer');
- var DialogManager = require('modules/dialog_manager');
-
- var PanelDialog = require('modules/dialog/panel_dialog');
- var AlertDialog = require('modules/dialog/alert_dialog');
- var ConfirmDialog = require('modules/dialog/confirm_dialog');
- var PromptDialog = require('modules/dialog/prompt_dialog');
-
- var DialogService = function() {
- this._navigating = false;
- this._pendingRequests = [];
- this._settingsAlertDialogId = 'settings-alert-dialog';
- this._settingsBaseDialogId = 'settings-base-dialog';
- this._settingsConfirmDialogId = 'settings-confirm-dialog';
- this._settingsPromptDialogId = 'settings-prompt-dialog';
- };
-
- DialogService.prototype = {
- /**
- * Alert dialog with more controls.
- *
- * @memberOf DialogService
- * @access public
- * @param {String} message
- * @param {Object} userOptions
- * @return {Promise}
- */
- alert: function(message, userOptions) {
- var options = userOptions || {};
- return this.show(this._settingsAlertDialogId, {
- type: 'alert',
- message: message,
- title: options.title,
- submitButton: options.submitButton
- });
- },
-
- /**
- * Confirm dialog with more controls.
- *
- * @memberOf DialogService
- * @access public
- * @param {String} message
- * @param {Object} userOptions
- * @return {Promise}
- */
- confirm: function(message, userOptions) {
- var options = userOptions || {};
- return this.show(this._settingsConfirmDialogId, {
- type: 'confirm',
- message: message,
- title: options.title,
- submitButton: options.submitButton,
- cancelButton: options.cancelButton
- });
- },
-
- /**
- * Prompt dialog with more controls.
- *
- * @memberOf DialogService
- * @access public
- * @param {String} message
- * @param {Object} userOptions
- * @return {Promise}
- */
- prompt: function(message, userOptions) {
- var options = userOptions || {};
- return this.show(this._settingsPromptDialogId, {
- type: 'prompt',
- message: message,
- title: options.title,
- defaultValue: options.defaultValue,
- submitButton: options.submitButton,
- cancelButton: options.cancelButton
- });
- },
-
- /**
- * Panel dialog. If you are going to show any panel as a dialog,
- * you have to use this method to show them.
- *
- * @memberOf DialogService
- * @access public
- * @param {String} panelId
- * @param {Object} userOptions
- * @return {Promise}
- */
- show: function dm_show(panelId, userOptions, _pendingDefer) {
- var self = this;
- var defer;
- var dialog;
- var dialogDOM = document.getElementById(panelId);
- var currentPanel = Settings.currentPanel;
- var options = userOptions || {};
-
- if (_pendingDefer) {
- defer = _pendingDefer;
- } else {
- defer = Defer();
- }
-
- if (this._navigating) {
- this._pendingRequests.push({
- defer: defer,
- panelId: panelId,
- userOptions: userOptions
- });
- } else {
- if ('#' + panelId === currentPanel) {
- defer.reject('You are showing the same panel #' + panelId);
- } else {
- var opening;
- var closing = false;
- function close(type) {
- if (closing) return;
- closing = true;
- // A tap during the opening animation must not run hide/show
- // concurrently, or a queued dialog can inherit stale form values.
- Promise.resolve(opening).then(function() {
- return DialogManager.close(dialog, type, options);
- }).then(function(result) {
- self._navigating = false;
- defer.resolve({type: type, value: result});
- self._execPendingRequest();
- }, function(error) {
- // Panel validation rejects to keep the form open. Allow a
- // correction or cancellation without rejecting the show promise.
- closing = false;
- dialogDOM.dispatchEvent(new CustomEvent('dialogvalidationfailed', {
- detail: error
- }));
- });
- }
- options.onWrapSubmit = function() { close('submit'); };
- options.onWrapCancel = function() { close('cancel'); };
-
- switch (options.type) {
- case 'alert':
- dialog = AlertDialog(dialogDOM, options);
- break;
- case 'confirm':
- dialog = ConfirmDialog(dialogDOM, options);
- break;
- case 'prompt':
- dialog = PromptDialog(dialogDOM, options);
- break;
- default:
- dialog = PanelDialog(dialogDOM, options);
- break;
- }
- this._navigating = true;
- opening = DialogManager.open(dialog, options);
- opening.catch(function(error) {
- // A missing host API must not lock every later Settings dialog.
- dialogDOM.classList.remove('current');
- dialogDOM.hidden = true;
- var overlay = document.querySelector('.settings-dialog-overlay');
- if (overlay) overlay.hidden = true;
- self._navigating = false;
- defer.reject(error);
- self._execPendingRequest();
- });
- }
- }
-
- return defer.promise;
- },
-
- /**
- * This method can help us pop up any pending request and would try to
- * show it after previous request was done.
- *
- * @memberOf DialogService
- * @access private
- */
- _execPendingRequest: function() {
- var request = this._pendingRequests.pop();
- if (request) {
- this.show(request.panelId, request.userOptions, request.defer);
- }
- }
- };
-
- var dialogService = new DialogService();
- return dialogService;
-});
-define('panels/firefox_accounts/firefox_accounts',['require','shared/text_normalizer','shared/fxa_iac_client','modules/dialog_service'],function(require){var normalizer=require('shared/text_normalizer');var fxaHelper=require('shared/fxa_iac_client');var dialogService=require('modules/dialog_service');var FirefoxAccounts=function(){this._boundRefreshStatus=this._boundRefreshStatus||this.refreshStatus.bind(this);};FirefoxAccounts.prototype={onInit:function fxa_init(elements){this._elements=elements;},onBeforeShow:function fxa_beforeShow(){fxaHelper.addEventListener('onlogin',this._boundRefreshStatus);fxaHelper.addEventListener('onverified',this._boundRefreshStatus);fxaHelper.addEventListener('onlogout',this._boundRefreshStatus);this._boundRefreshStatus();},onBeforeHide:function fxa_beforeHide(){fxaHelper.removeEventListener('onlogin',this._boundRefreshStatus);fxaHelper.removeEventListener('onverified',this._boundRefreshStatus);fxaHelper.removeEventListener('onlogout',this._boundRefreshStatus);},refreshStatus:function fxa_refreshStatus(){fxaHelper.getAccount(this._onFxAccountStateChange.bind(this),this._onFxAccountError.bind(this));},_onFxAccountStateChange:function fxa_onFxAccountStateChange(e){var email=e?normalizer.escapeHTML(e.email):'';if(!e){this._hideLoggedInPanel();this._hideUnverifiedPanel();this._showLoggedOutPanel();}else if(e.verified){this._hideLoggedOutPanel();this._hideUnverifiedPanel();this._showLoggedInPanel(email);}else{this._hideLoggedOutPanel();this._hideLoggedInPanel();this._showUnverifiedPanel(email);}},_onFxAccountError:function fxa_onFxAccountError(err){console.error('FxaPanel: Error getting Firefox Account: '+err.error);},_hideLoggedOutPanel:function fxa_hideLoggedOutPanel(){this._elements.loginBtn.onclick=null;this._elements.loggedOutPanel.hidden=true;},_showLoggedOutPanel:function fxa_showLoggedOutPanel(){this._elements.loginBtn.onclick=this._onLoginClick.bind(this);this._elements.loggedOutPanel.hidden=false;},_hideLoggedInPanel:function fxa_hideLoggedInPanel(){this._elements.loggedInPanel.hidden=true;this._elements.loggedInEmail.textContent='';this._elements.logoutBtn.onclick=null;},_showLoggedInPanel:function fxa_showLoggedInPanel(email){document.l10n.setAttributes(this._elements.loggedInEmail,'fxa-logged-in-text',{email:email});this._elements.loggedInPanel.hidden=false;this._elements.logoutBtn.onclick=this._onLogoutClick.bind(this);},_hideUnverifiedPanel:function fxa_hideUnverifiedPanel(){this._elements.unverifiedPanel.hidden=true;this._elements.unverifiedEmail.textContent='';this._elements.cancelBtn.onclick=null;if(this._elements.resendLink){this._elements.resendLink.onclick=null;}},_showUnverifiedPanel:function fxa_showUnverifiedPanel(email){this._elements.unverifiedPanel.hidden=false;this._elements.cancelBtn.onclick=this._onLogoutClick.bind(this);document.l10n.setAttributes(this._elements.unverifiedEmail,'fxa-verification-email-sent-msg',{email:email});this._elements.resendLink.onclick=this._onResendClick.bind(this);},_onLogoutClick:function fxa_onLogoutClick(e){e.stopPropagation();e.preventDefault();fxaHelper.logout(this._onFxAccountStateChange.bind(this),this._onFxAccountError.bind(this));},_onResendClick:function fxa__onResendClick(e){e.stopPropagation();e.preventDefault();if(e.target.classList.contains('disabled')){return;}
-fxaHelper.getAccount((accts)=>{var email=accts&&accts.email;if(!email){return this._onFxAccountStateChange(accts);}
-fxaHelper.resendVerificationEmail(email,this._onResend.bind(this,email),this._onFxAccountError.bind(this));},this._onFxAccountError.bind(this));},_onResend:function fxa__onResend(email){dialogService.alert({id:'fxa-resend-alert',args:{email:email}});this._elements.resendLink.classList.add('disabled');setTimeout(()=>{this._elements.resendLink.classList.remove('disabled');},60000);},_onLoginClick:function fxa_onLoginClick(e){e.stopPropagation();e.preventDefault();fxaHelper.openFlow(this._onFxAccountStateChange.bind(this),this._onFxAccountError.bind(this));}};return function ctor_firefox_accounts(){return new FirefoxAccounts();};});define('panels/firefox_accounts/panel',['require','modules/settings_panel','panels/firefox_accounts/firefox_accounts'],function(require){var SettingsPanel=require('modules/settings_panel');var FirefoxAccounts=require('panels/firefox_accounts/firefox_accounts');return function ctor_firefox_account_panel(){var elements;var firefoxAccounts=FirefoxAccounts();return SettingsPanel({onInit:function(panel){elements={fxaContainer:document.getElementById('fxa'),loggedOutPanel:document.getElementById('fxa-logged-out'),loggedInPanel:document.getElementById('fxa-logged-in'),unverifiedPanel:document.getElementById('fxa-unverified'),resendEmail:document.getElementById('fxa-resend-email'),cancelBtn:document.getElementById('fxa-cancel-confirmation'),loginBtn:document.getElementById('fxa-login'),logoutBtn:document.getElementById('fxa-logout'),loggedInEmail:document.getElementById('fxa-logged-in-text'),unverifiedEmail:document.getElementById('fxa-unverified-text'),resendLink:document.getElementById('fxa-resend')};firefoxAccounts.onInit(elements);},onBeforeShow:function(){firefoxAccounts.onBeforeShow();},onBeforeHide:function(){firefoxAccounts.onBeforeHide();}});};});
\ No newline at end of file
diff --git a/overrides/settings.gaiamobile.org/js/panels/firefox_sync/panel.js b/overrides/settings.gaiamobile.org/js/panels/firefox_sync/panel.js
deleted file mode 100644
index ea67ba9f..00000000
--- a/overrides/settings.gaiamobile.org/js/panels/firefox_sync/panel.js
+++ /dev/null
@@ -1,505 +0,0 @@
-
-define('modules/sync_manager_bridge',['require'],function(require){'use strict';var SyncManagerBridge={_port:null,_requests:new Map(),set onsyncchange(callback){this._onsyncchange=callback;},connect:function(){if(this._port){return Promise.resolve(this._port);}
-return new Promise((resolve,reject)=>{navigator.mozApps.getSelf().onsuccess=event=>{var app=event.target.result;app.connect('gaia-sync-management').then(ports=>{if(!ports||!ports.length){return reject();}
-this._port=ports[0];this._port.onmessage=this.onmessage.bind(this);resolve(this._port);}).catch(reject);};});},iacRequest:function(request){return new Promise(resolve=>{this.connect().then(port=>{if(request.id){this._requests.set(request.id,resolve);}
-port.postMessage(request);});});},onmessage:function(event){var message=event.data;if(!message){return;}
-if(message.id&&this._requests.has(message.id)){this._requests.get(message.id)(message);this._requests.delete(message.id);return;}
-if(message.name!=='onsyncchange'){return;}
-if(this._onsyncchange){this._onsyncchange(message);}},getInfo:function(){return this.iacRequest({name:'getInfo',id:Date.now()});},};['enable','disable','sync'].forEach(requestName=>{SyncManagerBridge[requestName]=()=>{return SyncManagerBridge.iacRequest({name:requestName});};});return SyncManagerBridge;});define('modules/dialog_manager',["require", "modules/panel_cache", "shared/lazy_loader"],function(require) {
- 'use strict';
-
- var PanelCache = require('modules/panel_cache');
- var LazyLoader = require('shared/lazy_loader');
-
- var DialogManager = function() {
- this.OVERLAY_SELECTOR = '.settings-dialog-overlay';
-
- this._overlayDOM = document.querySelector(this.OVERLAY_SELECTOR);
- };
-
- DialogManager.prototype = {
- /**
- * load panel based on passed in panelId
- *
- * @memberOf DialogManager
- * @access private
- * @param {String} panelId
- * @return {Promise}
- */
- _loadPanel: function dm__loadPanel(panelId) {
- var promise = new Promise(function(resolve, reject) {
- var panelElement = document.getElementById(panelId);
- if (panelElement.dataset.rendered) { // already initialized
- resolve();
- return;
- }
-
- panelElement.dataset.rendered = true;
-
- // XXX remove SubPanel loader once sub panel are modulized
- if (panelElement.dataset.requireSubPanels) {
- // load the panel and its sub-panels (dependencies)
- // (load the main panel last because it contains the scripts)
- var selector = 'section[id^="' + panelElement.id + '-"]';
- var subPanels = document.querySelectorAll(selector);
- for (var i = 0, il = subPanels.length; i < il; i++) {
- LazyLoader.load([subPanels[i]]);
- }
- LazyLoader.load([panelElement], resolve);
- } else {
- LazyLoader.load([panelElement], resolve);
- }
- });
- return promise;
- },
-
- /**
- * promised version of PanelCache.get()
- *
- * @memberOf DialogManager
- * @access private
- * @param {String} panelId
- * @return {Promise}
- */
- _getPanel: function dm__getPanel(panelId) {
- var promise = new Promise(function(resolve) {
- PanelCache.get(panelId, function(panel) {
- resolve(panel);
- });
- });
- return promise;
- },
-
- /**
- * this is used to control visibility of overlayDOM
- *
- * @memberOf DialogManager
- * @access private
- * @param {Boolean} show
- */
- _showOverlay: function dm__showOverlay(show) {
- this._overlayDOM.hidden = !show;
- },
-
- /**
- * It is used to control the timing of transitions so that we can make sure
- * whether animation is done or not.
- *
- * @memberOf DialogManager
- * @access private
- * @param {String} method
- * @param {BaseDialog} dialog
- * @param {Object} options
- * @return {Promise}
- */
- _transit: function dm__transit(method, dialog, options) {
- var promise = new Promise(function(resolve) {
- var panel = dialog.panel;
- var completed = false;
- var fallback;
- function finish(event) {
- if (event && (event.target !== panel || event.propertyName !== 'visibility')) return;
- if (completed) return;
- completed = true;
- clearTimeout(fallback);
- panel.removeEventListener('transitionend', finish);
- panel.removeEventListener('transitioncancel', finish);
- if (method === 'close') panel.hidden = true;
- resolve();
- }
- if (method === 'open') panel.hidden = false;
- setTimeout(function() {
- panel.addEventListener('transitionend', finish);
- panel.addEventListener('transitioncancel', finish);
- panel.classList.toggle('current', method === 'open');
- var style = window.getComputedStyle(panel);
- function milliseconds(value) {
- return parseFloat(value) * (value.trim().endsWith('ms') ? 1 : 1000) || 0;
- }
- var durations = style.transitionDuration.split(',').map(milliseconds);
- var delays = style.transitionDelay.split(',').map(milliseconds);
- var duration = Math.max.apply(Math, durations.map(function(value, index) {
- return value + delays[index % delays.length];
- }));
- // Reduced motion, display changes or interrupted transitions may
- // omit the visibility event. Always release the dialog queue.
- fallback = setTimeout(finish, Math.max(0, duration) + 100);
- }, 150);
- });
- return promise;
- },
-
- /**
- * Do necessary works to open panel like loading panel, doing transition
- * and call related functions.
- *
- * @memberOf DialogManager
- * @access private
- * @param {BaseDialog} dialog
- * @param {Object} options
- * @return {Promise}
- */
- _open: function dm__open(dialog, options) {
- var self = this;
- var foundPanel;
-
- return Promise.resolve()
- .then(function() {
- // 1: load panel
- return self._loadPanel(dialog.panel.id);
- })
- .then(function() {
- // 2: Get that panel
- return self._getPanel(dialog.panel.id);
- })
- .then(function(panel) {
- // 3: call beforeShow
- foundPanel = panel;
- return foundPanel.beforeShow(dialog.panel, options);
- })
- .then(function() {
- // 4. UI stuffs + transition
- dialog.init();
- dialog.initUI();
- dialog.bindEvents();
-
- if (dialog.TRANSITION_CLASS === 'zoom-in-80') {
- self._showOverlay(true);
- }
-
- return self._transit('open', dialog, options);
- })
- .then(function() {
- // 5. show that panel as a dialog
- return foundPanel.show(dialog.panel, options);
- });
- },
-
- /**
- * Do necessary works to close panel like loading panel, doing transition
- * and call related functions.
- *
- * @memberOf DialogManager
- * @access private
- * @param {BaseDialog} dialog
- * @param {Object} options
- * @return {Promise}
- */
- _close: function dm__close(dialog, options) {
- var self = this;
- var foundPanel;
- var cachedResult;
-
- return Promise.resolve()
- .then(function() {
- // 1: Get that panel
- return self._getPanel(dialog.panel.id);
- })
- .then(function(panel) {
- // 2: Let's validate to see whether we can close this dialog or not.
- foundPanel = panel;
-
- var promise;
- // custom dialog - onSubmit
- if (foundPanel.onSubmit && options._type === 'submit') {
- promise = foundPanel.onSubmit();
- // custom dialog - onCancel
- } else if (foundPanel.onCancel && options._type === 'cancel') {
- promise = foundPanel.onCancel();
- // if no onSubmit & onCancel, pass directly
- } else {
- promise = Promise.resolve();
- }
-
- return promise;
- })
- .then(function(result) {
- cachedResult = result;
-
- // 3: call beforeHide
- return foundPanel.beforeHide();
- })
- .then(function() {
- // 4. transition
- return self._transit('close', dialog, options);
- })
- .then(function() {
- // 5. call hide
- return foundPanel.hide();
- })
- .then(function() {
- // 6. Get result and cleanup dialog
- var result;
-
- // for prompt dialog, we have to get its own result from input text.
- if (dialog.DIALOG_CLASS === 'prompt-dialog') {
- result = dialog.getResult();
- } else if (cachedResult) {
- result = cachedResult;
- }
-
- dialog.cleanup();
-
- if (dialog.TRANSITION_CLASS === 'zoom-in-80') {
- self._showOverlay(false);
- }
-
- return result;
- });
- },
-
- /**
- * It is a bridge to call open or close function.
- *
- * @memberOf DialogManager
- * @access private
- * @param {String} method
- * @param {BaseDialog} dialog
- * @param {Object} options
- * @return {Promise}
- */
- _navigate: function dm__navigate(method, dialog, options) {
- method = (method === 'open') ? '_open' : '_close';
- return this[method](dialog, options);
- },
-
- /**
- * DialogService would use this exposed API to open dialog.
- *
- * @memberOf DialogManager
- * @access public
- * @param {BaseDialog} dialog
- * @param {Object} options
- * @return {Promise}
- */
- open: function dm_open(dialog, options) {
- return this._navigate('open', dialog, options);
- },
-
- /**
- * DialogService would use this exposed API to close dialog.
- *
- * @memberOf DialogManager
- * @access public
- * @param {BaseDialog} dialog
- * @param {Object} options
- * @return {Promise}
- */
- close: function dm_close(dialog, type, options) {
- options._type = type;
- return this._navigate('close', dialog, options);
- }
- };
-
- var dialogManager = new DialogManager();
- return dialogManager;
-});
-define('modules/dialog/base_dialog',['require'],function(require){'use strict';var BaseDialog=function(panelDOM,options){this.panel=panelDOM;this._options=options||{};};BaseDialog.prototype.DIALOG_CLASS='dialog';BaseDialog.prototype.TRANSITION_CLASS='fade';BaseDialog.prototype.SUBMIT_BUTTON_SELECTOR='[type="submit"]';BaseDialog.prototype.CANCEL_BUTTON_SELECTOR='[type="reset"]';BaseDialog.prototype.MESSAGE_SELECTOR='.settings-dialog-message';BaseDialog.prototype.TITLE_SELECTOR='.settings-dialog-title';BaseDialog.prototype.init=function bd_init(){this.TRANSITION_CLASS=this._options.transition||this.TRANSITION_CLASS;this.panel.classList.add(this.DIALOG_CLASS);this.panel.classList.add(this.TRANSITION_CLASS);};BaseDialog.prototype.initUI=function bd_initUI(){var message=this._options.message;var title=this._options.title;var submitButton=this._options.submitButton;var cancelButton=this._options.cancelButton;this._updateMessage(message);this._updateTitle(title);this._updateSubmitButton(submitButton);this._updateCancelButton(cancelButton);};BaseDialog.prototype.bindEvents=function bd_bindEvent(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};BaseDialog.prototype._updateMessage=function bd__updateMessage(message){var messageDOM=this.panel.querySelector(this.MESSAGE_SELECTOR);if(messageDOM&&message){message=this._getWrapL10nObject(message);document.l10n.setAttributes(messageDOM,message.id,message.args);}};BaseDialog.prototype._updateTitle=function bd__updateTitle(title){var titleDOM=this.panel.querySelector(this.TITLE_SELECTOR);if(titleDOM&&title){title=this._getWrapL10nObject(title);document.l10n.setAttributes(titleDOM,title.id,title.args);}};BaseDialog.prototype._updateSubmitButton=function bd__update(options){var buttonDOM=this.getSubmitButton();if(buttonDOM&&options){options=this._getWrapL10nObject(options);document.l10n.setAttributes(buttonDOM,options.id,options.args);buttonDOM.className=options.style||'recommend';}};BaseDialog.prototype._updateCancelButton=function bd__updateText(options){var buttonDOM=this.getCancelButton();if(buttonDOM&&options){options=this._getWrapL10nObject(options);document.l10n.setAttributes(buttonDOM,options.id,options.args);buttonDOM.className=options.style||'';}};BaseDialog.prototype._getWrapL10nObject=function bd__getWrapL10nObject(input){if(typeof input==='string'){return{id:input,args:null};}else if(typeof input==='object'){if(typeof input.id==='undefined'){throw new Error('You forgot to put l10nId - '+
-JSON.stringify(input));}else{return{id:input.id,args:input.args||null,style:input.style};}}else{throw new Error('You are using the wrong L10nObject, '+'please check its format again');}};BaseDialog.prototype.getDOM=function bd_getDOM(){return this.panel;};BaseDialog.prototype.getSubmitButton=function bd_getSubmitButton(){return this.panel.querySelector(this.SUBMIT_BUTTON_SELECTOR);};BaseDialog.prototype.getCancelButton=function bd_getCancelButton(){return this.panel.querySelector(this.CANCEL_BUTTON_SELECTOR);};BaseDialog.prototype.cleanup=function bd_cleanup(){if(this.DIALOG_CLASS!=='panel-dialog'){this._updateTitle('settings-'+this.DIALOG_CLASS+'-header');this._updateSubmitButton('ok');this._updateCancelButton('cancel');}
-this.panel.classList.remove(this.DIALOG_CLASS);this.panel.classList.remove(this.TRANSITION_CLASS);};return BaseDialog;});define('modules/dialog/panel_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var PanelDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};PanelDialog.prototype=Object.create(BaseDialog.prototype);PanelDialog.prototype.constructor=PanelDialog;PanelDialog.prototype.DIALOG_CLASS='panel-dialog';PanelDialog.prototype.TRANSITION_CLASS='fade';return function ctor_PanelDialog(panelDOM,options){return new PanelDialog(panelDOM,options);};});define('modules/dialog/alert_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var AlertDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};AlertDialog.prototype=Object.create(BaseDialog.prototype);AlertDialog.prototype.constructor=AlertDialog;AlertDialog.prototype.DIALOG_CLASS='alert-dialog';AlertDialog.prototype.TRANSITION_CLASS='fade';AlertDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};};return function ctor_alertDialog(panelDOM,options){return new AlertDialog(panelDOM,options);};});define('modules/dialog/confirm_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var ConfirmDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};ConfirmDialog.prototype=Object.create(BaseDialog.prototype);ConfirmDialog.prototype.constructor=ConfirmDialog;ConfirmDialog.prototype.DIALOG_CLASS='confirm-dialog';ConfirmDialog.prototype.TRANSITION_CLASS='fade';ConfirmDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};return function ctor_confirmDialog(panelDOM,options){return new ConfirmDialog(panelDOM,options);};});define('modules/dialog/prompt_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var PromptDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};PromptDialog.prototype=Object.create(BaseDialog.prototype);PromptDialog.prototype.constructor=PromptDialog;PromptDialog.prototype.DIALOG_CLASS='prompt-dialog';PromptDialog.prototype.TRANSITION_CLASS='fade';PromptDialog.prototype.INPUT_SELECTOR='.settings-dialog-input';PromptDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};PromptDialog.prototype.initUI=function(){BaseDialog.prototype.initUI.call(this);this.getInput().value=this._options.defaultValue||'';};PromptDialog.prototype.getInput=function(){return this.panel.querySelector(this.INPUT_SELECTOR);};PromptDialog.prototype.getResult=function(){return this.getInput().value;};return function ctor_promptDialog(panelDOM,options){return new PromptDialog(panelDOM,options);};});define('modules/dialog_service',["require", "settings", "modules/defer", "modules/dialog_manager", "modules/dialog/panel_dialog", "modules/dialog/alert_dialog", "modules/dialog/confirm_dialog", "modules/dialog/prompt_dialog"],function(require) {
- 'use strict';
-
- var Settings = require('settings');
- var Defer = require('modules/defer');
- var DialogManager = require('modules/dialog_manager');
-
- var PanelDialog = require('modules/dialog/panel_dialog');
- var AlertDialog = require('modules/dialog/alert_dialog');
- var ConfirmDialog = require('modules/dialog/confirm_dialog');
- var PromptDialog = require('modules/dialog/prompt_dialog');
-
- var DialogService = function() {
- this._navigating = false;
- this._pendingRequests = [];
- this._settingsAlertDialogId = 'settings-alert-dialog';
- this._settingsBaseDialogId = 'settings-base-dialog';
- this._settingsConfirmDialogId = 'settings-confirm-dialog';
- this._settingsPromptDialogId = 'settings-prompt-dialog';
- };
-
- DialogService.prototype = {
- /**
- * Alert dialog with more controls.
- *
- * @memberOf DialogService
- * @access public
- * @param {String} message
- * @param {Object} userOptions
- * @return {Promise}
- */
- alert: function(message, userOptions) {
- var options = userOptions || {};
- return this.show(this._settingsAlertDialogId, {
- type: 'alert',
- message: message,
- title: options.title,
- submitButton: options.submitButton
- });
- },
-
- /**
- * Confirm dialog with more controls.
- *
- * @memberOf DialogService
- * @access public
- * @param {String} message
- * @param {Object} userOptions
- * @return {Promise}
- */
- confirm: function(message, userOptions) {
- var options = userOptions || {};
- return this.show(this._settingsConfirmDialogId, {
- type: 'confirm',
- message: message,
- title: options.title,
- submitButton: options.submitButton,
- cancelButton: options.cancelButton
- });
- },
-
- /**
- * Prompt dialog with more controls.
- *
- * @memberOf DialogService
- * @access public
- * @param {String} message
- * @param {Object} userOptions
- * @return {Promise}
- */
- prompt: function(message, userOptions) {
- var options = userOptions || {};
- return this.show(this._settingsPromptDialogId, {
- type: 'prompt',
- message: message,
- title: options.title,
- defaultValue: options.defaultValue,
- submitButton: options.submitButton,
- cancelButton: options.cancelButton
- });
- },
-
- /**
- * Panel dialog. If you are going to show any panel as a dialog,
- * you have to use this method to show them.
- *
- * @memberOf DialogService
- * @access public
- * @param {String} panelId
- * @param {Object} userOptions
- * @return {Promise}
- */
- show: function dm_show(panelId, userOptions, _pendingDefer) {
- var self = this;
- var defer;
- var dialog;
- var dialogDOM = document.getElementById(panelId);
- var currentPanel = Settings.currentPanel;
- var options = userOptions || {};
-
- if (_pendingDefer) {
- defer = _pendingDefer;
- } else {
- defer = Defer();
- }
-
- if (this._navigating) {
- this._pendingRequests.push({
- defer: defer,
- panelId: panelId,
- userOptions: userOptions
- });
- } else {
- if ('#' + panelId === currentPanel) {
- defer.reject('You are showing the same panel #' + panelId);
- } else {
- var opening;
- var closing = false;
- function close(type) {
- if (closing) return;
- closing = true;
- // A tap during the opening animation must not run hide/show
- // concurrently, or a queued dialog can inherit stale form values.
- Promise.resolve(opening).then(function() {
- return DialogManager.close(dialog, type, options);
- }).then(function(result) {
- self._navigating = false;
- defer.resolve({type: type, value: result});
- self._execPendingRequest();
- }, function(error) {
- // Panel validation rejects to keep the form open. Allow a
- // correction or cancellation without rejecting the show promise.
- closing = false;
- dialogDOM.dispatchEvent(new CustomEvent('dialogvalidationfailed', {
- detail: error
- }));
- });
- }
- options.onWrapSubmit = function() { close('submit'); };
- options.onWrapCancel = function() { close('cancel'); };
-
- switch (options.type) {
- case 'alert':
- dialog = AlertDialog(dialogDOM, options);
- break;
- case 'confirm':
- dialog = ConfirmDialog(dialogDOM, options);
- break;
- case 'prompt':
- dialog = PromptDialog(dialogDOM, options);
- break;
- default:
- dialog = PanelDialog(dialogDOM, options);
- break;
- }
- this._navigating = true;
- opening = DialogManager.open(dialog, options);
- opening.catch(function(error) {
- // A missing host API must not lock every later Settings dialog.
- dialogDOM.classList.remove('current');
- dialogDOM.hidden = true;
- var overlay = document.querySelector('.settings-dialog-overlay');
- if (overlay) overlay.hidden = true;
- self._navigating = false;
- defer.reject(error);
- self._execPendingRequest();
- });
- }
- }
-
- return defer.promise;
- },
-
- /**
- * This method can help us pop up any pending request and would try to
- * show it after previous request was done.
- *
- * @memberOf DialogService
- * @access private
- */
- _execPendingRequest: function() {
- var request = this._pendingRequests.pop();
- if (request) {
- this.show(request.panelId, request.userOptions, request.defer);
- }
- }
- };
-
- var dialogService = new DialogService();
- return dialogService;
-});
-define('panels/firefox_sync/firefox_sync',['require','shared/settings_listener','modules/sync_manager_bridge','modules/dialog_service'],function(require){'use strict';var SettingsListener=require('shared/settings_listener');var SyncManagerBridge=require('modules/sync_manager_bridge');var DialogService=require('modules/dialog_service');const LOGGED_OUT_SCREEN='loggedout';const LOGGED_IN_SCREEN='loggedin';const ELEMENTS=[{screen:LOGGED_OUT_SCREEN,selector:'.fxsync-login',event:'click',listener:'enable'},{screen:LOGGED_IN_SCREEN,selector:'.fxsync-logout',event:'click',listener:'disable'},{screen:LOGGED_IN_SCREEN,selector:'.fxsync-user'},{screen:LOGGED_IN_SCREEN,selector:'.fxsync-sync-now',event:'click',listener:'sync'},{screen:LOGGED_IN_SCREEN,selector:'.fxsync-tos',event:'click',listener:'openTos'},{screen:LOGGED_IN_SCREEN,selector:'.fxsync-privacy',event:'click',listener:'openPrivacy'},{screen:LOGGED_IN_SCREEN,selector:'.fxsync-collections-bookmarks'},{screen:LOGGED_IN_SCREEN,selector:'.fxsync-collections-history'},{screen:LOGGED_IN_SCREEN,selector:'.fxsync-collections-passwords'},{screen:LOGGED_IN_SCREEN,selector:'.fxsync-last-sync'},{screen:LOGGED_IN_SCREEN,selector:'.fxsync-unverified'},{screen:LOGGED_IN_SCREEN,selector:'.fxsync-empty-account'}];function getElementName(str){str=str.toLowerCase().replace('.fxsync-','');return str.replace(/-([a-z])/g,g=>{return g[1].toUpperCase();});}
-function FirefoxSyncPanel(panel,screens){this.panel=panel;this.screens=screens;this.elements={};this.currentScreen=null;this.collections=new Map();['sync.collections.bookmarks.enabled','sync.collections.history.enabled','sync.collections.passwords.enabled'].forEach(setting=>{SettingsListener.observe(setting,true,enabled=>{enabled?this.collections.set(setting,enabled):this.collections.delete(setting);this.maybeEnableSyncNow();});});SyncManagerBridge.onsyncchange=this.onsyncchange.bind(this);}
-FirefoxSyncPanel.prototype={refresh(){SyncManagerBridge.getInfo().then(this.onsyncchange.bind(this));},clean(){window.clearTimeout(this.timeout);this.timeout=undefined;window.removeEventListener('offline',this.onlineListener);window.removeEventListener('online',this.onlineListener);},onsyncchange(message){if(!message||!message.state){throw new Error('Missing sync state');}
-switch(message.state){case'disabled':this.showScreen(LOGGED_OUT_SCREEN);this.hideEnabling();this.clean();break;case'enabling':this.showScreen(LOGGED_OUT_SCREEN);this.showEnabling();break;case'enabled':this.hideEnabling();this.showScreen(LOGGED_IN_SCREEN);this.showSyncNow();this.showUser(message.user);this.showLastSync(message.lastSync);if(message.error){LazyLoader.load('shared/js/sync/errors.js',()=>{if(message.error==ERROR_INVALID_SYNC_ACCOUNT){this.showEmptyAccount(message.user);}});}else{this.hideEmptyAccount();}
-break;case'syncing':this.showScreen(LOGGED_IN_SCREEN);this.showUser(message.user);this.showSyncing();break;case'errored':LazyLoader.load('shared/js/sync/errors.js',()=>{if(message.error==ERROR_UNVERIFIED_ACCOUNT){this.showScreen(LOGGED_IN_SCREEN);this.showUnverified(message.user);return;}
-if(message.error==ERROR_INVALID_SYNC_ACCOUNT){this.showScreen(LOGGED_IN_SCREEN);this.showEmptyAccount(message.user);return;}
-const IGNORED_ERRORS=[ERROR_DIALOG_CLOSED_BY_USER,ERROR_SYNC_APP_KILLED];if(IGNORED_ERRORS.indexOf(message.error)>-1){return;}
-var errorMsg=ERROR_UNKNOWN;var title;const KNOWN_ERRORS=[ERROR_OFFLINE,ERROR_NO_KEY_FETCH_TOKEN];if(KNOWN_ERRORS.indexOf(message.error)>-1){title=message.error;errorMsg=message.error+'-explanation';}
-DialogService.alert(errorMsg,{title:title}).then(()=>{this.showScreen(LOGGED_OUT_SCREEN);this.clean();});});break;}},addListener(element,event,listener){if(!element||!event||!listener){return;}
-element.addEventListener(event,this[listener]);},removeListener(element,event,listener){if(!element||!event||!listener){return;}
-element.removeEventListener(event,this[listener]);},loadElements(screen){ELEMENTS.forEach(element=>{var name=getElementName(element.selector);if(element.screen==screen){this.elements[name]=this.panel.querySelector(element.selector);this.addListener(this.elements[name],element.event,element.listener);return;}
-this.removeListener(this.elements[name],element.event,element.listener);this.elements[name]=null;});},showScreen(screen){if(this.currentScreen==screen){return;}
-this.currentScreen=screen;this.screens.loggedIn.hidden=(screen!=LOGGED_IN_SCREEN);this.screens.loggedOut.hidden=(screen!=LOGGED_OUT_SCREEN);this.loadElements(screen);},showEnabling(){this.elements.login.disabled=true;this.elements.login.dataset.l10nId='fxsync-signing';},hideEnabling(){if(!this.elements.login){return;}
-this.elements.login.disabled=false;this.elements.login.dataset.l10nId='fxsync-get-started';},showSyncing(){this.elements.syncNow.dataset.l10nId='fxsync-syncing';this.disableSyncNowAndCollections(true);},maybeEnableSyncNow(){if(!this.elements.syncNow){return;}
-this.elements.syncNow.disabled=(this.collections.size<=0)||!navigator.onLine;},showSyncNow(){this.elements.syncNow.dataset.l10nId='fxsync-sync-now';this.disableSyncNowAndCollections(false);this.elements.unverified.hidden=true;this.maybeEnableSyncNow();this.onlineListener=this.maybeEnableSyncNow.bind(this);window.addEventListener('offline',this.onlineListener);window.addEventListener('online',this.onlineListener);},showUser(email){this.elements.user.textContent=email;},showUnverified(email){this.showUser(email);this.showLastSync();this.disableSyncNowAndCollections(true);this.elements.unverified.hidden=false;},showEmptyAccount(email){this.showUser(email);this.showLastSync();this.disableSyncNowAndCollections(false);this.elements.emptyAccount.hidden=false;},hideEmptyAccount(){this.elements.emptyAccount.hidden=true;},showLastSync(time){if(!time){this.elements.lastSync.classList.add('hidden');return;}
-var formatter=mozIntl._gaia.RelativeDate(navigator.languages,{style:'short'});formatter.format(time).then(relDate=>{var selector='span[data-l10n-id=fxsync-last-synced]';var lastSync=this.elements.lastSync.querySelector(selector);document.l10n.setAttributes(lastSync,'fxsync-last-synced',{when:relDate});this.elements.lastSync.classList.remove('hidden');});this.timeout=window.setTimeout(()=>{this.showLastSync(time);},60*1000);},disableSyncNowAndCollections(disabled){['syncNow','collectionsBookmarks','collectionsHistory','collectionsPasswords'].forEach(name=>{this.elements[name].disabled=disabled;});},enable(){SyncManagerBridge.enable();},disable(){SyncManagerBridge.disable();},sync(){SyncManagerBridge.sync();},openTos(){window.open('https://accounts.firefox.com/legal/terms');},openPrivacy(){window.open('https://accounts.firefox.com/legal/privacy');}};return(panel,screens)=>{return new FirefoxSyncPanel(panel,screens);};});define('panels/firefox_sync/panel',['require','modules/settings_panel','modules/settings_utils','panels/firefox_sync/firefox_sync'],function(require){'use strict';var SettingsPanel=require('modules/settings_panel');var SettingsUtils=require('modules/settings_utils');var FirefoxSyncPanel=require('panels/firefox_sync/firefox_sync');return()=>{return SettingsPanel({onInit:function fxsync_onInit(panel){var screens={loggedIn:panel.querySelector('.fxsync-logged-in'),loggedOut:panel.querySelector('.fxsync-logged-out')};this.syncPanel=FirefoxSyncPanel(panel,screens);var header=panel.querySelector('gaia-header');SettingsUtils.runHeaderFontFit(header);},onShow:function fxsync_onShow(){this.syncPanel.refresh();},onHide:function fxsync_onHide(){this.syncPanel.clean();}});};});
\ No newline at end of file
diff --git a/overrides/settings.gaiamobile.org/js/panels/help/panel.js b/overrides/settings.gaiamobile.org/js/panels/help/panel.js
deleted file mode 100644
index cd804001..00000000
--- a/overrides/settings.gaiamobile.org/js/panels/help/panel.js
+++ /dev/null
@@ -1,171 +0,0 @@
-
-/**
- * Handle support panel functionality with SIM and without SIM
- */
-/**
- * Handle support panel functionality with SIM and without SIM
- */
-/**
- * Handle support panel functionality with SIM and without SIM
- */
-/**
- * Handle support panel functionality with SIM and without SIM
- */
-/**
- * Handle support panel functionality with SIM and without SIM
- */
-/**
- * Handle support panel functionality with SIM and without SIM
- */
-define('panels/help/support',["require", "modules/settings_cache", "shared/lazy_loader"],function(require) {
- 'use strict';
- var SettingsCache = require('modules/settings_cache');
- var LazyLoader = require('shared/lazy_loader');
-
- var Support = function() {};
-
- Support.prototype = {
- /**
- * initialization
- */
- init: function support_init(elements) {
- this._elements = elements;
- /**
- * We'll stash the support info in here when reading from the
- * Settings.
- * If no values are found in Settings for support info We'll
- * refer to the JSON File Data.
- */
- this._callSupportInfo = null;
- var url = 'https://vulpes-os.org/';
- this._elements.userGuide.addEventListener('click', () => {
- this._elements.userGuide.blur();
- var activity = new window.MozActivity({
- name: 'view',
- data: { type: 'url', url: url }
- });
- // For workaround jshint.
- activity.onsuccess = function() {};
- });
-
- // parse support information from data
- this._getSupportInfo(this._displaySupportInfo.bind(this));
- },
- /**
- * Clean states for test
- */
- uninit: function() {
- this._elements = null;
- this._supportInfo = null;
- this._callSupportInfo = null;
- },
- /**
- * get support data from cache or from resource file
- */
- _getSupportInfo: function support_getSupportInfo(callback) {
- if (this._supportInfo) {
- callback(this._supportInfo);
- return;
- }
- LazyLoader.getJSON('/resources/support.json')
- .then(function loadSupportInfo(data) {
- this._supportInfo = data;
- callback(this._supportInfo);
- }.bind(this)).catch(function(error) {
- console.warn('Support information:', error);
- callback({onlinesupport: {title: 'Vulpes OS', href: 'https://vulpes-os.org/'}, callsupport: []});
- });
- },
- /**
- * create support url items
- */
- _createLinkNode: function support_createLinkNode(data, type) {
- var link = document.createElement('a');
- link.setAttribute('href', data.href);
- link.classList.add('link-text');
- if (type == 'tel') {
- link.textContent = data.title + ' (' + data.href + ')';
- } else {
- link.textContent = data.title;
- }
- return link;
- },
- /**
- * Indicate to our panel that there is support info present.
- */
- _enableSupportInfo: function support_enableSupportInfo() {
- this._elements.help.dataset.hasSupportInfo = true;
- },
- /**
- * Local helper function to set the online support information
- * once it's retrieved or we've determined that we'll use
- * what's in the build JSON file.
- */
- _setOnlineSupportInfo:
- function support_setOnlineSupportInfo(onlineSupportInfo) {
- this._enableSupportInfo();
- var nodes = this._createLinkNode(onlineSupportInfo);
- this._elements.supportText.appendChild(nodes);
- },
- /**
- * Local helper function to set the information once we've
- * retrieved it.
- */
- _setCallSupportInfo:
- function support_setCallSupportInfo(supportInfo) {
- this._enableSupportInfo();
- for (var id in supportInfo) {
- this._elements.supportNumber
- .appendChild(this._createLinkNode(supportInfo[id], 'tel'));
- }
- },
- _renderSupportInfo: function support_renderSupportInfo(result) {
- var onlineSupportInfo = null;
- var onlineSupportTitle = result['support.onlinesupport.title'];
- if (typeof onlineSupportTitle === 'string' && onlineSupportTitle !== '') {
- onlineSupportInfo = { title: onlineSupportTitle };
- onlineSupportInfo.href = result['support.onlinesupport.href'];
- this._setOnlineSupportInfo(onlineSupportInfo);
- } else if (this._supportInfo) {
- this._setOnlineSupportInfo(this._supportInfo.onlinesupport);
- }
-
- // Check to see if we have a title for the first support number.
- var callSupport1Title = result['support.callsupport1.title'];
- // If we have a title we'll go ahead and load the href for it too.
- if (typeof callSupport1Title === 'string' && callSupport1Title !== '') {
- this._callSupportInfo = [
- {
- 'title': callSupport1Title,
- 'href': result['support.callsupport1.href']
- }
- ];
-
- // Now check to see if we have a title for the second
- // support number. If we do, we'll load it's href as well.
- var callSupport2Title = result['support.callsupport2.title'];
- if (typeof callSupport2Title === 'string' && callSupport2Title !== '') {
- this._callSupportInfo.push({
- 'title': callSupport2Title,
- 'href': result['support.callsupport2.href']
- });
- // Finally set the support info retreived from Settings.
- this._setCallSupportInfo(this._callSupportInfo);
- }
- } else if (this._supportInfo) {
- // No customized values, use what's in the JSON file.
- this._setCallSupportInfo(this._supportInfo.callsupport);
- }
- },
- /**
- * display Support information
- */
- _displaySupportInfo: function support_displaySupportInfo(supportInfo) {
- SettingsCache.getSettings(this._renderSupportInfo.bind(this));
- }
- };
- return function ctor_support() {
- return new Support();
- };
-});
-define('panels/help/panel',['require','modules/settings_panel','panels/help/support'],function(require){'use strict';var SettingsPanel=require('modules/settings_panel');var Support=require('panels/help/support');return function ctor_support_panel(){var help=Support();return SettingsPanel({onInit:function(panel){var elements={userGuide:panel.querySelector('#user-guide'),help:panel,supportText:panel.querySelector('#help-online-support-text'),supportNumber:panel.querySelector('#help-call-support-numbers')};help.init(elements);}});};});
\ No newline at end of file
diff --git a/overrides/settings.gaiamobile.org/js/panels/keyboard_add_layouts/panel.js b/overrides/settings.gaiamobile.org/js/panels/keyboard_add_layouts/panel.js
deleted file mode 100644
index df4ab184..00000000
--- a/overrides/settings.gaiamobile.org/js/panels/keyboard_add_layouts/panel.js
+++ /dev/null
@@ -1,490 +0,0 @@
-
-define('panels/keyboard_add_layouts/nested_template_factory',['require'],function(require){'use strict';return function ctor_nestedTemplate(parentTemplate,childTemplate){var listViews=[];var template=parentTemplate.bind({listViews:listViews,childTemplate:childTemplate});Object.defineProperty(template,'listViews',{configurable:false,get:function(){return listViews;}});return template;};});define('panels/keyboard_add_layouts/keyboard_template',['require','modules/mvvm/list_view'],function(require){'use strict';var ListView=require('modules/mvvm/list_view');return function kal_keyboardTemplate(keyboard,recycled,helper){var layoutTemplate=this.childTemplate;var listViews=this.listViews;var container,header,h2,ul,listView;if(recycled){container=recycled;h2=container.querySelector('h2');ul=container.querySelector('ul');}else{container=document.createElement('div');header=document.createElement('header');h2=document.createElement('h2');ul=document.createElement('ul');header.appendChild(h2);container.appendChild(header);container.appendChild(ul);}
-listView=listViews.some(function eachListView(list){if(list.element===ul){list.set(keyboard.layouts);list.enabled=true;return true;}});if(!listView){listView=ListView(ul,keyboard.layouts,layoutTemplate);listView.enabled=true;listViews.push(listView);}
-helper.observeAndCall(keyboard,{name:function refreshName(){h2.textContent=keyboard.name;}});return container;};});define('panels/keyboard_add_layouts/layout_template',['require'],function(require){'use strict';return function kal_layoutTemplate(layout,recycled,helper){var container=null;var nameBdi,checkbox;if(recycled){container=recycled;checkbox=container.querySelector('gaia-checkbox');nameBdi=container.querySelector('bdi');}else{container=document.createElement('li');checkbox=document.createElement('gaia-checkbox');nameBdi=document.createElement('bdi');checkbox.appendChild(document.createElement('label'));checkbox.lastChild.appendChild(nameBdi);container.appendChild(checkbox);}
-if(layout.nameL10nId){nameBdi.dataset.l10nId=layout.nameL10nId;}
-checkbox.addEventListener('change',function(e){layout.enabled=e.target.checked;});helper.observeAndCall(layout,{name:function refreshName(){nameBdi.textContent=layout.name;},enabled:function(){checkbox.checked=layout.enabled;}});return container;};});define('modules/dialog_manager',["require", "modules/panel_cache", "shared/lazy_loader"],function(require) {
- 'use strict';
-
- var PanelCache = require('modules/panel_cache');
- var LazyLoader = require('shared/lazy_loader');
-
- var DialogManager = function() {
- this.OVERLAY_SELECTOR = '.settings-dialog-overlay';
-
- this._overlayDOM = document.querySelector(this.OVERLAY_SELECTOR);
- };
-
- DialogManager.prototype = {
- /**
- * load panel based on passed in panelId
- *
- * @memberOf DialogManager
- * @access private
- * @param {String} panelId
- * @return {Promise}
- */
- _loadPanel: function dm__loadPanel(panelId) {
- var promise = new Promise(function(resolve, reject) {
- var panelElement = document.getElementById(panelId);
- if (panelElement.dataset.rendered) { // already initialized
- resolve();
- return;
- }
-
- panelElement.dataset.rendered = true;
-
- // XXX remove SubPanel loader once sub panel are modulized
- if (panelElement.dataset.requireSubPanels) {
- // load the panel and its sub-panels (dependencies)
- // (load the main panel last because it contains the scripts)
- var selector = 'section[id^="' + panelElement.id + '-"]';
- var subPanels = document.querySelectorAll(selector);
- for (var i = 0, il = subPanels.length; i < il; i++) {
- LazyLoader.load([subPanels[i]]);
- }
- LazyLoader.load([panelElement], resolve);
- } else {
- LazyLoader.load([panelElement], resolve);
- }
- });
- return promise;
- },
-
- /**
- * promised version of PanelCache.get()
- *
- * @memberOf DialogManager
- * @access private
- * @param {String} panelId
- * @return {Promise}
- */
- _getPanel: function dm__getPanel(panelId) {
- var promise = new Promise(function(resolve) {
- PanelCache.get(panelId, function(panel) {
- resolve(panel);
- });
- });
- return promise;
- },
-
- /**
- * this is used to control visibility of overlayDOM
- *
- * @memberOf DialogManager
- * @access private
- * @param {Boolean} show
- */
- _showOverlay: function dm__showOverlay(show) {
- this._overlayDOM.hidden = !show;
- },
-
- /**
- * It is used to control the timing of transitions so that we can make sure
- * whether animation is done or not.
- *
- * @memberOf DialogManager
- * @access private
- * @param {String} method
- * @param {BaseDialog} dialog
- * @param {Object} options
- * @return {Promise}
- */
- _transit: function dm__transit(method, dialog, options) {
- var promise = new Promise(function(resolve) {
- var panel = dialog.panel;
- var completed = false;
- var fallback;
- function finish(event) {
- if (event && (event.target !== panel || event.propertyName !== 'visibility')) return;
- if (completed) return;
- completed = true;
- clearTimeout(fallback);
- panel.removeEventListener('transitionend', finish);
- panel.removeEventListener('transitioncancel', finish);
- if (method === 'close') panel.hidden = true;
- resolve();
- }
- if (method === 'open') panel.hidden = false;
- setTimeout(function() {
- panel.addEventListener('transitionend', finish);
- panel.addEventListener('transitioncancel', finish);
- panel.classList.toggle('current', method === 'open');
- var style = window.getComputedStyle(panel);
- function milliseconds(value) {
- return parseFloat(value) * (value.trim().endsWith('ms') ? 1 : 1000) || 0;
- }
- var durations = style.transitionDuration.split(',').map(milliseconds);
- var delays = style.transitionDelay.split(',').map(milliseconds);
- var duration = Math.max.apply(Math, durations.map(function(value, index) {
- return value + delays[index % delays.length];
- }));
- // Reduced motion, display changes or interrupted transitions may
- // omit the visibility event. Always release the dialog queue.
- fallback = setTimeout(finish, Math.max(0, duration) + 100);
- }, 150);
- });
- return promise;
- },
-
- /**
- * Do necessary works to open panel like loading panel, doing transition
- * and call related functions.
- *
- * @memberOf DialogManager
- * @access private
- * @param {BaseDialog} dialog
- * @param {Object} options
- * @return {Promise}
- */
- _open: function dm__open(dialog, options) {
- var self = this;
- var foundPanel;
-
- return Promise.resolve()
- .then(function() {
- // 1: load panel
- return self._loadPanel(dialog.panel.id);
- })
- .then(function() {
- // 2: Get that panel
- return self._getPanel(dialog.panel.id);
- })
- .then(function(panel) {
- // 3: call beforeShow
- foundPanel = panel;
- return foundPanel.beforeShow(dialog.panel, options);
- })
- .then(function() {
- // 4. UI stuffs + transition
- dialog.init();
- dialog.initUI();
- dialog.bindEvents();
-
- if (dialog.TRANSITION_CLASS === 'zoom-in-80') {
- self._showOverlay(true);
- }
-
- return self._transit('open', dialog, options);
- })
- .then(function() {
- // 5. show that panel as a dialog
- return foundPanel.show(dialog.panel, options);
- });
- },
-
- /**
- * Do necessary works to close panel like loading panel, doing transition
- * and call related functions.
- *
- * @memberOf DialogManager
- * @access private
- * @param {BaseDialog} dialog
- * @param {Object} options
- * @return {Promise}
- */
- _close: function dm__close(dialog, options) {
- var self = this;
- var foundPanel;
- var cachedResult;
-
- return Promise.resolve()
- .then(function() {
- // 1: Get that panel
- return self._getPanel(dialog.panel.id);
- })
- .then(function(panel) {
- // 2: Let's validate to see whether we can close this dialog or not.
- foundPanel = panel;
-
- var promise;
- // custom dialog - onSubmit
- if (foundPanel.onSubmit && options._type === 'submit') {
- promise = foundPanel.onSubmit();
- // custom dialog - onCancel
- } else if (foundPanel.onCancel && options._type === 'cancel') {
- promise = foundPanel.onCancel();
- // if no onSubmit & onCancel, pass directly
- } else {
- promise = Promise.resolve();
- }
-
- return promise;
- })
- .then(function(result) {
- cachedResult = result;
-
- // 3: call beforeHide
- return foundPanel.beforeHide();
- })
- .then(function() {
- // 4. transition
- return self._transit('close', dialog, options);
- })
- .then(function() {
- // 5. call hide
- return foundPanel.hide();
- })
- .then(function() {
- // 6. Get result and cleanup dialog
- var result;
-
- // for prompt dialog, we have to get its own result from input text.
- if (dialog.DIALOG_CLASS === 'prompt-dialog') {
- result = dialog.getResult();
- } else if (cachedResult) {
- result = cachedResult;
- }
-
- dialog.cleanup();
-
- if (dialog.TRANSITION_CLASS === 'zoom-in-80') {
- self._showOverlay(false);
- }
-
- return result;
- });
- },
-
- /**
- * It is a bridge to call open or close function.
- *
- * @memberOf DialogManager
- * @access private
- * @param {String} method
- * @param {BaseDialog} dialog
- * @param {Object} options
- * @return {Promise}
- */
- _navigate: function dm__navigate(method, dialog, options) {
- method = (method === 'open') ? '_open' : '_close';
- return this[method](dialog, options);
- },
-
- /**
- * DialogService would use this exposed API to open dialog.
- *
- * @memberOf DialogManager
- * @access public
- * @param {BaseDialog} dialog
- * @param {Object} options
- * @return {Promise}
- */
- open: function dm_open(dialog, options) {
- return this._navigate('open', dialog, options);
- },
-
- /**
- * DialogService would use this exposed API to close dialog.
- *
- * @memberOf DialogManager
- * @access public
- * @param {BaseDialog} dialog
- * @param {Object} options
- * @return {Promise}
- */
- close: function dm_close(dialog, type, options) {
- options._type = type;
- return this._navigate('close', dialog, options);
- }
- };
-
- var dialogManager = new DialogManager();
- return dialogManager;
-});
-define('modules/dialog/base_dialog',['require'],function(require){'use strict';var BaseDialog=function(panelDOM,options){this.panel=panelDOM;this._options=options||{};};BaseDialog.prototype.DIALOG_CLASS='dialog';BaseDialog.prototype.TRANSITION_CLASS='fade';BaseDialog.prototype.SUBMIT_BUTTON_SELECTOR='[type="submit"]';BaseDialog.prototype.CANCEL_BUTTON_SELECTOR='[type="reset"]';BaseDialog.prototype.MESSAGE_SELECTOR='.settings-dialog-message';BaseDialog.prototype.TITLE_SELECTOR='.settings-dialog-title';BaseDialog.prototype.init=function bd_init(){this.TRANSITION_CLASS=this._options.transition||this.TRANSITION_CLASS;this.panel.classList.add(this.DIALOG_CLASS);this.panel.classList.add(this.TRANSITION_CLASS);};BaseDialog.prototype.initUI=function bd_initUI(){var message=this._options.message;var title=this._options.title;var submitButton=this._options.submitButton;var cancelButton=this._options.cancelButton;this._updateMessage(message);this._updateTitle(title);this._updateSubmitButton(submitButton);this._updateCancelButton(cancelButton);};BaseDialog.prototype.bindEvents=function bd_bindEvent(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};BaseDialog.prototype._updateMessage=function bd__updateMessage(message){var messageDOM=this.panel.querySelector(this.MESSAGE_SELECTOR);if(messageDOM&&message){message=this._getWrapL10nObject(message);document.l10n.setAttributes(messageDOM,message.id,message.args);}};BaseDialog.prototype._updateTitle=function bd__updateTitle(title){var titleDOM=this.panel.querySelector(this.TITLE_SELECTOR);if(titleDOM&&title){title=this._getWrapL10nObject(title);document.l10n.setAttributes(titleDOM,title.id,title.args);}};BaseDialog.prototype._updateSubmitButton=function bd__update(options){var buttonDOM=this.getSubmitButton();if(buttonDOM&&options){options=this._getWrapL10nObject(options);document.l10n.setAttributes(buttonDOM,options.id,options.args);buttonDOM.className=options.style||'recommend';}};BaseDialog.prototype._updateCancelButton=function bd__updateText(options){var buttonDOM=this.getCancelButton();if(buttonDOM&&options){options=this._getWrapL10nObject(options);document.l10n.setAttributes(buttonDOM,options.id,options.args);buttonDOM.className=options.style||'';}};BaseDialog.prototype._getWrapL10nObject=function bd__getWrapL10nObject(input){if(typeof input==='string'){return{id:input,args:null};}else if(typeof input==='object'){if(typeof input.id==='undefined'){throw new Error('You forgot to put l10nId - '+
-JSON.stringify(input));}else{return{id:input.id,args:input.args||null,style:input.style};}}else{throw new Error('You are using the wrong L10nObject, '+'please check its format again');}};BaseDialog.prototype.getDOM=function bd_getDOM(){return this.panel;};BaseDialog.prototype.getSubmitButton=function bd_getSubmitButton(){return this.panel.querySelector(this.SUBMIT_BUTTON_SELECTOR);};BaseDialog.prototype.getCancelButton=function bd_getCancelButton(){return this.panel.querySelector(this.CANCEL_BUTTON_SELECTOR);};BaseDialog.prototype.cleanup=function bd_cleanup(){if(this.DIALOG_CLASS!=='panel-dialog'){this._updateTitle('settings-'+this.DIALOG_CLASS+'-header');this._updateSubmitButton('ok');this._updateCancelButton('cancel');}
-this.panel.classList.remove(this.DIALOG_CLASS);this.panel.classList.remove(this.TRANSITION_CLASS);};return BaseDialog;});define('modules/dialog/panel_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var PanelDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};PanelDialog.prototype=Object.create(BaseDialog.prototype);PanelDialog.prototype.constructor=PanelDialog;PanelDialog.prototype.DIALOG_CLASS='panel-dialog';PanelDialog.prototype.TRANSITION_CLASS='fade';return function ctor_PanelDialog(panelDOM,options){return new PanelDialog(panelDOM,options);};});define('modules/dialog/alert_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var AlertDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};AlertDialog.prototype=Object.create(BaseDialog.prototype);AlertDialog.prototype.constructor=AlertDialog;AlertDialog.prototype.DIALOG_CLASS='alert-dialog';AlertDialog.prototype.TRANSITION_CLASS='fade';AlertDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};};return function ctor_alertDialog(panelDOM,options){return new AlertDialog(panelDOM,options);};});define('modules/dialog/confirm_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var ConfirmDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};ConfirmDialog.prototype=Object.create(BaseDialog.prototype);ConfirmDialog.prototype.constructor=ConfirmDialog;ConfirmDialog.prototype.DIALOG_CLASS='confirm-dialog';ConfirmDialog.prototype.TRANSITION_CLASS='fade';ConfirmDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};return function ctor_confirmDialog(panelDOM,options){return new ConfirmDialog(panelDOM,options);};});define('modules/dialog/prompt_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var PromptDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};PromptDialog.prototype=Object.create(BaseDialog.prototype);PromptDialog.prototype.constructor=PromptDialog;PromptDialog.prototype.DIALOG_CLASS='prompt-dialog';PromptDialog.prototype.TRANSITION_CLASS='fade';PromptDialog.prototype.INPUT_SELECTOR='.settings-dialog-input';PromptDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};PromptDialog.prototype.initUI=function(){BaseDialog.prototype.initUI.call(this);this.getInput().value=this._options.defaultValue||'';};PromptDialog.prototype.getInput=function(){return this.panel.querySelector(this.INPUT_SELECTOR);};PromptDialog.prototype.getResult=function(){return this.getInput().value;};return function ctor_promptDialog(panelDOM,options){return new PromptDialog(panelDOM,options);};});define('modules/dialog_service',["require", "settings", "modules/defer", "modules/dialog_manager", "modules/dialog/panel_dialog", "modules/dialog/alert_dialog", "modules/dialog/confirm_dialog", "modules/dialog/prompt_dialog"],function(require) {
- 'use strict';
-
- var Settings = require('settings');
- var Defer = require('modules/defer');
- var DialogManager = require('modules/dialog_manager');
-
- var PanelDialog = require('modules/dialog/panel_dialog');
- var AlertDialog = require('modules/dialog/alert_dialog');
- var ConfirmDialog = require('modules/dialog/confirm_dialog');
- var PromptDialog = require('modules/dialog/prompt_dialog');
-
- var DialogService = function() {
- this._navigating = false;
- this._pendingRequests = [];
- this._settingsAlertDialogId = 'settings-alert-dialog';
- this._settingsBaseDialogId = 'settings-base-dialog';
- this._settingsConfirmDialogId = 'settings-confirm-dialog';
- this._settingsPromptDialogId = 'settings-prompt-dialog';
- };
-
- DialogService.prototype = {
- /**
- * Alert dialog with more controls.
- *
- * @memberOf DialogService
- * @access public
- * @param {String} message
- * @param {Object} userOptions
- * @return {Promise}
- */
- alert: function(message, userOptions) {
- var options = userOptions || {};
- return this.show(this._settingsAlertDialogId, {
- type: 'alert',
- message: message,
- title: options.title,
- submitButton: options.submitButton
- });
- },
-
- /**
- * Confirm dialog with more controls.
- *
- * @memberOf DialogService
- * @access public
- * @param {String} message
- * @param {Object} userOptions
- * @return {Promise}
- */
- confirm: function(message, userOptions) {
- var options = userOptions || {};
- return this.show(this._settingsConfirmDialogId, {
- type: 'confirm',
- message: message,
- title: options.title,
- submitButton: options.submitButton,
- cancelButton: options.cancelButton
- });
- },
-
- /**
- * Prompt dialog with more controls.
- *
- * @memberOf DialogService
- * @access public
- * @param {String} message
- * @param {Object} userOptions
- * @return {Promise}
- */
- prompt: function(message, userOptions) {
- var options = userOptions || {};
- return this.show(this._settingsPromptDialogId, {
- type: 'prompt',
- message: message,
- title: options.title,
- defaultValue: options.defaultValue,
- submitButton: options.submitButton,
- cancelButton: options.cancelButton
- });
- },
-
- /**
- * Panel dialog. If you are going to show any panel as a dialog,
- * you have to use this method to show them.
- *
- * @memberOf DialogService
- * @access public
- * @param {String} panelId
- * @param {Object} userOptions
- * @return {Promise}
- */
- show: function dm_show(panelId, userOptions, _pendingDefer) {
- var self = this;
- var defer;
- var dialog;
- var dialogDOM = document.getElementById(panelId);
- var currentPanel = Settings.currentPanel;
- var options = userOptions || {};
-
- if (_pendingDefer) {
- defer = _pendingDefer;
- } else {
- defer = Defer();
- }
-
- if (this._navigating) {
- this._pendingRequests.push({
- defer: defer,
- panelId: panelId,
- userOptions: userOptions
- });
- } else {
- if ('#' + panelId === currentPanel) {
- defer.reject('You are showing the same panel #' + panelId);
- } else {
- var opening;
- var closing = false;
- function close(type) {
- if (closing) return;
- closing = true;
- // A tap during the opening animation must not run hide/show
- // concurrently, or a queued dialog can inherit stale form values.
- Promise.resolve(opening).then(function() {
- return DialogManager.close(dialog, type, options);
- }).then(function(result) {
- self._navigating = false;
- defer.resolve({type: type, value: result});
- self._execPendingRequest();
- }, function(error) {
- // Panel validation rejects to keep the form open. Allow a
- // correction or cancellation without rejecting the show promise.
- closing = false;
- dialogDOM.dispatchEvent(new CustomEvent('dialogvalidationfailed', {
- detail: error
- }));
- });
- }
- options.onWrapSubmit = function() { close('submit'); };
- options.onWrapCancel = function() { close('cancel'); };
-
- switch (options.type) {
- case 'alert':
- dialog = AlertDialog(dialogDOM, options);
- break;
- case 'confirm':
- dialog = ConfirmDialog(dialogDOM, options);
- break;
- case 'prompt':
- dialog = PromptDialog(dialogDOM, options);
- break;
- default:
- dialog = PanelDialog(dialogDOM, options);
- break;
- }
- this._navigating = true;
- opening = DialogManager.open(dialog, options);
- opening.catch(function(error) {
- // A missing host API must not lock every later Settings dialog.
- dialogDOM.classList.remove('current');
- dialogDOM.hidden = true;
- var overlay = document.querySelector('.settings-dialog-overlay');
- if (overlay) overlay.hidden = true;
- self._navigating = false;
- defer.reject(error);
- self._execPendingRequest();
- });
- }
- }
-
- return defer.promise;
- },
-
- /**
- * This method can help us pop up any pending request and would try to
- * show it after previous request was done.
- *
- * @memberOf DialogService
- * @access private
- */
- _execPendingRequest: function() {
- var request = this._pendingRequests.pop();
- if (request) {
- this.show(request.panelId, request.userOptions, request.defer);
- }
- }
- };
-
- var dialogService = new DialogService();
- return dialogService;
-});
-define('panels/keyboard_add_layouts/core',['require','modules/mvvm/list_view'],function(require){'use strict';var ListView=require('modules/mvvm/list_view');function KeyboardAddLayoutsCore(context,template){this._enabled=false;this._listView=null;this._keyboardContext=context;this._keyboardTemplate=template;}
-KeyboardAddLayoutsCore.prototype={get enabled(){return this._enabled;},set enabled(value){this._enabled=value;if(this._listView){this._listView.enabled=this._enabled;}
-this._keyboardTemplate.listViews.forEach(function(listView){listView.enabled=this._enabled;}.bind(this));},_initInstalledLayoutListView:function kal_initListView(listViewRoot,keyboards,keyboardTemplate){this._listView=ListView(listViewRoot,keyboards,keyboardTemplate);},_showEnabledDefaultDialog:function kal_showDialog(layout,missingType){require(['modules/dialog_service'],function(DialogService){document.l10n.formatValue('keyboardType-'+missingType).then(type=>{DialogService.alert({id:'defaultKeyboardEnabled',args:{layoutName:layout.inputManifest.name,appName:layout.manifest.name}},{title:{id:'mustHaveOneKeyboard',args:{type:type}}});});});},init:function kal_onInit(elements){var that=this;this._keyboardContext.init(function(){that._keyboardContext.keyboards(function(keyboards){that._initInstalledLayoutListView(elements.listViewRoot,keyboards,that._keyboardTemplate);that.enabled=true;});});this._keyboardContext.defaultKeyboardEnabled(this._showEnabledDefaultDialog);}};return function ctor_kalCore(context,template){return new KeyboardAddLayoutsCore(context,template);};});define('panels/keyboard_add_layouts/panel',['require','modules/settings_panel','modules/keyboard_context','shared/keyboard_helper','panels/keyboard_add_layouts/nested_template_factory','panels/keyboard_add_layouts/keyboard_template','panels/keyboard_add_layouts/layout_template','panels/keyboard_add_layouts/core'],function(require){'use strict';var SettingsPanel=require('modules/settings_panel');var KeyboardContext=require('modules/keyboard_context');var KeyboardHelper=require('shared/keyboard_helper');var NestedTemplateFactory=require('panels/keyboard_add_layouts/nested_template_factory');var keyboardTemplate=require('panels/keyboard_add_layouts/keyboard_template');var layoutTemplate=require('panels/keyboard_add_layouts/layout_template');var Core=require('panels/keyboard_add_layouts/core');return function ctor_addLayoutsPanel(){var nestedTemplate=NestedTemplateFactory(keyboardTemplate,layoutTemplate);var core=Core(KeyboardContext,nestedTemplate);return SettingsPanel({onInit:function kalp_onInit(rootElement){core.init({listViewRoot:rootElement.querySelector('.keyboardAppContainer')});},onBeforeShow:function kalp_onBeforeShow(){core.enabled=true;},onBeforeHide:function kalp_onBeforeHide(){KeyboardHelper.saveToSettings();},onHide:function kalp_onHide(){core.enabled=false;}});};});
\ No newline at end of file
diff --git a/overrides/settings.gaiamobile.org/js/panels/screen_lock/panel.js b/overrides/settings.gaiamobile.org/js/panels/screen_lock/panel.js
deleted file mode 100644
index 33fa6a19..00000000
--- a/overrides/settings.gaiamobile.org/js/panels/screen_lock/panel.js
+++ /dev/null
@@ -1,486 +0,0 @@
-
-define('modules/dialog_manager',["require", "modules/panel_cache", "shared/lazy_loader"],function(require) {
- 'use strict';
-
- var PanelCache = require('modules/panel_cache');
- var LazyLoader = require('shared/lazy_loader');
-
- var DialogManager = function() {
- this.OVERLAY_SELECTOR = '.settings-dialog-overlay';
-
- this._overlayDOM = document.querySelector(this.OVERLAY_SELECTOR);
- };
-
- DialogManager.prototype = {
- /**
- * load panel based on passed in panelId
- *
- * @memberOf DialogManager
- * @access private
- * @param {String} panelId
- * @return {Promise}
- */
- _loadPanel: function dm__loadPanel(panelId) {
- var promise = new Promise(function(resolve, reject) {
- var panelElement = document.getElementById(panelId);
- if (panelElement.dataset.rendered) { // already initialized
- resolve();
- return;
- }
-
- panelElement.dataset.rendered = true;
-
- // XXX remove SubPanel loader once sub panel are modulized
- if (panelElement.dataset.requireSubPanels) {
- // load the panel and its sub-panels (dependencies)
- // (load the main panel last because it contains the scripts)
- var selector = 'section[id^="' + panelElement.id + '-"]';
- var subPanels = document.querySelectorAll(selector);
- for (var i = 0, il = subPanels.length; i < il; i++) {
- LazyLoader.load([subPanels[i]]);
- }
- LazyLoader.load([panelElement], resolve);
- } else {
- LazyLoader.load([panelElement], resolve);
- }
- });
- return promise;
- },
-
- /**
- * promised version of PanelCache.get()
- *
- * @memberOf DialogManager
- * @access private
- * @param {String} panelId
- * @return {Promise}
- */
- _getPanel: function dm__getPanel(panelId) {
- var promise = new Promise(function(resolve) {
- PanelCache.get(panelId, function(panel) {
- resolve(panel);
- });
- });
- return promise;
- },
-
- /**
- * this is used to control visibility of overlayDOM
- *
- * @memberOf DialogManager
- * @access private
- * @param {Boolean} show
- */
- _showOverlay: function dm__showOverlay(show) {
- this._overlayDOM.hidden = !show;
- },
-
- /**
- * It is used to control the timing of transitions so that we can make sure
- * whether animation is done or not.
- *
- * @memberOf DialogManager
- * @access private
- * @param {String} method
- * @param {BaseDialog} dialog
- * @param {Object} options
- * @return {Promise}
- */
- _transit: function dm__transit(method, dialog, options) {
- var promise = new Promise(function(resolve) {
- var panel = dialog.panel;
- var completed = false;
- var fallback;
- function finish(event) {
- if (event && (event.target !== panel || event.propertyName !== 'visibility')) return;
- if (completed) return;
- completed = true;
- clearTimeout(fallback);
- panel.removeEventListener('transitionend', finish);
- panel.removeEventListener('transitioncancel', finish);
- if (method === 'close') panel.hidden = true;
- resolve();
- }
- if (method === 'open') panel.hidden = false;
- setTimeout(function() {
- panel.addEventListener('transitionend', finish);
- panel.addEventListener('transitioncancel', finish);
- panel.classList.toggle('current', method === 'open');
- var style = window.getComputedStyle(panel);
- function milliseconds(value) {
- return parseFloat(value) * (value.trim().endsWith('ms') ? 1 : 1000) || 0;
- }
- var durations = style.transitionDuration.split(',').map(milliseconds);
- var delays = style.transitionDelay.split(',').map(milliseconds);
- var duration = Math.max.apply(Math, durations.map(function(value, index) {
- return value + delays[index % delays.length];
- }));
- // Reduced motion, display changes or interrupted transitions may
- // omit the visibility event. Always release the dialog queue.
- fallback = setTimeout(finish, Math.max(0, duration) + 100);
- }, 150);
- });
- return promise;
- },
-
- /**
- * Do necessary works to open panel like loading panel, doing transition
- * and call related functions.
- *
- * @memberOf DialogManager
- * @access private
- * @param {BaseDialog} dialog
- * @param {Object} options
- * @return {Promise}
- */
- _open: function dm__open(dialog, options) {
- var self = this;
- var foundPanel;
-
- return Promise.resolve()
- .then(function() {
- // 1: load panel
- return self._loadPanel(dialog.panel.id);
- })
- .then(function() {
- // 2: Get that panel
- return self._getPanel(dialog.panel.id);
- })
- .then(function(panel) {
- // 3: call beforeShow
- foundPanel = panel;
- return foundPanel.beforeShow(dialog.panel, options);
- })
- .then(function() {
- // 4. UI stuffs + transition
- dialog.init();
- dialog.initUI();
- dialog.bindEvents();
-
- if (dialog.TRANSITION_CLASS === 'zoom-in-80') {
- self._showOverlay(true);
- }
-
- return self._transit('open', dialog, options);
- })
- .then(function() {
- // 5. show that panel as a dialog
- return foundPanel.show(dialog.panel, options);
- });
- },
-
- /**
- * Do necessary works to close panel like loading panel, doing transition
- * and call related functions.
- *
- * @memberOf DialogManager
- * @access private
- * @param {BaseDialog} dialog
- * @param {Object} options
- * @return {Promise}
- */
- _close: function dm__close(dialog, options) {
- var self = this;
- var foundPanel;
- var cachedResult;
-
- return Promise.resolve()
- .then(function() {
- // 1: Get that panel
- return self._getPanel(dialog.panel.id);
- })
- .then(function(panel) {
- // 2: Let's validate to see whether we can close this dialog or not.
- foundPanel = panel;
-
- var promise;
- // custom dialog - onSubmit
- if (foundPanel.onSubmit && options._type === 'submit') {
- promise = foundPanel.onSubmit();
- // custom dialog - onCancel
- } else if (foundPanel.onCancel && options._type === 'cancel') {
- promise = foundPanel.onCancel();
- // if no onSubmit & onCancel, pass directly
- } else {
- promise = Promise.resolve();
- }
-
- return promise;
- })
- .then(function(result) {
- cachedResult = result;
-
- // 3: call beforeHide
- return foundPanel.beforeHide();
- })
- .then(function() {
- // 4. transition
- return self._transit('close', dialog, options);
- })
- .then(function() {
- // 5. call hide
- return foundPanel.hide();
- })
- .then(function() {
- // 6. Get result and cleanup dialog
- var result;
-
- // for prompt dialog, we have to get its own result from input text.
- if (dialog.DIALOG_CLASS === 'prompt-dialog') {
- result = dialog.getResult();
- } else if (cachedResult) {
- result = cachedResult;
- }
-
- dialog.cleanup();
-
- if (dialog.TRANSITION_CLASS === 'zoom-in-80') {
- self._showOverlay(false);
- }
-
- return result;
- });
- },
-
- /**
- * It is a bridge to call open or close function.
- *
- * @memberOf DialogManager
- * @access private
- * @param {String} method
- * @param {BaseDialog} dialog
- * @param {Object} options
- * @return {Promise}
- */
- _navigate: function dm__navigate(method, dialog, options) {
- method = (method === 'open') ? '_open' : '_close';
- return this[method](dialog, options);
- },
-
- /**
- * DialogService would use this exposed API to open dialog.
- *
- * @memberOf DialogManager
- * @access public
- * @param {BaseDialog} dialog
- * @param {Object} options
- * @return {Promise}
- */
- open: function dm_open(dialog, options) {
- return this._navigate('open', dialog, options);
- },
-
- /**
- * DialogService would use this exposed API to close dialog.
- *
- * @memberOf DialogManager
- * @access public
- * @param {BaseDialog} dialog
- * @param {Object} options
- * @return {Promise}
- */
- close: function dm_close(dialog, type, options) {
- options._type = type;
- return this._navigate('close', dialog, options);
- }
- };
-
- var dialogManager = new DialogManager();
- return dialogManager;
-});
-define('modules/dialog/base_dialog',['require'],function(require){'use strict';var BaseDialog=function(panelDOM,options){this.panel=panelDOM;this._options=options||{};};BaseDialog.prototype.DIALOG_CLASS='dialog';BaseDialog.prototype.TRANSITION_CLASS='fade';BaseDialog.prototype.SUBMIT_BUTTON_SELECTOR='[type="submit"]';BaseDialog.prototype.CANCEL_BUTTON_SELECTOR='[type="reset"]';BaseDialog.prototype.MESSAGE_SELECTOR='.settings-dialog-message';BaseDialog.prototype.TITLE_SELECTOR='.settings-dialog-title';BaseDialog.prototype.init=function bd_init(){this.TRANSITION_CLASS=this._options.transition||this.TRANSITION_CLASS;this.panel.classList.add(this.DIALOG_CLASS);this.panel.classList.add(this.TRANSITION_CLASS);};BaseDialog.prototype.initUI=function bd_initUI(){var message=this._options.message;var title=this._options.title;var submitButton=this._options.submitButton;var cancelButton=this._options.cancelButton;this._updateMessage(message);this._updateTitle(title);this._updateSubmitButton(submitButton);this._updateCancelButton(cancelButton);};BaseDialog.prototype.bindEvents=function bd_bindEvent(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};BaseDialog.prototype._updateMessage=function bd__updateMessage(message){var messageDOM=this.panel.querySelector(this.MESSAGE_SELECTOR);if(messageDOM&&message){message=this._getWrapL10nObject(message);document.l10n.setAttributes(messageDOM,message.id,message.args);}};BaseDialog.prototype._updateTitle=function bd__updateTitle(title){var titleDOM=this.panel.querySelector(this.TITLE_SELECTOR);if(titleDOM&&title){title=this._getWrapL10nObject(title);document.l10n.setAttributes(titleDOM,title.id,title.args);}};BaseDialog.prototype._updateSubmitButton=function bd__update(options){var buttonDOM=this.getSubmitButton();if(buttonDOM&&options){options=this._getWrapL10nObject(options);document.l10n.setAttributes(buttonDOM,options.id,options.args);buttonDOM.className=options.style||'recommend';}};BaseDialog.prototype._updateCancelButton=function bd__updateText(options){var buttonDOM=this.getCancelButton();if(buttonDOM&&options){options=this._getWrapL10nObject(options);document.l10n.setAttributes(buttonDOM,options.id,options.args);buttonDOM.className=options.style||'';}};BaseDialog.prototype._getWrapL10nObject=function bd__getWrapL10nObject(input){if(typeof input==='string'){return{id:input,args:null};}else if(typeof input==='object'){if(typeof input.id==='undefined'){throw new Error('You forgot to put l10nId - '+
-JSON.stringify(input));}else{return{id:input.id,args:input.args||null,style:input.style};}}else{throw new Error('You are using the wrong L10nObject, '+'please check its format again');}};BaseDialog.prototype.getDOM=function bd_getDOM(){return this.panel;};BaseDialog.prototype.getSubmitButton=function bd_getSubmitButton(){return this.panel.querySelector(this.SUBMIT_BUTTON_SELECTOR);};BaseDialog.prototype.getCancelButton=function bd_getCancelButton(){return this.panel.querySelector(this.CANCEL_BUTTON_SELECTOR);};BaseDialog.prototype.cleanup=function bd_cleanup(){if(this.DIALOG_CLASS!=='panel-dialog'){this._updateTitle('settings-'+this.DIALOG_CLASS+'-header');this._updateSubmitButton('ok');this._updateCancelButton('cancel');}
-this.panel.classList.remove(this.DIALOG_CLASS);this.panel.classList.remove(this.TRANSITION_CLASS);};return BaseDialog;});define('modules/dialog/panel_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var PanelDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};PanelDialog.prototype=Object.create(BaseDialog.prototype);PanelDialog.prototype.constructor=PanelDialog;PanelDialog.prototype.DIALOG_CLASS='panel-dialog';PanelDialog.prototype.TRANSITION_CLASS='fade';return function ctor_PanelDialog(panelDOM,options){return new PanelDialog(panelDOM,options);};});define('modules/dialog/alert_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var AlertDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};AlertDialog.prototype=Object.create(BaseDialog.prototype);AlertDialog.prototype.constructor=AlertDialog;AlertDialog.prototype.DIALOG_CLASS='alert-dialog';AlertDialog.prototype.TRANSITION_CLASS='fade';AlertDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};};return function ctor_alertDialog(panelDOM,options){return new AlertDialog(panelDOM,options);};});define('modules/dialog/confirm_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var ConfirmDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};ConfirmDialog.prototype=Object.create(BaseDialog.prototype);ConfirmDialog.prototype.constructor=ConfirmDialog;ConfirmDialog.prototype.DIALOG_CLASS='confirm-dialog';ConfirmDialog.prototype.TRANSITION_CLASS='fade';ConfirmDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};return function ctor_confirmDialog(panelDOM,options){return new ConfirmDialog(panelDOM,options);};});define('modules/dialog/prompt_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var PromptDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};PromptDialog.prototype=Object.create(BaseDialog.prototype);PromptDialog.prototype.constructor=PromptDialog;PromptDialog.prototype.DIALOG_CLASS='prompt-dialog';PromptDialog.prototype.TRANSITION_CLASS='fade';PromptDialog.prototype.INPUT_SELECTOR='.settings-dialog-input';PromptDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};PromptDialog.prototype.initUI=function(){BaseDialog.prototype.initUI.call(this);this.getInput().value=this._options.defaultValue||'';};PromptDialog.prototype.getInput=function(){return this.panel.querySelector(this.INPUT_SELECTOR);};PromptDialog.prototype.getResult=function(){return this.getInput().value;};return function ctor_promptDialog(panelDOM,options){return new PromptDialog(panelDOM,options);};});define('modules/dialog_service',["require", "settings", "modules/defer", "modules/dialog_manager", "modules/dialog/panel_dialog", "modules/dialog/alert_dialog", "modules/dialog/confirm_dialog", "modules/dialog/prompt_dialog"],function(require) {
- 'use strict';
-
- var Settings = require('settings');
- var Defer = require('modules/defer');
- var DialogManager = require('modules/dialog_manager');
-
- var PanelDialog = require('modules/dialog/panel_dialog');
- var AlertDialog = require('modules/dialog/alert_dialog');
- var ConfirmDialog = require('modules/dialog/confirm_dialog');
- var PromptDialog = require('modules/dialog/prompt_dialog');
-
- var DialogService = function() {
- this._navigating = false;
- this._pendingRequests = [];
- this._settingsAlertDialogId = 'settings-alert-dialog';
- this._settingsBaseDialogId = 'settings-base-dialog';
- this._settingsConfirmDialogId = 'settings-confirm-dialog';
- this._settingsPromptDialogId = 'settings-prompt-dialog';
- };
-
- DialogService.prototype = {
- /**
- * Alert dialog with more controls.
- *
- * @memberOf DialogService
- * @access public
- * @param {String} message
- * @param {Object} userOptions
- * @return {Promise}
- */
- alert: function(message, userOptions) {
- var options = userOptions || {};
- return this.show(this._settingsAlertDialogId, {
- type: 'alert',
- message: message,
- title: options.title,
- submitButton: options.submitButton
- });
- },
-
- /**
- * Confirm dialog with more controls.
- *
- * @memberOf DialogService
- * @access public
- * @param {String} message
- * @param {Object} userOptions
- * @return {Promise}
- */
- confirm: function(message, userOptions) {
- var options = userOptions || {};
- return this.show(this._settingsConfirmDialogId, {
- type: 'confirm',
- message: message,
- title: options.title,
- submitButton: options.submitButton,
- cancelButton: options.cancelButton
- });
- },
-
- /**
- * Prompt dialog with more controls.
- *
- * @memberOf DialogService
- * @access public
- * @param {String} message
- * @param {Object} userOptions
- * @return {Promise}
- */
- prompt: function(message, userOptions) {
- var options = userOptions || {};
- return this.show(this._settingsPromptDialogId, {
- type: 'prompt',
- message: message,
- title: options.title,
- defaultValue: options.defaultValue,
- submitButton: options.submitButton,
- cancelButton: options.cancelButton
- });
- },
-
- /**
- * Panel dialog. If you are going to show any panel as a dialog,
- * you have to use this method to show them.
- *
- * @memberOf DialogService
- * @access public
- * @param {String} panelId
- * @param {Object} userOptions
- * @return {Promise}
- */
- show: function dm_show(panelId, userOptions, _pendingDefer) {
- var self = this;
- var defer;
- var dialog;
- var dialogDOM = document.getElementById(panelId);
- var currentPanel = Settings.currentPanel;
- var options = userOptions || {};
-
- if (_pendingDefer) {
- defer = _pendingDefer;
- } else {
- defer = Defer();
- }
-
- if (this._navigating) {
- this._pendingRequests.push({
- defer: defer,
- panelId: panelId,
- userOptions: userOptions
- });
- } else {
- if ('#' + panelId === currentPanel) {
- defer.reject('You are showing the same panel #' + panelId);
- } else {
- var opening;
- var closing = false;
- function close(type) {
- if (closing) return;
- closing = true;
- // A tap during the opening animation must not run hide/show
- // concurrently, or a queued dialog can inherit stale form values.
- Promise.resolve(opening).then(function() {
- return DialogManager.close(dialog, type, options);
- }).then(function(result) {
- self._navigating = false;
- defer.resolve({type: type, value: result});
- self._execPendingRequest();
- }, function(error) {
- // Panel validation rejects to keep the form open. Allow a
- // correction or cancellation without rejecting the show promise.
- closing = false;
- dialogDOM.dispatchEvent(new CustomEvent('dialogvalidationfailed', {
- detail: error
- }));
- });
- }
- options.onWrapSubmit = function() { close('submit'); };
- options.onWrapCancel = function() { close('cancel'); };
-
- switch (options.type) {
- case 'alert':
- dialog = AlertDialog(dialogDOM, options);
- break;
- case 'confirm':
- dialog = ConfirmDialog(dialogDOM, options);
- break;
- case 'prompt':
- dialog = PromptDialog(dialogDOM, options);
- break;
- default:
- dialog = PanelDialog(dialogDOM, options);
- break;
- }
- this._navigating = true;
- opening = DialogManager.open(dialog, options);
- opening.catch(function(error) {
- // A missing host API must not lock every later Settings dialog.
- dialogDOM.classList.remove('current');
- dialogDOM.hidden = true;
- var overlay = document.querySelector('.settings-dialog-overlay');
- if (overlay) overlay.hidden = true;
- self._navigating = false;
- defer.reject(error);
- self._execPendingRequest();
- });
- }
- }
-
- return defer.promise;
- },
-
- /**
- * This method can help us pop up any pending request and would try to
- * show it after previous request was done.
- *
- * @memberOf DialogService
- * @access private
- */
- _execPendingRequest: function() {
- var request = this._pendingRequests.pop();
- if (request) {
- this.show(request.panelId, request.userOptions, request.defer);
- }
- }
- };
-
- var dialogService = new DialogService();
- return dialogService;
-});
-define('panels/screen_lock/screen_lock',['require','modules/dialog_service','shared/settings_listener'],function(require){'use strict';var DialogService=require('modules/dialog_service');var SettingsListener=require('shared/settings_listener');var ScreenLock=function ctor_screenlock(){return{_settings:{passcodeEnabled:false,lockscreenEnabled:false},onInit:function sl_onInit(elements){this._elements=elements;this._elements.passcodeEnable.addEventListener('click',this);this._elements.lockscreenEnable.addEventListener('click',this);this._elements.passcodeEditButton.addEventListener('click',this);this._fetchSettings();},_fetchSettings:function sl_fetchSettings(){SettingsListener.observe('lockscreen.enabled',false,function(enabled){this._toggleLockscreen(enabled);}.bind(this));SettingsListener.observe('lockscreen.passcode-lock.enabled',false,function(enabled){this._togglePasscode(enabled);}.bind(this));},_togglePasscode:function sl_togglePasscode(enabled){this._settings.passcodeEnabled=enabled;this._elements.panel.dataset.passcodeEnabled=enabled;this._elements.passcodeEnable.checked=enabled;},_toggleLockscreen:function sl_toggleLockscreen(enabled){this._settings.lockscreenEnabled=enabled;this._elements.panel.dataset.lockscreenEnabled=enabled;this._elements.lockscreenEnable.checked=enabled;},_showDialog:function sl_showDialog(mode){DialogService.show('screenLock-passcode',{mode:mode});},handleEvent:function sl_handleEvent(evt){switch(evt.target){case this._elements.passcodeEnable:evt.preventDefault();if(this._settings.passcodeEnabled){this._showDialog('confirm');}else{this._showDialog('create');}
-break;case this._elements.lockscreenEnable:if(this._settings.lockscreenEnabled===true&&this._settings.passcodeEnabled===true){evt.preventDefault();this._showDialog('confirmLock');}
-break;case this._elements.passcodeEditButton:this._showDialog('edit');break;}}};};return ScreenLock;});define('panels/screen_lock/panel',['require','modules/settings_panel','panels/screen_lock/screen_lock'],function(require){'use strict';var SettingsPanel=require('modules/settings_panel');var ScreenLock=require('panels/screen_lock/screen_lock');return function ctor_screenlock(){var screenLock=ScreenLock();return SettingsPanel({onInit:function(panel){var elements={panel:panel,lockscreenEnable:panel.querySelector('.lockscreen-enable'),passcodeEnable:panel.querySelector('.passcode-enable'),passcodeEditButton:panel.querySelector('.passcode-edit')};screenLock.onInit(elements);}});};});
\ No newline at end of file
diff --git a/overrides/settings.gaiamobile.org/js/panels/simcard_manager/panel.js b/overrides/settings.gaiamobile.org/js/panels/simcard_manager/panel.js
deleted file mode 100644
index ace8f3b1..00000000
--- a/overrides/settings.gaiamobile.org/js/panels/simcard_manager/panel.js
+++ /dev/null
@@ -1,17 +0,0 @@
-
-define('panels/simcard_manager/sim_ui_model',['require'],function(require){'use strict';var SimUIModel=function(cardIndex){this._cardIndex=cardIndex;this._enabled=false;this._absent=false;this._locked=false;this._defaultName={id:'simWithIndex',args:{index:this._cardIndex+1}};this._name=this._defaultName;this._number='';this._operator=null;};SimUIModel.prototype={getInfo:function(){var keys=['enabled','absent','locked','name','number','operator'];var info={};keys.forEach(function(key){info[key]=this['_'+key];}.bind(this));return info;},setState:function(key,options){switch(key){case'nosim':this._enabled=false;this._absent=true;this._locked=false;this._number='';this._operator={id:'noSimCard'};this._name=this._defaultName;break;case'locked':this._enabled=false;this._absent=false;this._locked=true;this._number='';this._operator={id:'sim-pin-locked'};this._name=this._defaultName;break;case'blocked':this._enabled=true;this._absent=true;this._locked=false;this._number='';this._operator=null;this._name={id:'noSimCard'};break;case'normal':this._enabled=true;this._absent=false;this._locked=false;this._number=options.number;if(options.operator){this._operator={text:options.operator};}else{this._operator={id:'no-operator'};}
-this._name=this._defaultName;break;}}};return function ctor_simUIModel(cardIndex){return new SimUIModel(cardIndex);};});define('panels/simcard_manager/simcard_manager',['require','shared/sanitizer','shared/sim_settings_helper','shared/airplane_mode_helper','shared/mobile_operator','panels/simcard_manager/sim_ui_model'],function(require){'use strict';var l10n=document.l10n;var Sanitizer=require('shared/sanitizer');var SimSettingsHelper=require('shared/sim_settings_helper');var AirplaneModeHelper=require('shared/airplane_mode_helper');var MobileOperator=require('shared/mobile_operator');var SimUIModel=require('panels/simcard_manager/sim_ui_model');var SimCardManager=function(elements){this._elements=elements;this._simcards=[];this._isAirplaneMode=false;};SimCardManager.prototype={init:function scm_init(){this._elements.outgoingCallSelect.addEventListener('change',this);this._elements.outgoingMessagesSelect.addEventListener('change',this);this._addOutgoingDataSelectEvent();this._addVoiceChangeEventOnConns();this._addCardStateChangeEventOnIccs();this._addLocalizedChangeEventOnIccs();SimSettingsHelper.observe('outgoingData',this._outgoingDataChangeEvent.bind(this));this._addAirplaneModeChangeEvent();this._isAirplaneMode=AirplaneModeHelper.getStatus()==='enabled'?true:false;this._initSimCardsInfo();this._initSimCardManagerUI();},simItemView:function({simIndex}){return Sanitizer.escapeHTML`
-
-
- `;},_initSimCardsInfo:function scm__initSimCardsInfo(){var conns=window.navigator.mozMobileConnections;for(var cardIndex=0;cardIndex{var wantToChange=window.confirm(value);if(wantToChange){SimSettingsHelper.setServiceOnCard('outgoingData',newCardIndex);}else{this.selectedIndex=prevCardIndex;}});}});},_getSimCardsCount:function scm__getSimCardsCount(){return this._simcards.length;},_getSimCardInfo:function scm__getSimCardInfo(cardIndex){return this._simcards[cardIndex].getInfo();},_getSimCard:function scm__getSimCard(cardIndex){return this._simcards[cardIndex];},_updateSimCardsUI:function scm__updateSimCardsUI(){this._simcards.forEach(function(simcard,cardIndex){this._updateSimCardUI(cardIndex);}.bind(this));},_updateSimCardUI:function scm__updateSimCardUI(cardIndex){var simcardInfo=this._getSimCardInfo(cardIndex);var selectors=['name','number','operator'];var cardSelector='.sim-card-'+cardIndex;var cardDom=this._elements.simCardContainer.querySelector(cardSelector);cardDom.classList.toggle('absent',simcardInfo.absent);cardDom.classList.toggle('locked',simcardInfo.locked);cardDom.classList.toggle('enabled',simcardInfo.enabled);cardDom.classList.toggle('with-number',!!simcardInfo.number);selectors.forEach(function(selector){var targetSelector=cardSelector+' .sim-card-'+selector;var element=this._elements.simCardContainer.querySelector(targetSelector);if(selector==='number'){element.textContent=simcardInfo[selector];}else{var l10nObj=simcardInfo[selector];if(l10nObj.id){l10n.setAttributes(element,l10nObj.id,l10nObj.args);}else{element.removeAttribute('data-l10n-id');element.textContent=l10nObj.text;}}}.bind(this));},_initSimCardManagerUI:function scm__initSimCardManagerUI(){this._initSimCardsUI();this._updateSelectOptionsUI();this._updateSimCardsUI();this._updateSimSettingsUI();},_initSimCardsUI:function scm__initSimCardsUI(){var simItemHTMLs=[];this._simcards.forEach(function(simcard,index){simItemHTMLs.push(this.simItemView({simIndex:index.toString()}));}.bind(this));this._elements.simCardContainer.innerHTML=simItemHTMLs.join('');},_updateSimSettingsUI:function scm__updateSimSettingsUI(){var firstCardInfo=this._simcards[0].getInfo();var secondCardInfo=(this._simcards[1]?this._simcards[1].getInfo():{absent:true});var hidden=firstCardInfo.absent&&secondCardInfo.absent||this._isAirplaneMode;this._elements.simSettingsHeader.hidden=hidden;this._elements.simSettingsList.hidden=hidden;},_updateSelectOptionsUI:function scm__updateSelectOptionsUI(){var firstCardInfo=this._simcards[0].getInfo();var secondCardInfo=(this._simcards[1]?this._simcards[1].getInfo():{absent:true});if(!firstCardInfo.absent&&!secondCardInfo.absent){SimSettingsHelper.getCardIndexFrom('outgoingCall',function(cardIndex){this._updateSelectOptionUI('outgoingCall',cardIndex,this._elements.outgoingCallSelect);}.bind(this));SimSettingsHelper.getCardIndexFrom('outgoingMessages',function(cardIndex){this._updateSelectOptionUI('outgoingMessages',cardIndex,this._elements.outgoingMessagesSelect);}.bind(this));SimSettingsHelper.getCardIndexFrom('outgoingData',function(cardIndex){this._updateSelectOptionUI('outgoingData',cardIndex,this._elements.outgoingDataSelect);}.bind(this));}else{var selectedCardIndex;if(firstCardInfo.absent&&secondCardInfo.absent){selectedCardIndex=0;}else{selectedCardIndex=firstCardInfo.absent?1:0;}
-this._elements.outgoingCallSelect.disabled=true;this._elements.outgoingMessagesSelect.disabled=true;this._elements.outgoingDataSelect.disabled=true;this._updateSelectOptionUI('outgoingCall',selectedCardIndex,this._elements.outgoingCallSelect);this._updateSelectOptionUI('outgoingMessages',selectedCardIndex,this._elements.outgoingMessagesSelect);this._updateSelectOptionUI('outgoingData',selectedCardIndex,this._elements.outgoingDataSelect);}},_updateSelectOptionUI:function scm__updateSelectOptionUI(storageKey,selectedCardIndex,selectDOM){while(selectDOM.firstChild){selectDOM.removeChild(selectDOM.firstChild);}
-this._simcards.forEach(function(simcard,index){var simcardInfo=simcard.getInfo();var option=document.createElement('option');option.value=index;if(simcardInfo.absent){option.value=SimSettingsHelper.EMPTY_OPTION_VALUE;option.text=SimSettingsHelper.EMPTY_OPTION_TEXT;}else{if(simcardInfo.name.id){l10n.setAttributes(option,simcardInfo.name.id,simcardInfo.name.args);}else{option.text=simcardInfo.name.text;}}
-if(index==selectedCardIndex){option.selected=true;}
-selectDOM.add(option);});if(storageKey==='outgoingCall'||storageKey==='outgoingMessages'){var option=document.createElement('option');option.value=SimSettingsHelper.ALWAYS_ASK_OPTION_VALUE;option.setAttribute('data-l10n-id','sim-manager-always-ask');if(SimSettingsHelper.ALWAYS_ASK_OPTION_VALUE===selectedCardIndex){option.selected=true;}
-selectDOM.add(option);}},_isSimCardLocked:function scm__isSimCardLocked(cardState){var lockedState=['pinRequired','pukRequired','networkLocked','serviceProviderLocked','corporateLocked','network1Locked','network2Locked','hrpdNetworkLocked','ruimCorporateLocked','ruimServiceProviderLocked'];return lockedState.indexOf(cardState)!==-1;},_isSimCardBlocked:function scm__isSimCardBlocked(cardState){var uselessState=['permanentBlocked'];return uselessState.indexOf(cardState)!==-1;},_addVoiceChangeEventOnConns:function scm__addVoiceChangeEventOnConns(){var conns=window.navigator.mozMobileConnections;for(var i=0;i{this.conns=window.navigator.mozMobileConnections;this.iccManager=window.navigator.mozIccManager;this.isAirplaneMode=(AirplaneModeHelper.getStatus()==='enabled');this._elements.simPinContainer.addEventListener('click',this);this._elements.simPinContainer.addEventListener('change',this.simPinSwitched.bind(this));this.addIccDetectedEvent();this.addIccUndetectedEvent();this.addAirplaneModeChangeEvent();this.initSimPinBack();this.initSimPinsUI();this.updateSimPinsUI();this.addChangeEventOnIccs();});},simPinView:function({simIndex,simPinArgs}){return Sanitizer.escapeHTML`
-
-
-
-
-
-
-
- `;},initSimPinBack:function simpin_initSimPinBack(){this._elements.simPinHeader.dataset.href=SIMSlotManager.isMultiSIM()?'#sim-manager':'#root';},initSimPinsUI:function simpin_initSimPinsUI(){var simPinHTMLs=[];[].forEach.call(this.conns,(conn,index)=>{var simPinIndex=index+1;if(!SIMSlotManager.isMultiSIM()){simPinIndex='';}
-simPinHTMLs.push(this.simPinView({'simIndex':index.toString(),'simPinArgs':JSON.stringify({'index':simPinIndex})}));});this._elements.simPinContainer.innerHTML=simPinHTMLs.join('');},updateSimPinUI:function simpin_updateSimPinUI(cardIndex){var iccId=this.conns[cardIndex].iccId;var icc=this.iccManager.getIccById(iccId);var changeSimPinItem=this._elements.simPinContainer.querySelector('.simpin-change-'+cardIndex);var simPinCheckbox=this._elements.simPinContainer.querySelector('.simpin-enabled-'+cardIndex+' gaia-switch');var isSimAvailable=icc&&icc.cardState&&icc.cardState!=='unknown';if(!isSimAvailable||this.isAirplaneMode){simPinCheckbox.disabled=true;changeSimPinItem.hidden=true;return Promise.resolve();}
-return SimSecurity.getCardLock(cardIndex,'pin').then((result)=>{var enabled=result.enabled;simPinCheckbox.disabled=false;simPinCheckbox.checked=enabled;changeSimPinItem.hidden=!enabled;},()=>{console.log('onerror');console.log('cardIndex',cardIndex);});},updateSimPinsUI:function simpin_updateSimPinsUI(){[].forEach.call(this.conns,(simcard,cardIndex)=>{this.updateSimPinUI(cardIndex);});},handleEvent:function simpin_handleEvent(evt){var target=evt.target;var cardIndex=target.dataset&&target.dataset.simIndex;var type=target.dataset&&target.dataset.type;cardIndex=parseInt(cardIndex,10);switch(type){case'changeSimPin':this.changeSimPin(cardIndex);break;}},simPinSwitched:function simpin_simPinSwitched(evt){var target=evt.target;var cardIndex=target.dataset&&target.dataset.simIndex;cardIndex=parseInt(cardIndex,10);this.checkSimPin(target,cardIndex);},checkSimPin:function simpin_checkSimPin(checkbox,cardIndex){var enabled=checkbox.checked;var iccId=this.conns[cardIndex].iccId;var icc=this.iccManager.getIccById(iccId);switch(icc.cardState){case'pukRequired':return DialogService.show('simpin-dialog',{method:'unlock_puk',cardIndex:cardIndex}).then((result)=>{var type=result.type;if(type==='submit'){checkbox.checked=true;this.updateSimPinUI(cardIndex);}else{checkbox.checked=!enabled;this.updateSimPinUI(cardIndex);}});default:var action=enabled?'enable_lock':'disable_lock';return DialogService.show('simpin-dialog',{method:action,cardIndex:cardIndex}).then((result)=>{var type=result.type;if(type==='submit'){this.updateSimPinUI(cardIndex);}else{checkbox.checked=!enabled;this.updateSimPinUI(cardIndex);}});}},changeSimPin:function(cardIndex){return DialogService.show('simpin-dialog',{method:'change_pin',cardIndex:cardIndex}).then(function(result){var type=result.type;if(type==='submit'){var toast;if(SIMSlotManager.isMultiSIM()){toast={messageL10nId:'simPinChangedSuccessfullyWithIndex',messageL10nArgs:{'index':+(cardIndex)+1},latency:3000,useTransition:true};}else{toast={messageL10nId:'simPinChangedSuccessfully',latency:3000,useTransition:true};}
-Toaster.showToast(toast);}});},addIccDetectedEvent:function simpin_addIccDetectedEvent(){this.iccManager.addEventListener('iccdetected',(evt)=>{var iccId=evt.iccId;var icc=this.iccManager.getIccById(iccId);if(icc){var cardIndex=this.getCardIndexByIccId(iccId);this.updateSimPinUI(cardIndex);this.addChangeEventOnIccByIccId(iccId);}});},addIccUndetectedEvent:function simpin_addIccDetectedEvent(){this.iccManager.addEventListener('iccundetected',(evt)=>{this.updateSimPinsUI();});},addAirplaneModeChangeEvent:function simpin_addAirplaneModeChangeEvent(){AirplaneModeHelper.addEventListener('statechange',(status)=>{this.isAirplaneMode=(status==='enabled');this.updateSimPinsUI();});},addChangeEventOnIccs:function simpin_addChangeEventOnIccs(){for(var i=0;i{var cardIndex=this.getCardIndexByIccId(iccId);this.updateSimPinUI(cardIndex);});}},getCardIndexByIccId:function simpin_getCardIndexByIccId(iccId){var cardIndex;for(var i=0;i [UsbTransfer]: '+msg);};}
-var UsbTransfer=function(){this._keyUmsEnabled='ums.enabled';this._keyUmsMode='ums.mode';this._keyTransferProtocol='usb.transfer';this._elements=null;this._usbHotProtocolSwitch=false;this._partialUmsSupport=!navigator.getDeviceStorages('sdcard').every(storage=>storage.canBeShared);this.PROTOCOL_UMS='0';this.PROTOCOL_MTP='1';this.MODE_UMS=1;this.MODE_MTP=3;};UsbTransfer.prototype={init:function ut_init(elements,option){this._elements=elements;if(option&&option.usbHotProtocolSwitch!==undefined){this._usbHotProtocolSwitch=option.usbHotProtocolSwitch;}
-SettingsListener.observe(this._keyTransferProtocol,this.PROTOCOL_UMS,this._configProtocol.bind(this));SettingsListener.observe(this._keyUmsEnabled,false,this._umsEnabledHandler.bind(this));this._elements.usbEnabledCheckBox.addEventListener('change',this._umsCheckboxChange.bind(this));MediaStorage.observe('volumeState',this._updateUmsState.bind(this));},_umsCheckboxChange:function storage_umsCheckboxChange(evt){var checkbox=evt.target;var cset={};var warningKey='ums-turn-on-warning';debug('state change '+checkbox.checked);if(checkbox.checked){AsyncStorage.getItem(warningKey,(showed)=>{if(!showed){debug('show turn-on warning');this._umsTurnOnWarning(checkbox,warningKey);}else{debug('turn-on warning is showed');SettingsCache.getSettings(this._openIncompatibleSettingsDialogIfNeeded.bind(this));}});}else{cset[this._keyUmsEnabled]=false;navigator.mozSettings.createLock().set(cset);}},_umsTurnOnWarning:function storage_umsTurnOnWarning(checkbox,warningKey){DialogService.confirm('ums-confirm',{title:'ums-warning-title',submitButton:'ok',cancelButton:'cancel'}).then((result)=>{var type=result.type;if(type==='submit'){debug('turn on success');AsyncStorage.setItem(warningKey,true);SettingsCache.getSettings(this._openIncompatibleSettingsDialogIfNeeded.bind(this));}else{debug('turn on fail');var cset={};cset[this._keyUmsEnabled]=false;navigator.mozSettings.createLock().set(cset);checkbox.checked=false;}});},_openIncompatibleSettingsDialogIfNeeded:function storage_openIncompatibleSettingsDialogIfNeeded(settings){var cset={};var umsSettingKey=this._keyUmsEnabled;var oldSetting='tethering.usb.enabled';var usbTetheringSetting=settings[oldSetting];if(!usbTetheringSetting){cset[umsSettingKey]=true;navigator.mozSettings.createLock().set(cset);}else{DialogService.confirm('is-warning-storage-tethering-message',{title:'is-warning-storage-header',submitButton:{id:'enable',style:'recommend'},cancelButton:{id:'cancel'}}).then((result)=>{var enabled=result.type==='submit';var lock=navigator.mozSettings.createLock();var cset={};cset[umsSettingKey]=enabled;cset[oldSetting]=!enabled;lock.set(cset);});}},_umsEnabledHandler:function ut_umsEnabledHandler(enabled){debug('ums.enabled: '+enabled);this._elements.usbEnabledCheckBox.checked=enabled;this._updateUmsState();},_configProtocol:function ut_configProtocol(protocol){SettingsCache.getSettings((results)=>{var enabled=results[this._keyUmsEnabled];var mode=results[this._keyUmsMode];if(enabled){this._changeMode(mode,protocol);}});},_changeMode:function ut_changeMode(mode,protocol){if(mode!==this.MODE_MTP&&protocol===this.PROTOCOL_MTP){this._setMode(this.MODE_MTP);}else if(mode===this.MODE_MTP&&protocol===this.PROTOCOL_UMS){if(this._partialUmsSupport){debug('show partial warning');DialogService.show('ums-partial-warning').then((result)=>{if(result.type==='cancel'){var param={};param[this._keyTransferProtocol]=this.PROTOCOL_MTP;SettingsListener.getSettingsLock().set(param);}else{this._setMode(this.MODE_UMS);}});}else{this._setMode(this.MODE_UMS);}}else{console.log('Error: should not be executed');}},_updateUmsState:function ut_updateUmsState(){var key;debug('enabled:'+this._elements.usbEnabledCheckBox.checked+'/'+'volumeState:'+MediaStorage.volumeState);if(this._elements.usbEnabledCheckBox.checked){key='enabled';if(MediaStorage.volumeState==='shared'){this._disableProtocolSelections(true);}else{this._disableProtocolSelections(false);}}else if(MediaStorage.volumeState==='shared'){key='umsUnplugToDisable';this._disableProtocolSelections(true);}else{key='disabled';this._disableProtocolSelections(false);}
-this._elements.usbEnabledInfoBlock.setAttribute('data-l10n-id',key);},_disableProtocolSelections:function ut_disableProtocolSelections(disabled){var i;if(disabled){if(!this._usbHotProtocolSwitch){for(i=0;iusbTransfer.init(elements,{usbHotProtocolSwitch:usbHotProtocolSwitch}));}});};});
\ No newline at end of file
diff --git a/overrides/settings.gaiamobile.org/js/panels/wifi/panel.js b/overrides/settings.gaiamobile.org/js/panels/wifi/panel.js
index 1ed81789..b9922f32 100644
--- a/overrides/settings.gaiamobile.org/js/panels/wifi/panel.js
+++ b/overrides/settings.gaiamobile.org/js/panels/wifi/panel.js
@@ -259,6 +259,7 @@ req.onsuccess=function(){if(method==='myPin'){document.l10n.formatValue('wpsPinI
return WifiNetworkList;
});
+
define('panels/wifi/panel',['require','modules/dialog_service','modules/settings_panel','shared/settings_listener','panels/wifi/wifi_wps','modules/wifi_context','shared/wifi_helper'],function(require){'use strict';var DialogService=require('modules/dialog_service');var SettingsPanel=require('modules/settings_panel');var SettingsListener=require('shared/settings_listener');var WifiWps=require('panels/wifi/wifi_wps');var WifiContext=require('modules/wifi_context');var WifiHelper=require('shared/wifi_helper');var wifiManager=WifiHelper.getWifiManager();return function ctor_wifi(){var elements;return SettingsPanel({onInit:function(panel){this._settings=navigator.mozSettings;this._wifiSectionVisible=true;this._scanPending=false;this._networkListPromise=null;this._initialized=false;elements={panel:panel,wifi:panel,wpsColumn:panel.querySelector('.wps-column'),wpsInfoBlock:panel.querySelector('.wps-column small'),wpsPbcLabelBlock:panel.querySelector('.wps-column span'),wifiCheckbox:panel.querySelector('.wifi-enabled gaia-switch'),wifiAvailableNetworks:panel.querySelector('.wifi-availableNetworks'),dialogElement:panel.querySelector('.wifi-bad-credentials-dialog'),okBtn:panel.querySelector('.wifi-bad-credentials-confirm'),cancelBtn:panel.querySelector('.wifi-bad-credentials-cancel')};elements.infoItem=elements.wifiAvailableNetworks.querySelector('li[data-state="on"]');elements.scanItem=elements.wifiAvailableNetworks.querySelector('li[data-state="ready"]');elements.wifiItem=elements.wifiAvailableNetworks.querySelector('li:not([data-state])');elements.networklist={infoItem:elements.infoItem,scanItem:elements.scanItem,wifiAvailableNetworks:elements.wifiAvailableNetworks};elements.wps={wpsColumn:elements.wpsColumn,wpsInfoBlock:elements.wpsInfoBlock,wpsPbcLabelBlock:elements.wpsPbcLabelBlock};this._wps=WifiWps();this._wps.addEventListener('statusreset',function(){elements.wps.wpsPbcLabelBlock.setAttribute('data-l10n-id','wpsMessage');setTimeout(function resetWpsInfoBlock(){elements.wps.wpsPbcLabelBlock.setAttribute('data-l10n-id','wpsDescription2');},1500);});elements.scanItem.addEventListener('click',this._onScanItemClick.bind(this));elements.wifiCheckbox.addEventListener('change',this._onWifiCheckboxChange.bind(this));elements.wpsColumn.addEventListener('click',this._onWpsColumnClick.bind(this));WifiContext.addEventListener('wifiEnabled',function(){elements.wifiCheckbox.removeAttribute('disabled');this._updateNetworkState();this._networkList().then((networkList)=>{networkList.scan();});}.bind(this));WifiContext.addEventListener('wifiDisabled',function(){elements.wifiCheckbox.removeAttribute('disabled');this._networkList().then((networkList)=>{networkList.clear(false);networkList.autoscan=false;});}.bind(this));WifiContext.addEventListener('wifiStatusChange',function(event){var scanStates=new Set(['connected','connectingfailed','disconnected']);this._updateNetworkState();if(scanStates.has(event.status)){if(this._wifiSectionVisible){this._networkList().then((networkList)=>{networkList.scan();});}else{this._scanPending=true;}}}.bind(this));WifiContext.addEventListener('wifiWrongPassword',function(event){var currentNetwork=WifiContext.currentNetwork;if(currentNetwork.known===false){this._openBadCredentialsDialog(currentNetwork);}}.bind(this));window.performance.mark('wifiListStart');},onBeforeShow:function(){this._wifiSectionVisible=true;this._updateVisibilityStatus();},onShow:function(){if(!this._initialized){this._initialized=true;SettingsListener.observe('wifi.enabled',true,function(enabled){this._setMozSettingsEnabled(enabled);if(enabled){this._updateNetworkState();this._networkList().then((networkList)=>{networkList.scan();});}}.bind(this));}},onBeforeHide:function(){this._wifiSectionVisible=false;},_onWpsColumnClick:function(){var self=this;if(this._wps.inProgress){this._wps.cancel({onSuccess:function(){elements.wpsInfoBlock.setAttribute('data-l10n-id','fullStatus-wps-canceled');},onError:function(error){document.l10n.setAttributes(elements.wpsInfoBlock,'wpsCancelFailedMessageError',{error:error.name});}});}else{DialogService.show('wifi-wps',{wpsAvailableNetworks:function(){return self._networkList().then((networkList)=>{return networkList.getWpsAvailableNetworks();});}}).then(function(result){var type=result.type;var value=result.value;if(type==='submit'){self._wps.connect({pin:value.pin,selectedAp:value.selectedAp,selectedMethod:value.selectedMethod,onSuccess:function(){elements.wps.wpsPbcLabelBlock.setAttribute('data-l10n-id','wpsCancelMessage');elements.wps.wpsInfoBlock.setAttribute('data-l10n-id','fullStatus-wps-inprogress');},onError:function(error){document.l10n.setAttributes(elements.wpsInfoBlock,'fullStatus-wps-failed-error',{error:error.name});}});}});}},_onWifiCheckboxChange:function(e){var checkbox=elements.wifiCheckbox;this._settings.createLock().set({'wifi.enabled':checkbox.checked}).onerror=function(){checkbox.removeAttribute('disabled');};checkbox.setAttribute('disabled',true);},_onScanItemClick:function(){this._networkList().then((networkList)=>{networkList.clear(true);networkList.scan();});},_updateVisibilityStatus:function(){this._networkList().then((networkList)=>{if(this._scanPending){networkList.scan();this._scanPending=false;}});},_setMozSettingsEnabled:function(enabled){this._networkList().then((networkList)=>{elements.wifiCheckbox.checked=enabled;if(enabled){networkList.clear(true);elements.wpsColumn.hidden=false;}else{if(this._wps.inProgress){elements.wpsInfoBlock.setAttribute('data-l10n-id',WifiContext.wifiStatusText.id);if(WifiContext.wifiStatusText.args){elements.wpsInfoBlock.setAttribute('data-l10n-args',JSON.stringify(WifiContext.wifiStatusText.args));}else{elements.wpsInfoBlock.removeAttribute('data-l10n-args');}}
networkList.clear(false);networkList.autoscan=false;elements.wpsColumn.hidden=true;}});},_updateNetworkState:function(){var networkStatus=wifiManager.connection.status;if(this._wps.inProgress){if(networkStatus!=='disconnected'){elements.wpsInfoBlock.setAttribute('data-l10n-id',WifiContext.wifiStatusText.id);if(WifiContext.wifiStatusText.args){elements.wpsInfoBlock.setAttribute('data-l10n-args',JSON.stringify(WifiContext.wifiStatusText.args));}else{elements.wpsInfoBlock.removeAttribute('data-l10n-args');}}
if(networkStatus==='connected'||networkStatus==='wps-timedout'||networkStatus==='wps-failed'||networkStatus==='wps-overlapped'){this._wps.inProgress=false;this._wps.statusReset();}}},_openBadCredentialsDialog:function(network){DialogService.confirm({id:'wifi-bad-credentials-confirm',args:{ssid:network.ssid}},{title:'wifi-bad-credentials-title',submitButton:{id:'ok',style:'recommend'},cancelButton:'cancel',}).then((result)=>{if(result.type==='submit'){this._networkList().then((networkList)=>{networkList._toggleNetwork(network);});}});},_networkList:function(){if(!this._networkListPromise){this._networkListPromise=new Promise((resolve)=>{require(['panels/wifi/wifi_network_list'],(WifiNetworkList)=>{resolve(WifiNetworkList(elements.networklist));});});}
diff --git a/overrides/settings.gaiamobile.org/js/panels/wifi_select_certificate_file/panel.js b/overrides/settings.gaiamobile.org/js/panels/wifi_select_certificate_file/panel.js
deleted file mode 100644
index 323143a5..00000000
--- a/overrides/settings.gaiamobile.org/js/panels/wifi_select_certificate_file/panel.js
+++ /dev/null
@@ -1,485 +0,0 @@
-
-define('modules/dialog_manager',["require", "modules/panel_cache", "shared/lazy_loader"],function(require) {
- 'use strict';
-
- var PanelCache = require('modules/panel_cache');
- var LazyLoader = require('shared/lazy_loader');
-
- var DialogManager = function() {
- this.OVERLAY_SELECTOR = '.settings-dialog-overlay';
-
- this._overlayDOM = document.querySelector(this.OVERLAY_SELECTOR);
- };
-
- DialogManager.prototype = {
- /**
- * load panel based on passed in panelId
- *
- * @memberOf DialogManager
- * @access private
- * @param {String} panelId
- * @return {Promise}
- */
- _loadPanel: function dm__loadPanel(panelId) {
- var promise = new Promise(function(resolve, reject) {
- var panelElement = document.getElementById(panelId);
- if (panelElement.dataset.rendered) { // already initialized
- resolve();
- return;
- }
-
- panelElement.dataset.rendered = true;
-
- // XXX remove SubPanel loader once sub panel are modulized
- if (panelElement.dataset.requireSubPanels) {
- // load the panel and its sub-panels (dependencies)
- // (load the main panel last because it contains the scripts)
- var selector = 'section[id^="' + panelElement.id + '-"]';
- var subPanels = document.querySelectorAll(selector);
- for (var i = 0, il = subPanels.length; i < il; i++) {
- LazyLoader.load([subPanels[i]]);
- }
- LazyLoader.load([panelElement], resolve);
- } else {
- LazyLoader.load([panelElement], resolve);
- }
- });
- return promise;
- },
-
- /**
- * promised version of PanelCache.get()
- *
- * @memberOf DialogManager
- * @access private
- * @param {String} panelId
- * @return {Promise}
- */
- _getPanel: function dm__getPanel(panelId) {
- var promise = new Promise(function(resolve) {
- PanelCache.get(panelId, function(panel) {
- resolve(panel);
- });
- });
- return promise;
- },
-
- /**
- * this is used to control visibility of overlayDOM
- *
- * @memberOf DialogManager
- * @access private
- * @param {Boolean} show
- */
- _showOverlay: function dm__showOverlay(show) {
- this._overlayDOM.hidden = !show;
- },
-
- /**
- * It is used to control the timing of transitions so that we can make sure
- * whether animation is done or not.
- *
- * @memberOf DialogManager
- * @access private
- * @param {String} method
- * @param {BaseDialog} dialog
- * @param {Object} options
- * @return {Promise}
- */
- _transit: function dm__transit(method, dialog, options) {
- var promise = new Promise(function(resolve) {
- var panel = dialog.panel;
- var completed = false;
- var fallback;
- function finish(event) {
- if (event && (event.target !== panel || event.propertyName !== 'visibility')) return;
- if (completed) return;
- completed = true;
- clearTimeout(fallback);
- panel.removeEventListener('transitionend', finish);
- panel.removeEventListener('transitioncancel', finish);
- if (method === 'close') panel.hidden = true;
- resolve();
- }
- if (method === 'open') panel.hidden = false;
- setTimeout(function() {
- panel.addEventListener('transitionend', finish);
- panel.addEventListener('transitioncancel', finish);
- panel.classList.toggle('current', method === 'open');
- var style = window.getComputedStyle(panel);
- function milliseconds(value) {
- return parseFloat(value) * (value.trim().endsWith('ms') ? 1 : 1000) || 0;
- }
- var durations = style.transitionDuration.split(',').map(milliseconds);
- var delays = style.transitionDelay.split(',').map(milliseconds);
- var duration = Math.max.apply(Math, durations.map(function(value, index) {
- return value + delays[index % delays.length];
- }));
- // Reduced motion, display changes or interrupted transitions may
- // omit the visibility event. Always release the dialog queue.
- fallback = setTimeout(finish, Math.max(0, duration) + 100);
- }, 150);
- });
- return promise;
- },
-
- /**
- * Do necessary works to open panel like loading panel, doing transition
- * and call related functions.
- *
- * @memberOf DialogManager
- * @access private
- * @param {BaseDialog} dialog
- * @param {Object} options
- * @return {Promise}
- */
- _open: function dm__open(dialog, options) {
- var self = this;
- var foundPanel;
-
- return Promise.resolve()
- .then(function() {
- // 1: load panel
- return self._loadPanel(dialog.panel.id);
- })
- .then(function() {
- // 2: Get that panel
- return self._getPanel(dialog.panel.id);
- })
- .then(function(panel) {
- // 3: call beforeShow
- foundPanel = panel;
- return foundPanel.beforeShow(dialog.panel, options);
- })
- .then(function() {
- // 4. UI stuffs + transition
- dialog.init();
- dialog.initUI();
- dialog.bindEvents();
-
- if (dialog.TRANSITION_CLASS === 'zoom-in-80') {
- self._showOverlay(true);
- }
-
- return self._transit('open', dialog, options);
- })
- .then(function() {
- // 5. show that panel as a dialog
- return foundPanel.show(dialog.panel, options);
- });
- },
-
- /**
- * Do necessary works to close panel like loading panel, doing transition
- * and call related functions.
- *
- * @memberOf DialogManager
- * @access private
- * @param {BaseDialog} dialog
- * @param {Object} options
- * @return {Promise}
- */
- _close: function dm__close(dialog, options) {
- var self = this;
- var foundPanel;
- var cachedResult;
-
- return Promise.resolve()
- .then(function() {
- // 1: Get that panel
- return self._getPanel(dialog.panel.id);
- })
- .then(function(panel) {
- // 2: Let's validate to see whether we can close this dialog or not.
- foundPanel = panel;
-
- var promise;
- // custom dialog - onSubmit
- if (foundPanel.onSubmit && options._type === 'submit') {
- promise = foundPanel.onSubmit();
- // custom dialog - onCancel
- } else if (foundPanel.onCancel && options._type === 'cancel') {
- promise = foundPanel.onCancel();
- // if no onSubmit & onCancel, pass directly
- } else {
- promise = Promise.resolve();
- }
-
- return promise;
- })
- .then(function(result) {
- cachedResult = result;
-
- // 3: call beforeHide
- return foundPanel.beforeHide();
- })
- .then(function() {
- // 4. transition
- return self._transit('close', dialog, options);
- })
- .then(function() {
- // 5. call hide
- return foundPanel.hide();
- })
- .then(function() {
- // 6. Get result and cleanup dialog
- var result;
-
- // for prompt dialog, we have to get its own result from input text.
- if (dialog.DIALOG_CLASS === 'prompt-dialog') {
- result = dialog.getResult();
- } else if (cachedResult) {
- result = cachedResult;
- }
-
- dialog.cleanup();
-
- if (dialog.TRANSITION_CLASS === 'zoom-in-80') {
- self._showOverlay(false);
- }
-
- return result;
- });
- },
-
- /**
- * It is a bridge to call open or close function.
- *
- * @memberOf DialogManager
- * @access private
- * @param {String} method
- * @param {BaseDialog} dialog
- * @param {Object} options
- * @return {Promise}
- */
- _navigate: function dm__navigate(method, dialog, options) {
- method = (method === 'open') ? '_open' : '_close';
- return this[method](dialog, options);
- },
-
- /**
- * DialogService would use this exposed API to open dialog.
- *
- * @memberOf DialogManager
- * @access public
- * @param {BaseDialog} dialog
- * @param {Object} options
- * @return {Promise}
- */
- open: function dm_open(dialog, options) {
- return this._navigate('open', dialog, options);
- },
-
- /**
- * DialogService would use this exposed API to close dialog.
- *
- * @memberOf DialogManager
- * @access public
- * @param {BaseDialog} dialog
- * @param {Object} options
- * @return {Promise}
- */
- close: function dm_close(dialog, type, options) {
- options._type = type;
- return this._navigate('close', dialog, options);
- }
- };
-
- var dialogManager = new DialogManager();
- return dialogManager;
-});
-define('modules/dialog/base_dialog',['require'],function(require){'use strict';var BaseDialog=function(panelDOM,options){this.panel=panelDOM;this._options=options||{};};BaseDialog.prototype.DIALOG_CLASS='dialog';BaseDialog.prototype.TRANSITION_CLASS='fade';BaseDialog.prototype.SUBMIT_BUTTON_SELECTOR='[type="submit"]';BaseDialog.prototype.CANCEL_BUTTON_SELECTOR='[type="reset"]';BaseDialog.prototype.MESSAGE_SELECTOR='.settings-dialog-message';BaseDialog.prototype.TITLE_SELECTOR='.settings-dialog-title';BaseDialog.prototype.init=function bd_init(){this.TRANSITION_CLASS=this._options.transition||this.TRANSITION_CLASS;this.panel.classList.add(this.DIALOG_CLASS);this.panel.classList.add(this.TRANSITION_CLASS);};BaseDialog.prototype.initUI=function bd_initUI(){var message=this._options.message;var title=this._options.title;var submitButton=this._options.submitButton;var cancelButton=this._options.cancelButton;this._updateMessage(message);this._updateTitle(title);this._updateSubmitButton(submitButton);this._updateCancelButton(cancelButton);};BaseDialog.prototype.bindEvents=function bd_bindEvent(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};BaseDialog.prototype._updateMessage=function bd__updateMessage(message){var messageDOM=this.panel.querySelector(this.MESSAGE_SELECTOR);if(messageDOM&&message){message=this._getWrapL10nObject(message);document.l10n.setAttributes(messageDOM,message.id,message.args);}};BaseDialog.prototype._updateTitle=function bd__updateTitle(title){var titleDOM=this.panel.querySelector(this.TITLE_SELECTOR);if(titleDOM&&title){title=this._getWrapL10nObject(title);document.l10n.setAttributes(titleDOM,title.id,title.args);}};BaseDialog.prototype._updateSubmitButton=function bd__update(options){var buttonDOM=this.getSubmitButton();if(buttonDOM&&options){options=this._getWrapL10nObject(options);document.l10n.setAttributes(buttonDOM,options.id,options.args);buttonDOM.className=options.style||'recommend';}};BaseDialog.prototype._updateCancelButton=function bd__updateText(options){var buttonDOM=this.getCancelButton();if(buttonDOM&&options){options=this._getWrapL10nObject(options);document.l10n.setAttributes(buttonDOM,options.id,options.args);buttonDOM.className=options.style||'';}};BaseDialog.prototype._getWrapL10nObject=function bd__getWrapL10nObject(input){if(typeof input==='string'){return{id:input,args:null};}else if(typeof input==='object'){if(typeof input.id==='undefined'){throw new Error('You forgot to put l10nId - '+
-JSON.stringify(input));}else{return{id:input.id,args:input.args||null,style:input.style};}}else{throw new Error('You are using the wrong L10nObject, '+'please check its format again');}};BaseDialog.prototype.getDOM=function bd_getDOM(){return this.panel;};BaseDialog.prototype.getSubmitButton=function bd_getSubmitButton(){return this.panel.querySelector(this.SUBMIT_BUTTON_SELECTOR);};BaseDialog.prototype.getCancelButton=function bd_getCancelButton(){return this.panel.querySelector(this.CANCEL_BUTTON_SELECTOR);};BaseDialog.prototype.cleanup=function bd_cleanup(){if(this.DIALOG_CLASS!=='panel-dialog'){this._updateTitle('settings-'+this.DIALOG_CLASS+'-header');this._updateSubmitButton('ok');this._updateCancelButton('cancel');}
-this.panel.classList.remove(this.DIALOG_CLASS);this.panel.classList.remove(this.TRANSITION_CLASS);};return BaseDialog;});define('modules/dialog/panel_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var PanelDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};PanelDialog.prototype=Object.create(BaseDialog.prototype);PanelDialog.prototype.constructor=PanelDialog;PanelDialog.prototype.DIALOG_CLASS='panel-dialog';PanelDialog.prototype.TRANSITION_CLASS='fade';return function ctor_PanelDialog(panelDOM,options){return new PanelDialog(panelDOM,options);};});define('modules/dialog/alert_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var AlertDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};AlertDialog.prototype=Object.create(BaseDialog.prototype);AlertDialog.prototype.constructor=AlertDialog;AlertDialog.prototype.DIALOG_CLASS='alert-dialog';AlertDialog.prototype.TRANSITION_CLASS='fade';AlertDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};};return function ctor_alertDialog(panelDOM,options){return new AlertDialog(panelDOM,options);};});define('modules/dialog/confirm_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var ConfirmDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};ConfirmDialog.prototype=Object.create(BaseDialog.prototype);ConfirmDialog.prototype.constructor=ConfirmDialog;ConfirmDialog.prototype.DIALOG_CLASS='confirm-dialog';ConfirmDialog.prototype.TRANSITION_CLASS='fade';ConfirmDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};return function ctor_confirmDialog(panelDOM,options){return new ConfirmDialog(panelDOM,options);};});define('modules/dialog/prompt_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var PromptDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};PromptDialog.prototype=Object.create(BaseDialog.prototype);PromptDialog.prototype.constructor=PromptDialog;PromptDialog.prototype.DIALOG_CLASS='prompt-dialog';PromptDialog.prototype.TRANSITION_CLASS='fade';PromptDialog.prototype.INPUT_SELECTOR='.settings-dialog-input';PromptDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};PromptDialog.prototype.initUI=function(){BaseDialog.prototype.initUI.call(this);this.getInput().value=this._options.defaultValue||'';};PromptDialog.prototype.getInput=function(){return this.panel.querySelector(this.INPUT_SELECTOR);};PromptDialog.prototype.getResult=function(){return this.getInput().value;};return function ctor_promptDialog(panelDOM,options){return new PromptDialog(panelDOM,options);};});define('modules/dialog_service',["require", "settings", "modules/defer", "modules/dialog_manager", "modules/dialog/panel_dialog", "modules/dialog/alert_dialog", "modules/dialog/confirm_dialog", "modules/dialog/prompt_dialog"],function(require) {
- 'use strict';
-
- var Settings = require('settings');
- var Defer = require('modules/defer');
- var DialogManager = require('modules/dialog_manager');
-
- var PanelDialog = require('modules/dialog/panel_dialog');
- var AlertDialog = require('modules/dialog/alert_dialog');
- var ConfirmDialog = require('modules/dialog/confirm_dialog');
- var PromptDialog = require('modules/dialog/prompt_dialog');
-
- var DialogService = function() {
- this._navigating = false;
- this._pendingRequests = [];
- this._settingsAlertDialogId = 'settings-alert-dialog';
- this._settingsBaseDialogId = 'settings-base-dialog';
- this._settingsConfirmDialogId = 'settings-confirm-dialog';
- this._settingsPromptDialogId = 'settings-prompt-dialog';
- };
-
- DialogService.prototype = {
- /**
- * Alert dialog with more controls.
- *
- * @memberOf DialogService
- * @access public
- * @param {String} message
- * @param {Object} userOptions
- * @return {Promise}
- */
- alert: function(message, userOptions) {
- var options = userOptions || {};
- return this.show(this._settingsAlertDialogId, {
- type: 'alert',
- message: message,
- title: options.title,
- submitButton: options.submitButton
- });
- },
-
- /**
- * Confirm dialog with more controls.
- *
- * @memberOf DialogService
- * @access public
- * @param {String} message
- * @param {Object} userOptions
- * @return {Promise}
- */
- confirm: function(message, userOptions) {
- var options = userOptions || {};
- return this.show(this._settingsConfirmDialogId, {
- type: 'confirm',
- message: message,
- title: options.title,
- submitButton: options.submitButton,
- cancelButton: options.cancelButton
- });
- },
-
- /**
- * Prompt dialog with more controls.
- *
- * @memberOf DialogService
- * @access public
- * @param {String} message
- * @param {Object} userOptions
- * @return {Promise}
- */
- prompt: function(message, userOptions) {
- var options = userOptions || {};
- return this.show(this._settingsPromptDialogId, {
- type: 'prompt',
- message: message,
- title: options.title,
- defaultValue: options.defaultValue,
- submitButton: options.submitButton,
- cancelButton: options.cancelButton
- });
- },
-
- /**
- * Panel dialog. If you are going to show any panel as a dialog,
- * you have to use this method to show them.
- *
- * @memberOf DialogService
- * @access public
- * @param {String} panelId
- * @param {Object} userOptions
- * @return {Promise}
- */
- show: function dm_show(panelId, userOptions, _pendingDefer) {
- var self = this;
- var defer;
- var dialog;
- var dialogDOM = document.getElementById(panelId);
- var currentPanel = Settings.currentPanel;
- var options = userOptions || {};
-
- if (_pendingDefer) {
- defer = _pendingDefer;
- } else {
- defer = Defer();
- }
-
- if (this._navigating) {
- this._pendingRequests.push({
- defer: defer,
- panelId: panelId,
- userOptions: userOptions
- });
- } else {
- if ('#' + panelId === currentPanel) {
- defer.reject('You are showing the same panel #' + panelId);
- } else {
- var opening;
- var closing = false;
- function close(type) {
- if (closing) return;
- closing = true;
- // A tap during the opening animation must not run hide/show
- // concurrently, or a queued dialog can inherit stale form values.
- Promise.resolve(opening).then(function() {
- return DialogManager.close(dialog, type, options);
- }).then(function(result) {
- self._navigating = false;
- defer.resolve({type: type, value: result});
- self._execPendingRequest();
- }, function(error) {
- // Panel validation rejects to keep the form open. Allow a
- // correction or cancellation without rejecting the show promise.
- closing = false;
- dialogDOM.dispatchEvent(new CustomEvent('dialogvalidationfailed', {
- detail: error
- }));
- });
- }
- options.onWrapSubmit = function() { close('submit'); };
- options.onWrapCancel = function() { close('cancel'); };
-
- switch (options.type) {
- case 'alert':
- dialog = AlertDialog(dialogDOM, options);
- break;
- case 'confirm':
- dialog = ConfirmDialog(dialogDOM, options);
- break;
- case 'prompt':
- dialog = PromptDialog(dialogDOM, options);
- break;
- default:
- dialog = PanelDialog(dialogDOM, options);
- break;
- }
- this._navigating = true;
- opening = DialogManager.open(dialog, options);
- opening.catch(function(error) {
- // A missing host API must not lock every later Settings dialog.
- dialogDOM.classList.remove('current');
- dialogDOM.hidden = true;
- var overlay = document.querySelector('.settings-dialog-overlay');
- if (overlay) overlay.hidden = true;
- self._navigating = false;
- defer.reject(error);
- self._execPendingRequest();
- });
- }
- }
-
- return defer.promise;
- },
-
- /**
- * This method can help us pop up any pending request and would try to
- * show it after previous request was done.
- *
- * @memberOf DialogService
- * @access private
- */
- _execPendingRequest: function() {
- var request = this._pendingRequests.pop();
- if (request) {
- this.show(request.panelId, request.userOptions, request.defer);
- }
- }
- };
-
- var dialogService = new DialogService();
- return dialogService;
-});
-define('panels/wifi_select_certificate_file/panel',['require','modules/settings_panel','modules/settings_service','modules/dialog_service','shared/device_storage/enumerate_all','shared/wifi_helper'],function(require){'use strict';var SettingsPanel=require('modules/settings_panel');var SettingsService=require('modules/settings_service');var DialogService=require('modules/dialog_service');var EnumerateAll=require('shared/device_storage/enumerate_all');var WifiHelper=require('shared/wifi_helper');var wifiManager=WifiHelper.getWifiManager();return function ctor_selectCertificateWifi(){var elements={};return SettingsPanel({onInit:function(panel){elements={};elements.panel=panel;elements.certificateFilesList=panel.querySelector('.wifi-certificate-files-List');},onBeforeShow:function(panel){this._cleanup();this._createScanList(elements.certificateFilesList);},_cleanup:function(){while(elements.certificateFilesList.hasChildNodes()){elements.certificateFilesList.removeChild(elements.certificateFilesList.lastChild);}},_createScanList:function(list){var storages=navigator.getDeviceStorages('sdcard');var cursor=EnumerateAll(storages,'');cursor.onsuccess=function(){var file=cursor.result;if(file){var extension=this._parseExtension(file.name);if(this._isCertificateFile(extension)){var li=this._createLinkAnchor(file);list.appendChild(li);}
-cursor.continue();}}.bind(this);cursor.onerror=function(){console.warn('failed to get file:'+cursor.error.name);};},_setCertificateItemsEnabled:function(enabled){var items=elements.certificateFilesList.querySelectorAll('li');var update=enabled?function(item){item.classList.remove('disabled');}:function(item){item.classList.add('disabled');};for(var i=0;i{DialogService.show('wifi-enterCertificateNickname',{certificateName:certificateName}).then((result)=>{var type=result.type;var value=result.value;if(type==='submit'){var certRequest=wifiManager.importCert(file,'',value.nickname);this._setCertificateItemsEnabled(false);certRequest.onsuccess=()=>{SettingsService.navigate('wifi-manageCertificates');};certRequest.onerror=()=>{DialogService.alert('certificate-import-failed-description',{title:'certificate-import-failed'}).then(()=>{this._setCertificateItemsEnabled(true);});};}});};return li;},_parseFilename:function(path){return path.slice(path.lastIndexOf('/')+1,path.lastIndexOf('.'));},_parseExtension:function(filename){var array=filename.split('.');return array.length>1?array.pop():'';},_isCertificateFile:function(extension){var cerExtension=['cer','crt','pem','der'];return cerExtension.indexOf(extension)>-1;},});};});
\ No newline at end of file
diff --git a/overrides/settings.gaiamobile.org/js/panels/wifi_status/panel.js b/overrides/settings.gaiamobile.org/js/panels/wifi_status/panel.js
deleted file mode 100644
index 00a7a38f..00000000
--- a/overrides/settings.gaiamobile.org/js/panels/wifi_status/panel.js
+++ /dev/null
@@ -1,50 +0,0 @@
-
-define('panels/wifi_status/panel',["require", "modules/dialog_panel", "shared/wifi_helper", "modules/wifi_context"],function(require) {
- 'use strict';
-
- var DialogPanel = require('modules/dialog_panel');
- var WifiHelper = require('shared/wifi_helper');
- var WifiContext = require('modules/wifi_context');
- var wifiManager = WifiHelper.getWifiManager();
-
- return function ctor_statusWifi() {
- var elements = {};
-
- return DialogPanel({
- onInit: function(panel) {
- elements = {};
- elements.ip = panel.querySelector('[data-ip]');
- elements.speed = panel.querySelector('[data-speed]');
- elements.ssid = panel.querySelector('[data-ssid]');
- elements.signal = panel.querySelector('[data-signal]');
- elements.security = panel.querySelector('[data-security]');
- },
- onBeforeShow: function(panel, options) {
- this._updateNetworkInfo();
- elements.ssid.textContent = options.network.ssid;
- elements.signal.setAttribute('data-l10n-id',
- 'signalLevel' + options.sl);
- if (options.security) {
- elements.security.removeAttribute('data-l10n-id');
- elements.security.textContent = options.security;
- } else {
- elements.security.setAttribute('data-l10n-id', 'securityNone');
- }
-
- WifiContext.addEventListener('wifiConnectionInfoUpdate',
- this._updateNetworkInfo);
- },
- onBeforeHide: function() {
- WifiContext.removeEventListener('wifiConnectionInfoUpdate',
- this._updateNetworkInfo);
- },
- _updateNetworkInfo: function() {
- var info = wifiManager.connectionInformation || {};
- elements.ip.textContent = info.ipAddress || '';
- document.l10n.setAttributes(elements.speed,
- Number.isFinite(info.linkSpeed) ? 'linkSpeedMbs' : 'unavailable',
- Number.isFinite(info.linkSpeed) ? { linkSpeed: info.linkSpeed } : {});
- }
- });
- };
-});
diff --git a/overrides/settings.gaiamobile.org/resources/support.json b/overrides/settings.gaiamobile.org/resources/support.json
deleted file mode 100644
index ff1e2856..00000000
--- a/overrides/settings.gaiamobile.org/resources/support.json
+++ /dev/null
@@ -1 +0,0 @@
-{"onlinesupport":{"title":"Vulpes OS","href":"https://vulpes-os.org/"},"callsupport":[]}
diff --git a/overrides/settings.gaiamobile.org/shared/js/download/download_helper.js b/overrides/settings.gaiamobile.org/shared/js/download/download_helper.js
deleted file mode 100644
index 58427d5e..00000000
--- a/overrides/settings.gaiamobile.org/shared/js/download/download_helper.js
+++ /dev/null
@@ -1,693 +0,0 @@
-'use strict';
-/* exported DownloadHelper */
-/* global DownloadFormatter */
-/* global DownloadStore */
-/* global DownloadUI */
-/* global LazyLoader */
-/* global MimeMapper */
-/* global MozActivity */
-/* global SettingsListener */
-
-/*
- * DownloadHelper.js: Perform some utility functions with DOMDownload
- * objects.
- *
- * - You have to include in you HTML:
- *
- *
- *
- *
- *
- * - How to use this component.
- *
- * For launching a download
- *
- * var req = DownloadHelper.open(download);
- *
- * req.onsuccess = function req_onsuccess() {
- * alert('The download was opened so we can remove the notification');
- * }
- *
- * req.onerror = function req_onerror() {
- * alert('Something wrong!: ' + req.error.message);
- * }
- *
- */
-var DownloadHelper = (function() {
-
- // Exception code constants
- var CODE = {
- FILE_NOT_FOUND: 'FILE_NOT_FOUND',
- DEVICE_STORAGE: 'DEVICE_STORAGE',
- MIME_TYPE_NOT_SUPPORTED: 'MIME_TYPE_NOT_SUPPORTED',
- INVALID_STATE: 'INVALID_STATE',
- NO_SDCARD: 'NO_SDCARD',
- NO_PROVIDER: 'NO_PROVIDER',
- UNMOUNTED_SDCARD: 'UNMOUNTED_SDCARD'
- };
-
- /*
- * Request auxiliary object to support asynchronous calls
- */
- var Request = function() {
- this.done = function(result) {
- if (typeof this.onsuccess === 'function') {
- this.result = result;
- window.setTimeout(function() {
- this.onsuccess({
- target: this
- });
- }.bind(this), 0);
- }
- };
-
- this.failed = function(error) {
- if (typeof this.onerror === 'function') {
- this.error = error;
- window.setTimeout(function() {
- this.onerror({
- target: this
- });
- }.bind(this), 0);
- }
- };
- };
-
- // Storage name by default
- var STORAGE_NAME_BY_DEFAULT = 'sdcard';
-
- // Storage name settings key
- var STORAGE_NAME_KEY = 'device.storage.writable.name';
-
- // Current storage name
- var storageName = STORAGE_NAME_BY_DEFAULT;
-
- LazyLoader.load('shared/js/settings_listener.js', function settingsLoaded() {
- SettingsListener.observe(STORAGE_NAME_KEY, STORAGE_NAME_BY_DEFAULT,
- function setStorageName(evt) {
- var settingValue = evt.settingValue;
- if (settingValue) {
- storageName = settingValue;
- }
- }
- );
- });
-
- /*
- * Error auxiliary method
- */
- function sendError(req, message, code) {
- req.failed({
- message: message,
- code: code
- });
-
- console.error(message);
- }
-
- /*
- * Returns a blob from the sdcard
- *
- * @param{Object} The download object
- *
- */
- function getBlob(download) {
- var req = new Request();
- if (download.vulpesDownload) {
- download.getBlob().then(function(blob) { req.done(blob); }, function(error) { req.failed(error); });
- return req;
- }
- var storage = getVolume(download.storageName);
- var storeAvailableReq = storage.available();
-
- storeAvailableReq.onsuccess = function available_onsuccess(e) {
- var path = download.storagePath;
- switch (storeAvailableReq.result) {
- case 'unavailable':
- sendError(req, ' Could not open the file: ' + path + ' from ' +
- download.storageName, CODE.NO_SDCARD);
- break;
-
- case 'shared':
- sendError(req, ' Could not open the file: ' + path + ' from ' +
- download.storageName, CODE.UNMOUNTED_SDCARD);
- break;
-
- default:
- try {
- var storeGetReq = storage.get(path);
-
- storeGetReq.onsuccess = function store_onsuccess() {
- req.done(storeGetReq.result);
- };
-
- storeGetReq.onerror = function store_onerror() {
- sendError(req, storeGetReq.error.name +
- ' Could not open the file: ' + path + ' from ' +
- download.storageName, CODE.FILE_NOT_FOUND);
- };
- } catch (ex) {
- sendError(req, 'Error getting the file ' + path + ' from ' +
- download.storageName, CODE.DEVICE_STORAGE);
- }
- }
- };
-
- storeAvailableReq.onerror = function available_onerror() {
- sendError(req, 'Error getting storage state ', CODE.DEVICE_STORAGE);
- };
-
- return req;
- }
-
- /*
- * Base action that implements the commom logic for actions based on the
- * MozActivity API
- *
- * @param{Object} Configuration parameters
- */
- var Action = function Action(params) {
- this.req = params.request;
- this.name = params.actionType.activityName;
- };
-
- Action.prototype = {
- /*
- * It performs the action based on MozActivity
- */
- run: function a_run() {
- var activity = new MozActivity({
- name: this.name,
- data: this.data
- });
-
- activity.onsuccess = this._onsuccess.bind(this);
- activity.onerror = this._onerror.bind(this);
- },
-
- /*
- * This method implements the generic onsuccess callback
- */
- _onsuccess: function a_onsuccess() {
- this.req.done();
- },
-
- /*
- * This method implements the generic onerror callback
- */
- _onerror: function a_onerror(evt) {
- this.req.failed({
- message: evt.target.error.name
- });
- }
- };
-
- /*
- * This action opens downloads extending the Action Object
- *
- * @param{Object} Configuration parameters
- */
- var OpenAction = function OpenAction(params) {
- Action.call(this, params);
-
- this.data = {
- url: params.download.path,
- filename: DownloadFormatter.getFileName(params.download),
- type: params.type,
- blob: params.blob
- };
- };
-
- OpenAction.prototype = {
- __proto__: Action.prototype
- };
-
- /*
- * This action shares downloads extending the Action Object
- *
- * @param{Object} Configuration parameters
- */
- var ShareAction = function ShareAction(params) {
- Action.call(this, params);
-
- this.data = {
- // 'share' activities do not work with specific mime types
- type: params.type.split('/')[0] + '/*',
- blobs: [params.blob],
- filenames: [DownloadFormatter.getFileName(params.download)]
- };
- };
-
- ShareAction.prototype = {
- __proto__: Action.prototype,
-
- /*
- * It overrides the generic onerror callback for another more suitable
- */
- _onerror: function sa_onerror(evt) {
- if (evt.target.error.name !== 'NO_PROVIDER') {
- return;
- }
-
- sendError(this.req, 'No provider to share file', CODE.NO_PROVIDER);
- }
- };
-
- /*
- * This action gets the info for a download
- *
- * @param{Object} Configuration parameters
- */
- var InfoAction = function InfoAction(params) {
- Action.call(this, params);
-
- this.data = {
- name: DownloadFormatter.getFileName(params.download),
- type: params.type,
- blob: params.blob,
- size: params.download.totalBytes,
- path: params.download.path
- };
- };
-
- InfoAction.prototype = {
- __proto__: Action.prototype,
-
- /*
- * It overrides the generic run method
- */
- run: function ia_run() {
- this.req.done(this.data);
- }
- };
-
- // This is a factory that deals with different objects
- var ActionsFactory = {
- TYPE: {
- OPEN: {
- activityName: 'open',
- actionClass: OpenAction
- },
- SHARE: {
- activityName: 'share',
- actionClass: ShareAction
- },
- INFO: {
- actionClass: InfoAction
- },
- WALLPAPER: {
- activityName: 'setwallpaper',
- actionClass: ShareAction
- },
- RINGTONE: {
- activityName: 'setringtone',
- actionClass: ShareAction
- }
- },
-
- create: function af_create(params) {
- return new params.actionType.actionClass(params);
- }
- };
-
- /*
- * Returns the mime type
- *
- * @param{Object} It represents a DOMDownload object
- *
- * @returns(String) Mime type
- */
- function getType(download) {
- var fileName = DownloadFormatter.getFileName(download);
- var type = MimeMapper.guessTypeFromFileProperties(fileName,
- download.contentType);
- return type;
- }
-
- /*
- * This method allows third-parties to open or share downloads
- *
- * @param{Object} Action types: or
- *
- *
- * @param{Object} It represents a DOMDownload object
- */
- function runAction(actionType, download) {
- var req = new Request();
-
- window.setTimeout(function launching() {
- var state = download.state;
- if (state === 'succeeded' || state === 'finalized') {
- LazyLoader.load(['shared/js/mime_mapper.js',
- 'shared/js/download/download_formatter.js'],
- function loaded() {
- var type = getType(download);
-
- //
- // The 'open' action will always launch an activity using the original
- // content type to allow for third party applications to handle
- // arbitrary types of content.
- //
- // The 'share' action on the other hand only works with known mime
- // types at this time.
- //
-
- if (type.length === 0) {
- type = download.contentType;
-
- if (actionType !== ActionsFactory.TYPE.OPEN &&
- actionType !== ActionsFactory.TYPE.INFO) {
- sendError(req, 'Mime type not supported: ' + type,
- CODE.MIME_TYPE_NOT_SUPPORTED);
- return;
- }
- }
-
- var blobReq = getBlob(download);
-
- blobReq.onsuccess = function() {
- ActionsFactory.create({
- actionType: actionType,
- download: download,
- type: type,
- blob: blobReq.result,
- request: req
- }).run();
- };
-
- blobReq.onerror = function() {
- // Problem getting the blob from the sdcard
- req.failed(blobReq.error);
- };
- });
- return;
- }
-
- sendError(req, 'Becareful, the download is not finished!',
- CODE.INVALID_STATE);
- }, 0);
-
- return req;
- }
-
- /*
- * This method allows clients to remove a download, from the
- * list and the phone.
- *
- * @param{Object} It represents a DOMDownload object
- */
- function remove(download) {
- var req = new Request();
- if (download.vulpesDownload) {
- navigator.mozDownloadManager.remove(download).then(function() { req.done(download); }, function(error) { req.failed(error); });
- return req;
- }
- var incompleteDownload = download.state !== 'succeeded';
- // If is not done, use download manager to remove it,
- // otherwise, deal with the datastore.
- setTimeout(function() {
- if (incompleteDownload) {
- if (!navigator.mozDownloadManager) {
- sendError(req, 'DownloadManager not present', CODE.INVALID_STATE);
- } else {
- // First we pause the download so that everyone knows it's being
- // stopped. The Downloads API itself won't stop the download first,
- // it will simply kill it.
- // XXXAus: Remove when we fix bug #1090551
- download.pause().then(
- function() {
- navigator.mozDownloadManager.remove(download).then(
- function success() {
- req.done(download);
- },
- function error() {
- sendError(req,
- 'DownloadManager doesnt know about this download',
- CODE.INVALID_STATE);
- }
- );
- },
- function() {
- sendError(req,
- 'Failed to pause download before removal',
- CODE.INVALID_STATE);
- }
- );
- }
- } else {
- req.done(download);
- }
- }, 0);
-
- return incompleteDownload ? req : doRemoveFromPhone(req, download);
- }
-
- /*
- * Performs the proper delete of the physical file, also
- * from the datastore if the download has finished.
- *
- * @param{Object} This is the Request object
- * @param{Object} It represents a DOMDownload object
- */
- function doRemoveFromPhone(deleteRequest, download) {
- var req = new Request();
-
- deleteRequest.onsuccess = function() {
- var storage = getVolume(download.storageName);
- var storeAvailableReq = storage.available();
-
- storeAvailableReq.onsuccess = function available_onsuccess(e) {
- var path = download.path;
- switch (storeAvailableReq.result) {
- case 'unavailable':
- sendError(req, ' Could not delete the file: ' + path + ' from ' +
- storageName, CODE.NO_SDCARD);
- break;
-
- case 'shared':
- sendError(req, ' Could not delete the file: ' + path + ' from ' +
- storageName, CODE.UNMOUNTED_SDCARD);
- break;
-
- default:
- var storeDeleteReq = storage.delete(download.storagePath);
-
- storeDeleteReq.onsuccess = function store_onsuccess() {
- // Remove from the datastore if status is 'succeeded'
- // if we find any problem with the datastore, don't send
- // an error, since the physical remove already happened
- if (download.state === 'succeeded') {
- LazyLoader.load(['/shared/js/download/download_store.js'],
- function() {
- DownloadStore.remove(download);
- }
- );
- }
- req.done(storeDeleteReq.result);
- };
-
- storeDeleteReq.onerror = function store_onerror() {
- sendError(req, storeDeleteReq.error.name +
- ' Could not remove the file: ' + download.path + ' from ' +
- storageName, CODE.FILE_NOT_FOUND);
- };
- }
- };
-
- storeAvailableReq.onerror = function available_onerror() {
- sendError(req, 'Error getting storage state ', CODE.DEVICE_STORAGE);
- };
- };
-
- deleteRequest.onerror = function(error) {
- sendError(req, error.message, error.code);
- };
-
- return req;
- }
-
- function handlerError(error, download, cb) {
- LazyLoader.load('shared/js/download/download_ui.js', (function loaded() {
- var req;
- var show = DownloadUI.show;
-
- // Canceled activites are normal and shouldn't be interpreted as errors.
- // Unfortunately, this isn't reported in a standard way by our
- // applications (or third party apps for that matter). This is why we
- // have this lazy filter here that may need to be updated in the future
- // but hopefully will just get removed.
- if (error.message &&
- (error.message.endsWith('canceled') ||
- error.message.endsWith('cancelled'))) {
- // Since this isn't actually an error, we invoke the callback with null.
- cb && cb(null);
- // and return early.
- return;
- }
-
- switch (error.code) {
- case CODE.NO_SDCARD:
- case CODE.UNMOUNTED_SDCARD:
- case CODE.FILE_NOT_FOUND:
- case CODE.NO_PROVIDER:
- req = show(DownloadUI.TYPE[error.code], download, true);
- req.onconfirm = cb;
-
- break;
-
- case CODE.MIME_TYPE_NOT_SUPPORTED:
- req = show(DownloadUI.TYPE.UNSUPPORTED_FILE_TYPE, download, true);
- req.onconfirm = function tuftOnConfirm() {
- showRemoveFileUI(download, cb);
- };
-
- break;
-
- default:
- req = show(DownloadUI.TYPE.FILE_OPEN_ERROR, download, true);
- req.onconfirm = function tfoeOnConfirm() {
- showRemoveFileUI(download, cb);
- };
- break;
- }
-
- // We have to remove the notification if the user cancels
- req.oncancel = cb;
- }));
- }
-
- function showRemoveFileUI(download, cb) {
- var req = DownloadUI.show(DownloadUI.TYPE.DELETE, download, true);
-
- req.oncancel = cb;
-
- req.onconfirm = function doRemove() {
- if (typeof cb === 'function') {
- cb(download);
- } else {
- remove(download);
- }
- };
- }
-
- function getFreeSpace(cb) {
- var storage = navigator.getDeviceStorage(storageName);
-
- if (!storage) {
- console.error('Cannot get free space size in sdcard');
- cb(null);
- return;
- }
-
- var req = storage.freeSpace();
-
- req.onsuccess = function(e) {
- cb(e.target.result);
- };
-
- req.onerror = function() {
- cb(null);
- };
- }
-
- /**
- * Gets the storage for the download based on the volumen
- * it was saved (storageName)
- */
- function getVolume(volumeName) {
- // Per API design, all media type return the same volumes.
- // So we use 'sdcard' here for no reason.
- // https://bugzilla.mozilla.org/show_bug.cgi?id=856782#c10
- var volumes = navigator.getDeviceStorages('sdcard');
- if (!volumeName || volumeName === '') {
- return volumes[0];
- }
- for (var i = 0; i < volumes.length; ++i) {
- if (volumes[i].storageName === volumeName) {
- return volumes[i];
- }
- }
- return volumes[0];
- }
-
- return {
- /*
- * This method allows clients to open a downlaod
- *
- * @param{Object} It represents a DOMDownload object
- */
- open: function(download) {
- return runAction(ActionsFactory.TYPE.OPEN, download);
- },
-
- /*
- * This method allows clients to share a downlaoded file
- *
- * @param{Object} It represents a DOMDownload object
- */
- share: function(download) {
- return runAction(ActionsFactory.TYPE.SHARE, download);
- },
-
- /*
- * This method allows clients to set as wallaper a downlaoded file
- *
- * @param{Object} It represents a DOMDownload object
- */
- wallpaper: function(download) {
- return runAction(ActionsFactory.TYPE.WALLPAPER, download);
- },
-
- /*
- * This method allows clients to set as ringtone a downlaoded file
- *
- * @param{Object} It represents a DOMDownload object
- */
- ringtone: function(download) {
- return runAction(ActionsFactory.TYPE.RINGTONE, download);
- },
-
- /*
- * This method returns information about a download
- *
- * @param{Object} It represents a DOMDownload object
- */
- info: function(download) {
- return runAction(ActionsFactory.TYPE.INFO, download);
- },
-
- /*
- * Given a download, remove it from the DownloadManager
- * list, and the file system.
- *
- * @param{Object} It represents a DOMDownload object
- */
- remove: remove,
-
- /*
- * Returns exception code constants
- */
- get CODE() {
- return CODE;
- },
-
- /*
- * This method handles different errors when users attemp to open files
- *
- * @param{Object} Error object
- *
- * @param{Object} It represents a DOMDownload object
- *
- * @param{Function} This function is performed when the flow is finished
- */
- handlerError: handlerError,
-
- /*
- * Returns the free memory size in bytes
- *
- * @param{Function} This function is performed when the free memory size has
- * been calculated
- */
- getFreeSpace: getFreeSpace
- };
-}());
diff --git a/overrides/shared/resources/branding/vulpes-symbol.png b/overrides/shared/resources/branding/vulpes-symbol.png
deleted file mode 100644
index 7dd01e63..00000000
Binary files a/overrides/shared/resources/branding/vulpes-symbol.png and /dev/null differ
diff --git a/overrides/system.gaiamobile.org/js/system_banner.js b/overrides/system.gaiamobile.org/js/system_banner.js
deleted file mode 100755
index c88f6b55..00000000
--- a/overrides/system.gaiamobile.org/js/system_banner.js
+++ /dev/null
@@ -1,203 +0,0 @@
-/* global LazyLoader */
-'use strict';
-
-(function(exports) {
-
- /**
- * SystemBanner displays a type of notification to the user in certain cases.
- * It is a type of temporary notification that does not live in the
- * notifications tray. Examples of the SystemBanner implementation include
- * application installation, crash reporter, and low storage notices.
- * @class SystemBanner
- */
- function SystemBanner() {
- this._generation = 0;
- }
-
- /**
- * Amount of time before banner is automatically dismissed.
- */
- SystemBanner.TIMEOUT = 4000;
-
- SystemBanner.prototype = {
-
- /**
- * A reference to the SystemBanner element
- * @memberof SystemBanner.prototype
- * @type {Element}
- */
- _banner: null,
-
- /**
- * Set when the clicked callback called. Bypasses dismiss.
- * @memberof SystemBanner.prototype
- * @type {Boolean}
- */
- _clicked: false,
-
- /**
- * Set to true when the banner is currently showing to the user.
- * @memberof SystemBanner.prototype
- * @type {Boolean}
- */
- _showing: false,
-
- /**
- * Generates and returns the banner if it does not exist
- * @memberof SystemBanner.prototype
- * @type {Function}
- */
- getBanner: function() {
- if (this._banner) {
- return Promise.resolve(this._banner);
- }
-
- return LazyLoader.load('shared/elements/gaia-component/gaia-component.js')
- .then(() => {
- return LazyLoader.load('shared/elements/gaia-toast/gaia-toast.js');
- })
- .then(() => {
- this._banner = document.createElement('gaia-toast');
- this._banner.timeout = SystemBanner.TIMEOUT;
- this._banner.className = 'banner';
- this._banner.dataset.zIndexLevel = 'system-notification-banner';
- var container = createChild(this._banner, 'div', 'container');
- createChild(container, 'div', 'messages');
- createChild(container, 'div', 'buttons');
- return this._banner;
- });
- },
-
- /**
- * Shows a banner with a given message.
- * Optionally shows a button with a given label/callback/dismiss.
- * 'dismiss' is called when the banner is dismissed and button
- * has not been clicked. It is optional.
- * @memberof SystemBanner.prototype
- * @param {String|Array} messages The messages to display
- * @param {Object} buttonParams
- * { label: l10nAttrs, callback: ..., dismiss: ... }
- */
- show: function(messages, buttonParams) {
- var generation = ++this._generation;
- clearTimeout(this._dismissTimer);
- if (this._cancelRemoval) {
- this._cancelRemoval();
- }
- this._clicked = false;
- this._showing = true;
-
- return this.getBanner().then(banner => {
- if (generation !== this._generation || !this._showing) {
- return;
- }
- // Clear the button and message containers.
- var messageContainer = banner.querySelector('.messages');
- var buttonContainer = banner.querySelector('.buttons');
- messageContainer.innerHTML = '';
- buttonContainer.innerHTML = '';
-
- // Populate banner with messages.
- if (!Array.isArray(messages)) {
- messages = [messages];
- }
- messages.forEach((chunk) => {
- var p = createChild(messageContainer, 'p');
- setElementL10n(p, chunk);
- });
-
- // Populate buttons if specified.
- if (buttonParams) {
- var button = createChild(buttonContainer, 'span');
- button.className = 'button';
- setElementL10n(button, buttonParams.label);
- button.addEventListener('click', () => {
- this._clicked = true;
- this.hide();
- buttonParams.callback && buttonParams.callback();
- });
- }
-
- // Hide banner and invoke button dismiss() on click or timeout.
- var hideBanner = () => {
- if (generation !== this._generation || !this._showing) {
- return;
- }
- this.hide();
- if (!this._clicked) {
- buttonParams && buttonParams.dismiss && buttonParams.dismiss();
- }
- };
- banner.onclick = hideBanner;
- this._dismissTimer = setTimeout(hideBanner, SystemBanner.TIMEOUT);
-
- document.getElementById('screen').appendChild(banner);
- banner.show();
- });
- },
-
- /**
- * Hide the System Banner and remove it from the DOM.
- */
- hide: function() {
- if (!this._showing) {
- return;
- }
- this._showing = false;
- clearTimeout(this._dismissTimer);
- var generation = this._generation;
-
- return this.getBanner().then(banner => {
- if (generation !== this._generation || this._showing) {
- return;
- }
- // Animation events inside a modern shadow root do not reach the host.
- var animation = banner.shadowRoot &&
- banner.shadowRoot.querySelector('.bread') || banner;
- var cleanup = () => {
- clearTimeout(this._removeTimer);
- animation.removeEventListener('animationend', removeBanner);
- animation.removeEventListener('animationcancel', removeBanner);
- this._cancelRemoval = null;
- };
- var removeBanner = event => {
- if (event && event.target !== animation) {
- return;
- }
- cleanup();
- if (!this._showing && generation === this._generation) {
- banner.parentNode && banner.parentNode.removeChild(banner);
- }
- };
- this._cancelRemoval = cleanup;
- animation.addEventListener('animationend', removeBanner);
- animation.addEventListener('animationcancel', removeBanner);
- // Gaia's exit animation lasts 600 ms. Also dismiss if it is disabled
- // or no animation event is produced.
- this._removeTimer = setTimeout(removeBanner, 700);
- banner.hide();
- });
- }
- };
-
- function createChild(parentEl, type, className) {
- var el = document.createElement(type);
- if (className) {
- el.className = className;
- }
- parentEl.appendChild(el);
- return el;
- }
-
- function setElementL10n(element, l10nAttrs) {
- if (typeof(l10nAttrs) === 'string') {
- element.setAttribute('data-l10n-id', l10nAttrs);
- } else {
- navigator.mozL10n.setAttributes(
- element, l10nAttrs.id, l10nAttrs.args);
- }
- }
-
- exports.SystemBanner = SystemBanner;
-
-}(window));
diff --git a/overrides/video.gaiamobile.org/gaia_build_defer_index.js b/overrides/video.gaiamobile.org/gaia_build_defer_index.js
deleted file mode 100644
index 450001a5..00000000
--- a/overrides/video.gaiamobile.org/gaia_build_defer_index.js
+++ /dev/null
@@ -1,1177 +0,0 @@
-;var _createClass=(function(){function defineProperties(target,props){for(var i=0;ilistener.apply(this,args));}
-if(listeners[type]){listeners[type].slice().forEach(listener=>listener.apply(this,args));}}
-function addEventListener(listeners,type,listener){if(!(type in listeners)){listeners[type]=[];}
-listeners[type].push(listener);}
-function removeEventListener(listeners,type,listener){const typeListeners=listeners[type];const pos=typeListeners.indexOf(listener);if(pos===-1){return;}
-typeListeners.splice(pos,1);}
-let Client=(function(){function Client(remote){_classCallCheck(this,Client);this.id=this;this.remote=remote;const listeners={};this.on=(...args)=>addEventListener(listeners,...args);this.emit=(...args)=>emit(listeners,...args);}
-_createClass(Client,[{key:'method',value:function method(name,...args){return this.remote[name](...args);}}]);return Client;})();function broadcast(type,data){Array.from(this.ctxs.keys()).forEach(client=>client.emit(type,data));}
-function L10nError(message,id,lang){this.name='L10nError';this.message=message;this.id=id;this.lang=lang;}
-L10nError.prototype=Object.create(Error.prototype);L10nError.prototype.constructor=L10nError;function load(type,url){return new Promise(function(resolve,reject){const xhr=new XMLHttpRequest();if(xhr.overrideMimeType){xhr.overrideMimeType(type);}
-xhr.open('GET',url,true);if(type==='application/json'){xhr.responseType='json';}
-xhr.addEventListener('load',function io_onload(e){if(e.target.status===200||e.target.status===0){resolve(e.target.response||e.target.responseText);}else{reject(new L10nError('Not found: '+url));}});xhr.addEventListener('error',reject);xhr.addEventListener('timeout',reject);try{xhr.send(null);}catch(e){if(e.name==='NS_ERROR_FILE_NOT_FOUND'){reject(new L10nError('Not found: '+url));}else{throw e;}}});}
-const io={extra:function(code,ver,path,type){return navigator.mozApps.getLocalizationResource(code,ver,path,type);},app:function(code,ver,path,type){switch(type){case'text':return load('text/plain',path);case'json':return load('application/json',path);default:throw new L10nError('Unknown file type: '+type);}}};function fetchResource(res,{code,src,ver}){const url=res.replace('{locale}',code);const type=res.endsWith('.json')?'json':'text';return io[src](code,ver,url,type);}
-const KNOWN_MACROS=['plural'];const MAX_PLACEABLE_LENGTH=2500;const FSI='';const PDI='';const resolutionChain=new WeakSet();function format(ctx,lang,args,entity){if(typeof entity==='string'){return[{},entity];}
-if(resolutionChain.has(entity)){throw new L10nError('Cyclic reference detected');}
-resolutionChain.add(entity);let rv;try{rv=resolveValue({},ctx,lang,args,entity.value,entity.index);}finally{resolutionChain.delete(entity);}
-return rv;}
-function resolveIdentifier(ctx,lang,args,id){if(KNOWN_MACROS.indexOf(id)>-1){return[{},ctx._getMacro(lang,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==='__proto__'){throw new L10nError('Illegal id: '+id);}
-const entity=ctx._getEntity(lang,id);if(entity){return format(ctx,lang,args,entity);}
-throw new L10nError('Unknown reference: '+id);}
-function subPlaceable(locals,ctx,lang,args,id){let newLocals,value;try{[newLocals,value]=resolveIdentifier(ctx,lang,args,id);}catch(err){return[{error:err},FSI+'{{ '+id+' }}'+PDI];}
-if(typeof value==='number'){const formatter=ctx._getNumberFormatter(lang);return[newLocals,formatter.format(value)];}
-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+')');}
-return[newLocals,FSI+value+PDI];}
-return[{},FSI+'{{ '+id+' }}'+PDI];}
-function interpolate(locals,ctx,lang,args,arr){return arr.reduce(function([localsSeq,valueSeq],cur){if(typeof cur==='string'){return[localsSeq,valueSeq+cur];}else{const[,value]=subPlaceable(locals,ctx,lang,args,cur.name);return[localsSeq,valueSeq+value];}},[locals,'']);}
-function resolveSelector(ctx,lang,args,expr,index){let selectorName;if(index[0].type==='call'&&index[0].expr.type==='prop'&&index[0].expr.expr.name==='cldr'){selectorName='plural';}else{selectorName=index[0].name;}
-const selector=resolveIdentifier(ctx,lang,args,selectorName)[1];if(typeof selector!=='function'){return selector;}
-const argValue=index[0].args?resolveIdentifier(ctx,lang,args,index[0].args[0].name)[1]:undefined;if(selectorName==='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,ctx,lang,args,expr,index){if(!expr){return[locals,expr];}
-if(typeof expr==='string'||typeof expr==='boolean'||typeof expr==='number'){return[locals,expr];}
-if(Array.isArray(expr)){return interpolate(locals,ctx,lang,args,expr);}
-if(index){const selector=resolveSelector(ctx,lang,args,expr,index);if(selector in expr){return resolveValue(locals,ctx,lang,args,expr[selector]);}}
-const defaultKey=expr.__default||'other';if(defaultKey in expr){return resolveValue(locals,ctx,lang,args,expr[defaultKey]);}
-throw new L10nError('Unresolvable value');}
-const locales2rules={'af':3,'ak':4,'am':4,'ar':1,'asa':3,'az':0,'be':11,'bem':3,'bez':3,'bg':3,'bh':4,'bm':0,'bn':3,'bo':0,'br':20,'brx':3,'bs':11,'ca':3,'cgg':3,'chr':3,'cs':12,'cy':17,'da':3,'de':3,'dv':3,'dz':0,'ee':3,'el':3,'en':3,'eo':3,'es':3,'et':3,'eu':3,'fa':0,'ff':5,'fi':3,'fil':4,'fo':3,'fr':5,'fur':3,'fy':3,'ga':8,'gd':24,'gl':3,'gsw':3,'gu':3,'guw':4,'gv':23,'ha':3,'haw':3,'he':2,'hi':4,'hr':11,'hu':0,'id':0,'ig':0,'ii':0,'is':3,'it':3,'iu':7,'ja':0,'jmc':3,'jv':0,'ka':0,'kab':5,'kaj':3,'kcg':3,'kde':0,'kea':0,'kk':3,'kl':3,'km':0,'kn':0,'ko':0,'ksb':3,'ksh':21,'ku':3,'kw':7,'lag':18,'lb':3,'lg':3,'ln':4,'lo':0,'lt':10,'lv':6,'mas':3,'mg':4,'mk':16,'ml':3,'mn':3,'mo':9,'mr':3,'ms':0,'mt':15,'my':0,'nah':3,'naq':7,'nb':3,'nd':3,'ne':3,'nl':3,'nn':3,'no':3,'nr':3,'nso':4,'ny':3,'nyn':3,'om':3,'or':3,'pa':3,'pap':3,'pl':13,'ps':3,'pt':3,'rm':3,'ro':9,'rof':3,'ru':11,'rwk':3,'sah':0,'saq':3,'se':7,'seh':3,'ses':0,'sg':0,'sh':11,'shi':19,'sk':12,'sl':14,'sma':7,'smi':7,'smj':7,'smn':7,'sms':7,'sn':3,'so':3,'sq':3,'sr':11,'ss':3,'ssy':3,'st':3,'sv':3,'sw':3,'syr':3,'ta':3,'te':3,'teo':3,'th':0,'ti':4,'tig':3,'tk':3,'tl':4,'tn':3,'to':0,'tr':0,'ts':3,'tzm':22,'uk':11,'ur':3,'ve':3,'vi':0,'vun':3,'wa':4,'wae':3,'wo':0,'xh':3,'xog':3,'yo':0,'zh':0,'zu':3};function isIn(n,list){return list.indexOf(n)!==-1;}
-function isBetween(n,start,end){return typeof n===typeof start&&start<=n&&n<=end;}
-const pluralRules={'0':function(){return'other';},'1':function(n){if(isBetween(n%100,3,10)){return'few';}
-if(n===0){return'zero';}
-if(isBetween(n%100,11,99)){return'many';}
-if(n===2){return'two';}
-if(n===1){return'one';}
-return'other';},'2':function(n){if(n!==0&&n%10===0){return'many';}
-if(n===2){return'two';}
-if(n===1){return'one';}
-return'other';},'3':function(n){if(n===1){return'one';}
-return'other';},'4':function(n){if(isBetween(n,0,1)){return'one';}
-return'other';},'5':function(n){if(isBetween(n,0,2)&&n!==2){return'one';}
-return'other';},'6':function(n){if(n===0){return'zero';}
-if(n%10===1&&n%100!==11){return'one';}
-return'other';},'7':function(n){if(n===2){return'two';}
-if(n===1){return'one';}
-return'other';},'8':function(n){if(isBetween(n,3,6)){return'few';}
-if(isBetween(n,7,10)){return'many';}
-if(n===2){return'two';}
-if(n===1){return'one';}
-return'other';},'9':function(n){if(n===0||n!==1&&isBetween(n%100,1,19)){return'few';}
-if(n===1){return'one';}
-return'other';},'10':function(n){if(isBetween(n%10,2,9)&&!isBetween(n%100,11,19)){return'few';}
-if(n%10===1&&!isBetween(n%100,11,19)){return'one';}
-return'other';},'11':function(n){if(isBetween(n%10,2,4)&&!isBetween(n%100,12,14)){return'few';}
-if(n%10===0||isBetween(n%10,5,9)||isBetween(n%100,11,14)){return'many';}
-if(n%10===1&&n%100!==11){return'one';}
-return'other';},'12':function(n){if(isBetween(n,2,4)){return'few';}
-if(n===1){return'one';}
-return'other';},'13':function(n){if(isBetween(n%10,2,4)&&!isBetween(n%100,12,14)){return'few';}
-if(n!==1&&isBetween(n%10,0,1)||isBetween(n%10,5,9)||isBetween(n%100,12,14)){return'many';}
-if(n===1){return'one';}
-return'other';},'14':function(n){if(isBetween(n%100,3,4)){return'few';}
-if(n%100===2){return'two';}
-if(n%100===1){return'one';}
-return'other';},'15':function(n){if(n===0||isBetween(n%100,2,10)){return'few';}
-if(isBetween(n%100,11,19)){return'many';}
-if(n===1){return'one';}
-return'other';},'16':function(n){if(n%10===1&&n!==11){return'one';}
-return'other';},'17':function(n){if(n===3){return'few';}
-if(n===0){return'zero';}
-if(n===6){return'many';}
-if(n===2){return'two';}
-if(n===1){return'one';}
-return'other';},'18':function(n){if(n===0){return'zero';}
-if(isBetween(n,0,2)&&n!==0&&n!==2){return'one';}
-return'other';},'19':function(n){if(isBetween(n,2,10)){return'few';}
-if(isBetween(n,0,1)){return'one';}
-return'other';},'20':function(n){if((isBetween(n%10,3,4)||n%10===9)&&!(isBetween(n%100,10,19)||isBetween(n%100,70,79)||isBetween(n%100,90,99))){return'few';}
-if(n%1000000===0&&n!==0){return'many';}
-if(n%10===2&&!isIn(n%100,[12,72,92])){return'two';}
-if(n%10===1&&!isIn(n%100,[11,71,91])){return'one';}
-return'other';},'21':function(n){if(n===0){return'zero';}
-if(n===1){return'one';}
-return'other';},'22':function(n){if(isBetween(n,0,1)||isBetween(n,11,99)){return'one';}
-return'other';},'23':function(n){if(isBetween(n%10,1,2)||n%20===0){return'one';}
-return'other';},'24':function(n){if(isBetween(n,3,10)||isBetween(n,13,19)){return'few';}
-if(isIn(n,[2,12])){return'two';}
-if(isIn(n,[1,11])){return'one';}
-return'other';}};function getPluralRule(code){const index=locales2rules[code.replace(/-.*$/,'')];if(!(index in pluralRules)){return function(){return'other';};}
-return pluralRules[index];}
-const L20nIntl=typeof Intl!=='undefined'?Intl:{NumberFormat:function(){return{format:function(v){return v;}};}};let Context=(function(){function Context(env,langs,resIds){_classCallCheck(this,Context);this.langs=langs;this.resIds=resIds;this.env=env;this.emit=(type,evt)=>env.emit(type,evt,this);}
-_createClass(Context,[{key:'_formatTuple',value:function _formatTuple(lang,args,entity,id,key){try{return format(this,lang,args,entity);}catch(err){err.id=key?id+'::'+key:id;err.lang=lang;this.emit('resolveerror',err);return[{error:err},err.id];}}},{key:'_formatEntity',value:function _formatEntity(lang,args,entity,id){const[,value]=this._formatTuple(lang,args,entity,id);const formatted={value,attrs:null};if(entity.attrs){formatted.attrs=Object.create(null);for(let key in entity.attrs){const[,attrValue]=this._formatTuple(lang,args,entity.attrs[key],id,key);formatted.attrs[key]=attrValue;}}
-return formatted;}},{key:'_formatValue',value:function _formatValue(lang,args,entity,id){return this._formatTuple(lang,args,entity,id)[1];}},{key:'fetch',value:function fetch(langs=this.langs){if(langs.length===0){return Promise.resolve(langs);}
-return Promise.all(this.resIds.map(resId=>this.env._getResource(langs[0],resId))).then(()=>langs);}},{key:'_resolve',value:function _resolve(langs,keys,formatter,prevResolved){const lang=langs[0];if(!lang){return reportMissing.call(this,keys,formatter,prevResolved);}
-let hasUnresolved=false;const resolved=keys.map((key,i)=>{if(prevResolved&&prevResolved[i]!==undefined){return prevResolved[i];}
-const[id,args]=Array.isArray(key)?key:[key,undefined];const entity=this._getEntity(lang,id);if(entity){return formatter.call(this,lang,args,entity,id);}
-this.emit('notfounderror',new L10nError('"'+id+'"'+' not found in '+lang.code,id,lang));hasUnresolved=true;});if(!hasUnresolved){return resolved;}
-return this.fetch(langs.slice(1)).then(nextLangs=>this._resolve(nextLangs,keys,formatter,resolved));}},{key:'formatEntities',value:function formatEntities(...keys){return this.fetch().then(langs=>this._resolve(langs,keys,this._formatEntity));}},{key:'formatValues',value:function formatValues(...keys){return this.fetch().then(langs=>this._resolve(langs,keys,this._formatValue));}},{key:'_getEntity',value:function _getEntity(lang,id){const cache=this.env.resCache;for(let i=0,resId;resId=this.resIds[i];i++){const resource=cache.get(resId+lang.code+lang.src);if(resource instanceof L10nError){continue;}
-if(id in resource){return resource[id];}}
-return undefined;}},{key:'_getNumberFormatter',value:function _getNumberFormatter(lang){if(!this.env.numberFormatters){this.env.numberFormatters=new Map();}
-if(!this.env.numberFormatters.has(lang)){const formatter=L20nIntl.NumberFormat(lang);this.env.numberFormatters.set(lang,formatter);return formatter;}
-return this.env.numberFormatters.get(lang);}},{key:'_getMacro',value:function _getMacro(lang,id){switch(id){case'plural':return getPluralRule(lang.code);default:return undefined;}}}]);return Context;})();function reportMissing(keys,formatter,resolved){const missingIds=new Set();keys.forEach((key,i)=>{if(resolved&&resolved[i]!==undefined){return;}
-const id=Array.isArray(key)?key[0]:key;missingIds.add(id);resolved[i]=formatter===this._formatValue?id:{value:id,attrs:null};});this.emit('notfounderror',new L10nError('"'+Array.from(missingIds).join(', ')+'"'+' not found in any language',missingIds));return resolved;}
-var MAX_PLACEABLES=100;var PropertiesParser={patterns:null,entryIds:null,emit:null,init:function(){this.patterns={comment:/^\s*#|^\s*$/,entity:/^([^=\s]+)\s*=\s*(.*)$/,multiline:/[^\\]\\$/,index:/\{\[\s*(\w+)(?:\(([^\)]*)\))?\s*\]\}/i,unicode:/\\u([0-9a-fA-F]{1,4})/g,entries:/[^\r\n]+/g,controlChars:/\\([\\\n\r\t\b\f\{\}\"\'])/g,placeables:/\{\{\s*([^\s]*?)\s*\}\}/};},parse:function(emit,source){if(!this.patterns){this.init();}
-this.emit=emit;var entries={};var lines=source.match(this.patterns.entries);if(!lines){return entries;}
-for(var i=0;i2){throw this.error('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 this.error('Attribute can\'t start with "$"');}}else{attr=null;}
-this.setEntityValue(name,attr,key,this.unescapeString(value),entries);},setEntityValue:function(id,attr,key,rawValue,entries){var value=rawValue.indexOf('{{')>-1?this.parseString(rawValue):rawValue;var isSimpleValue=typeof value==='string';var root=entries;var isSimpleNode=typeof entries[id]==='string';if(!entries[id]&&(attr||key||!isSimpleValue)){entries[id]=Object.create(null);isSimpleNode=false;}
-if(attr){if(isSimpleNode){const val=entries[id];entries[id]=Object.create(null);entries[id].value=val;}
-if(!entries[id].attrs){entries[id].attrs=Object.create(null);}
-if(!entries[id].attrs&&!isSimpleValue){entries[id].attrs[attr]=Object.create(null);}
-root=entries[id].attrs;id=attr;}
-if(key){isSimpleNode=false;if(typeof root[id]==='string'){const val=root[id];root[id]=Object.create(null);root[id].index=this.parseIndex(val);root[id].value=Object.create(null);}
-root=root[id].value;id=key;isSimpleValue=true;}
-if(isSimpleValue){if(id in root){throw this.error('Duplicated id: '+id);}
-root[id]=value;}else{if(!root[id]){root[id]=Object.create(null);}
-root[id].value=value;}},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 this.error('Too many placeables ('+placeablesCount+', max allowed is '+MAX_PLACEABLES+')');}
-for(var i=0;i'){throw this.error('Expected ">"');}
-attrs=this.getAttributes();}else{const ws1=this.getRequiredWS();if(this._source[this._index]!=='>'){if(!ws1){throw this.error('Expected ">"');}
-attrs=this.getAttributes();}}
-++this._index;if(id in this.entries){throw this.error('Duplicate entry ID "'+id,'duplicateerror');}
-if(!attrs&&!index&&typeof value==='string'){this.entries[id]=value;}else{this.entries[id]={value,attrs,index};}},getValue:function(ch=this._source[this._index],index=false,required=true){switch(ch){case'\'':case'"':return this.getString(ch,1);case'{':return this.getHash(index);}
-if(required){throw this.error('Unknown value type');}
-return;},getWS:function(){let cc=this._source.charCodeAt(this._index);while(cc===32||cc===10||cc===9||cc===13){cc=this._source.charCodeAt(++this._index);}},getRequiredWS:function(){const pos=this._index;let cc=this._source.charCodeAt(pos);while(cc===32||cc===10||cc===9||cc===13){cc=this._source.charCodeAt(++this._index);}
-return this._index!==pos;},getIdentifier:function(){const start=this._index;let cc=this._source.charCodeAt(this._index);if(cc>=97&&cc<=122||cc>=65&&cc<=90||cc===95){cc=this._source.charCodeAt(++this._index);}else{throw this.error('Identifier has to start with [a-zA-Z_]');}
-while(cc>=97&&cc<=122||cc>=65&&cc<=90||cc>=48&&cc<=57||cc===95){cc=this._source.charCodeAt(++this._index);}
-return this._source.slice(start,this._index);},getUnicodeChar:function(){for(let i=0;i<4;i++){let cc=this._source.charCodeAt(++this._index);if(cc>96&&cc<103||cc>64&&cc<71||cc>47&&cc<58){continue;}
-throw this.error('Illegal unicode escape sequence');}
-this._index++;return String.fromCharCode(parseInt(this._source.slice(this._index-4,this._index),16));},stringRe:/"|'|{{|\\/g,getString:function(opchar,opcharLen){const body=[];let placeables=0;this._index+=opcharLen;const start=this._index;let bufStart=start;let buf='';while(true){this.stringRe.lastIndex=this._index;const match=this.stringRe.exec(this._source);if(!match){throw this.error('Unclosed string literal');}
-if(match[0]==='"'||match[0]==='\''){if(match[0]!==opchar){this._index+=opcharLen;continue;}
-this._index=match.index+opcharLen;break;}
-if(match[0]==='{{'){if(placeables>MAX_PLACEABLES$1-1){throw this.error('Too many placeables, maximum allowed is '+MAX_PLACEABLES$1);}
-placeables++;if(match.index>bufStart||buf.length>0){body.push(buf+this._source.slice(bufStart,match.index));buf='';}
-this._index=match.index+2;this.getWS();body.push(this.getExpression());this.getWS();this._index+=2;bufStart=this._index;continue;}
-if(match[0]==='\\'){this._index=match.index+1;const ch2=this._source[this._index];if(ch2==='u'){buf+=this._source.slice(bufStart,match.index)+this.getUnicodeChar();}else if(ch2===opchar||ch2==='\\'){buf+=this._source.slice(bufStart,match.index)+ch2;this._index++;}else if(this._source.startsWith('{{',this._index)){buf+=this._source.slice(bufStart,match.index)+'{{';this._index+=2;}else{throw this.error('Illegal escape sequence');}
-bufStart=this._index;}}
-if(body.length===0){return buf+this._source.slice(bufStart,this._index-opcharLen);}
-if(this._index-opcharLen>bufStart||buf.length>0){body.push(buf+this._source.slice(bufStart,this._index-opcharLen));}
-return body;},getAttributes:function(){const attrs=Object.create(null);while(true){this.getAttribute(attrs);const ws1=this.getRequiredWS();const ch=this._source.charAt(this._index);if(ch==='>'){break;}else if(!ws1){throw this.error('Expected ">"');}}
-return attrs;},getAttribute:function(attrs){const key=this.getIdentifier();let index;if(this._source[this._index]==='['){++this._index;this.getWS();index=this.getItemList(this.getExpression,']');}
-this.getWS();if(this._source[this._index]!==':'){throw this.error('Expected ":"');}
-++this._index;this.getWS();const hasIndex=index!==undefined;const value=this.getValue(undefined,hasIndex);if(key in attrs){throw this.error('Duplicate attribute "'+key,'duplicateerror');}
-if(!index&&typeof value==='string'){attrs[key]=value;}else{attrs[key]={value,index};}},getHash:function(index){const items=Object.create(null);++this._index;this.getWS();let defKey;while(true){const[key,value,def]=this.getHashItem();items[key]=value;if(def){if(defKey){throw this.error('Default item redefinition forbidden');}
-defKey=key;}
-this.getWS();const comma=this._source[this._index]===',';if(comma){++this._index;this.getWS();}
-if(this._source[this._index]==='}'){++this._index;break;}
-if(!comma){throw this.error('Expected "}"');}}
-if(defKey){items.__default=defKey;}else if(!index){throw this.error('Unresolvable Hash Value');}
-return items;},getHashItem:function(){let defItem=false;if(this._source[this._index]==='*'){++this._index;defItem=true;}
-const key=this.getIdentifier();this.getWS();if(this._source[this._index]!==':'){throw this.error('Expected ":"');}
-++this._index;this.getWS();return[key,this.getValue(),defItem];},getComment:function(){this._index+=2;const start=this._index;const end=this._source.indexOf('*/',start);if(end===-1){throw this.error('Comment without a closing tag');}
-this._index=end+2;},getExpression:function(){let exp=this.getPrimaryExpression();while(true){let ch=this._source[this._index];if(ch==='.'||ch==='['){++this._index;exp=this.getPropertyExpression(exp,ch==='[');}else if(ch==='('){++this._index;exp=this.getCallExpression(exp);}else{break;}}
-return exp;},getPropertyExpression:function(idref,computed){let exp;if(computed){this.getWS();exp=this.getExpression();this.getWS();if(this._source[this._index]!==']'){throw this.error('Expected "]"');}
-++this._index;}else{exp=this.getIdentifier();}
-return{type:'prop',expr:idref,prop:exp,cmpt:computed};},getCallExpression:function(callee){this.getWS();return{type:'call',expr:callee,args:this.getItemList(this.getExpression,')')};},getPrimaryExpression:function(){const ch=this._source[this._index];switch(ch){case'$':++this._index;return{type:'var',name:this.getIdentifier()};case'@':++this._index;return{type:'glob',name:this.getIdentifier()};default:return{type:'id',name:this.getIdentifier()};}},getItemList:function(callback,closeChar){const items=[];let closed=false;this.getWS();if(this._source[this._index]===closeChar){++this._index;closed=true;}
-while(!closed){items.push(callback.call(this));this.getWS();let ch=this._source.charAt(this._index);switch(ch){case',':++this._index;this.getWS();break;case closeChar:++this._index;closed=true;break;default:throw this.error('Expected "," or "'+closeChar+'"');}}
-return items;},getJunkEntry:function(){const pos=this._index;let nextEntity=this._source.indexOf('<',pos);let nextComment=this._source.indexOf('/*',pos);if(nextEntity===-1){nextEntity=this._length;}
-if(nextComment===-1){nextComment=this._length;}
-let nextEntry=Math.min(nextEntity,nextComment);this._index=nextEntry;},error:function(message,type='parsererror'){const pos=this._index;let start=this._source.lastIndexOf('<',pos-1);const lastClose=this._source.lastIndexOf('>',pos-1);start=lastClose>start?lastClose+1:start;const context=this._source.slice(start,pos+10);const msg=message+' at pos '+pos+': `'+context+'`';const err=new L10nError(msg);if(this.emit){this.emit(type,err);}
-return err;}};function walkEntry(entry,fn){if(typeof entry==='string'){return fn(entry);}
-const newEntry=Object.create(null);if(entry.value){newEntry.value=walkValue(entry.value,fn);}
-if(entry.index){newEntry.index=entry.index;}
-if(entry.attrs){newEntry.attrs=Object.create(null);for(let key in entry.attrs){newEntry.attrs[key]=walkEntry(entry.attrs[key],fn);}}
-return newEntry;}
-function walkValue(value,fn){if(typeof value==='string'){return fn(value);}
-if(value.type){return value;}
-const newValue=Array.isArray(value)?[]:Object.create(null);const keys=Object.keys(value);for(let i=0,key;key=keys[i];i++){newValue[key]=walkValue(value[key],fn);}
-return newValue;}
-function createGetter(id,name){let _pseudo=null;return function getPseudo(){if(_pseudo){return _pseudo;}
-const reAlphas=/[a-zA-Z]/g;const reVowels=/[aeiouAEIOU]/g;const reWords=/[^\W0-9_]+/g;const reExcluded=/(%[EO]?\w|\{\s*.+?\s*\}|&[#\w]+;|<\s*.+?\s*>)/;const charMaps={'fr-x-psaccent':'ȦƁƇḒḖƑƓĦĪĴĶĿḾȠǾƤɊŘŞŦŬṼẆẊẎẐ[\\]^_`ȧƀƈḓḗƒɠħīĵķŀḿƞǿƥɋřşŧŭṽẇẋẏẑ','ar-x-psbidi':'∀ԐↃpƎɟפHIſӼ˥WNOԀÒᴚS⊥∩ɅMXʎZ[\\]ᵥ_,ɐqɔpǝɟƃɥıɾʞʅɯuodbɹsʇnʌʍxʎz'};const mods={'fr-x-psaccent':val=>val.replace(reVowels,match=>match+match.toLowerCase()),'ar-x-psbidi':val=>val.replace(reWords,match=>''+match+'')};const replaceChars=(map,val)=>val.replace(reAlphas,match=>map.charAt(match.charCodeAt(0)-65));const transform=val=>replaceChars(charMaps[id],mods[id](val));const apply=(fn,val)=>{if(!val){return val;}
-const parts=val.split(reExcluded);const modified=parts.map(function(part){if(reExcluded.test(part)){return part;}
-return fn(part);});return modified.join('');};return _pseudo={name:transform(name),process:str=>apply(transform,str)};};}
-const pseudo=Object.defineProperties(Object.create(null),{'fr-x-psaccent':{enumerable:true,get:createGetter('fr-x-psaccent','Runtime Accented')},'ar-x-psbidi':{enumerable:true,get:createGetter('ar-x-psbidi','Runtime Bidi')}});let Env=(function(){function Env(fetchResource){_classCallCheck(this,Env);this.fetchResource=fetchResource;this.resCache=new Map();this.resRefs=new Map();this.numberFormatters=null;this.parsers={properties:PropertiesParser,l20n:L20nParser};const listeners={};this.emit=emit.bind(this,listeners);this.addEventListener=addEventListener.bind(this,listeners);this.removeEventListener=removeEventListener.bind(this,listeners);}
-_createClass(Env,[{key:'createContext',value:function createContext(langs,resIds){const ctx=new Context(this,langs,resIds);resIds.forEach(resId=>{const usedBy=this.resRefs.get(resId)||0;this.resRefs.set(resId,usedBy+1);});return ctx;}},{key:'destroyContext',value:function destroyContext(ctx){ctx.resIds.forEach(resId=>{const usedBy=this.resRefs.get(resId)||0;if(usedBy>1){return this.resRefs.set(resId,usedBy-1);}
-this.resRefs.delete(resId);this.resCache.forEach((val,key)=>key.startsWith(resId)?this.resCache.delete(key):null);});}},{key:'_parse',value:function _parse(syntax,lang,data){const parser=this.parsers[syntax];if(!parser){return data;}
-const emit=(type,err)=>this.emit(type,amendError(lang,err));return parser.parse.call(parser,emit,data);}},{key:'_create',value:function _create(lang,entries){if(lang.src!=='pseudo'){return entries;}
-const pseudoentries=Object.create(null);for(let key in entries){pseudoentries[key]=walkEntry(entries[key],pseudo[lang.code].process);}
-return pseudoentries;}},{key:'_getResource',value:function _getResource(lang,res){const cache=this.resCache;const id=res+lang.code+lang.src;if(cache.has(id)){return cache.get(id);}
-const syntax=res.substr(res.lastIndexOf('.')+1);const saveEntries=data=>{const entries=this._parse(syntax,lang,data);cache.set(id,this._create(lang,entries));};const recover=err=>{err.lang=lang;this.emit('fetcherror',err);cache.set(id,err);};const langToFetch=lang.src==='pseudo'?{code:'en-US',src:'app',ver:lang.ver}:lang;const resource=this.fetchResource(res,langToFetch).then(saveEntries,recover);cache.set(id,resource);return resource;}}]);return Env;})();function amendError(lang,err){err.lang=lang;return err;}
-function prioritizeLocales(def,availableLangs,requested){let supportedLocale;for(let i=0;i({code:code,src:getLangSource(appVersion,availableLangs,additionalLangs,code),ver:appVersion}));return{langs,haveChanged:!arrEqual(prevLangs,newLangs)};}
-function arrEqual(arr1,arr2){return arr1.length===arr2.length&&arr1.every((elem,i)=>elem===arr2[i]);}
-function getMatchingLangpack(appVersion,langpacks){for(let i=0,langpack;langpack=langpacks[i];i++){if(langpack.target===appVersion){return langpack;}}
-return null;}
-function getLangSource(appVersion,availableLangs,additionalLangs,code){if(additionalLangs&&additionalLangs[code]){const lp=getMatchingLangpack(appVersion,additionalLangs[code]);if(lp&&(!(code in availableLangs)||parseInt(lp.revision)>availableLangs[code])){return'extra';}}
-if(code in pseudo&&!(code in availableLangs)){return'pseudo';}
-return'app';}
-let Remote=(function(){function Remote(fetchResource,broadcast){_classCallCheck(this,Remote);this.broadcast=broadcast;this.env=new Env(fetchResource);this.ctxs=new Map();}
-_createClass(Remote,[{key:'registerView',value:function registerView(view,resources,meta,additionalLangs,requestedLangs){const{langs}=negotiateLanguages(meta,additionalLangs,[],requestedLangs);this.ctxs.set(view,this.env.createContext(langs,resources));return langs;}},{key:'unregisterView',value:function unregisterView(view){this.ctxs.delete(view);return true;}},{key:'formatEntities',value:function formatEntities(view,keys){return this.ctxs.get(view).formatEntities(...keys);}},{key:'formatValues',value:function formatValues(view,keys){return this.ctxs.get(view).formatValues(...keys);}},{key:'changeLanguages',value:function changeLanguages(view,meta,additionalLangs,requestedLangs){const oldCtx=this.ctxs.get(view);const prevLangs=oldCtx.langs;const newLangs=negotiateLanguages(meta,additionalLangs,prevLangs,requestedLangs);this.ctxs.set(view,this.env.createContext(newLangs.langs,oldCtx.resIds));return newLangs;}},{key:'requestLanguages',value:function requestLanguages(requestedLangs){this.broadcast('languageschangerequest',requestedLangs);}},{key:'getName',value:function getName(code){return pseudo[code].name;}},{key:'processString',value:function processString(code,str){return pseudo[code].process(str);}}]);return Remote;})();const observerConfig={attributes:true,characterData:false,childList:true,subtree:true,attributeFilter:['data-l10n-id','data-l10n-args']};const observers=new WeakMap();function initMutationObserver(view){observers.set(view,{roots:new Set(),observer:new MutationObserver(mutations=>translateMutations(view,mutations))});}
-function translateRoots(view){return Promise.all([...observers.get(view).roots].map(root=>_translateFragment(view,root)));}
-function observe(view,root){const obs=observers.get(view);if(obs){obs.roots.add(root);obs.observer.observe(root,observerConfig);}}
-function disconnect(view,root,allRoots){const obs=observers.get(view);if(obs){obs.observer.disconnect();if(allRoots){return;}
-obs.roots.delete(root);obs.roots.forEach(other=>obs.observer.observe(other,observerConfig));}}
-function reconnect(view){const obs=observers.get(view);if(obs){obs.roots.forEach(root=>obs.observer.observe(root,observerConfig));}}
-const reOverlay=/<|?\w+;/;const allowed={elements:['a','em','strong','small','s','cite','q','dfn','abbr','data','time','code','var','samp','kbd','sub','sup','i','b','u','mark','ruby','rt','rp','bdi','bdo','span','br','wbr'],attributes:{global:['title','aria-label','aria-valuetext','aria-moz-hint'],a:['download'],area:['download','alt'],input:['alt','placeholder'],menuitem:['label'],menu:['label'],optgroup:['label'],option:['label'],track:['label'],img:['alt'],textarea:['placeholder'],th:['abbr']}};function overlayElement(element,translation){const value=translation.value;if(typeof value==='string'){if(!reOverlay.test(value)){element.textContent=value;}else{const tmpl=element.ownerDocument.createElement('template');tmpl.innerHTML=value;overlay(element,tmpl.content);}}
-for(let key in translation.attrs){const attrName=camelCaseToDashed(key);if(isAttrAllowed({name:attrName},element)){element.setAttribute(attrName,translation.attrs[key]);}}}
-function overlay(sourceElement,translationElement){const result=translationElement.ownerDocument.createDocumentFragment();let k,attr;let childElement;while(childElement=translationElement.childNodes[0]){translationElement.removeChild(childElement);if(childElement.nodeType===childElement.TEXT_NODE){result.appendChild(childElement);continue;}
-const index=getIndexOfType(childElement);const sourceChild=getNthElementOfType(sourceElement,childElement,index);if(sourceChild){overlay(sourceChild,childElement);result.appendChild(sourceChild);continue;}
-if(isElementAllowed(childElement)){const sanitizedChild=childElement.ownerDocument.createElement(childElement.nodeName);overlay(sanitizedChild,childElement);result.appendChild(sanitizedChild);continue;}
-result.appendChild(translationElement.ownerDocument.createTextNode(childElement.textContent));}
-sourceElement.textContent='';sourceElement.appendChild(result);if(translationElement.attributes){for(k=0,attr;attr=translationElement.attributes[k];k++){if(isAttrAllowed(attr,sourceElement)){sourceElement.setAttribute(attr.name,attr.value);}}}}
-function isElementAllowed(element){return allowed.elements.indexOf(element.tagName.toLowerCase())!==-1;}
-function isAttrAllowed(attr,element){const attrName=attr.name.toLowerCase();const tagName=element.tagName.toLowerCase();if(allowed.attributes.global.indexOf(attrName)!==-1){return true;}
-if(!allowed.attributes[tagName]){return false;}
-if(allowed.attributes[tagName].indexOf(attrName)!==-1){return true;}
-if(tagName==='input'&&attrName==='value'){const type=element.type.toLowerCase();if(type==='submit'||type==='button'||type==='reset'){return true;}}
-return false;}
-function getNthElementOfType(context,element,index){let nthOfType=0;for(let i=0,child;child=context.children[i];i++){if(child.nodeType===child.ELEMENT_NODE&&child.tagName===element.tagName){if(nthOfType===index){return child;}
-nthOfType++;}}
-return null;}
-function getIndexOfType(element){let index=0;let child;while(child=element.previousElementSibling){if(child.tagName===element.tagName){index++;}}
-return index;}
-function camelCaseToDashed(string){if(string==='ariaValueText'){return'aria-valuetext';}
-return string.replace(/[A-Z]/g,function(match){return'-'+match.toLowerCase();}).replace(/^-/,'');}
-const reHtml=/[&<>]/g;const htmlEntities={'&':'&','<':'<','>':'>'};function setAttributes(element,id,args){element.setAttribute('data-l10n-id',id);if(args){element.setAttribute('data-l10n-args',JSON.stringify(args));}}
-function getAttributes(element){return{id:element.getAttribute('data-l10n-id'),args:JSON.parse(element.getAttribute('data-l10n-args'))};}
-function getTranslatables(element){const nodes=Array.from(element.querySelectorAll('[data-l10n-id]'));if(typeof element.hasAttribute==='function'&&element.hasAttribute('data-l10n-id')){nodes.push(element);}
-return nodes;}
-function translateMutations(view,mutations){const targets=new Set();for(let mutation of mutations){switch(mutation.type){case'attributes':targets.add(mutation.target);break;case'childList':for(let addedNode of mutation.addedNodes){if(addedNode.nodeType===addedNode.ELEMENT_NODE){if(addedNode.childElementCount){getTranslatables(addedNode).forEach(targets.add.bind(targets));}else{if(addedNode.hasAttribute('data-l10n-id')){targets.add(addedNode);}}}}
-break;}}
-if(targets.size===0){return;}
-translateElements(view,Array.from(targets));}
-function _translateFragment(view,frag){return translateElements(view,getTranslatables(frag));}
-function getElementsTranslation(view,elems){const keys=elems.map(elem=>{const id=elem.getAttribute('data-l10n-id');const args=elem.getAttribute('data-l10n-args');return args?[id,JSON.parse(args.replace(reHtml,match=>htmlEntities[match]))]:id;});return view.formatEntities(...keys);}
-function translateElements(view,elements){return getElementsTranslation(view,elements).then(translations=>applyTranslations(view,elements,translations));}
-function applyTranslations(view,elems,translations){disconnect(view,null,true);for(let i=0;i{document.addEventListener('readystatechange',function onrsc(){document.removeEventListener('readystatechange',onrsc);resolve();});});}
-function getDirection(code){const tag=code.split('-')[0];return['ar','he','fa','ps','ur'].indexOf(tag)>=0?'rtl':'ltr';}
-if(navigator.languages===undefined){navigator.languages=[navigator.language];}
-function getResourceLinks(head){return Array.prototype.map.call(head.querySelectorAll('link[rel="localization"]'),el=>el.getAttribute('href'));}
-function getMeta(head){let availableLangs=Object.create(null);let defaultLang=null;let appVersion=null;const metas=Array.from(head.querySelectorAll('meta[name="availableLanguages"],'+'meta[name="defaultLanguage"],'+'meta[name="appVersion"]'));for(let meta of metas){const name=meta.getAttribute('name');const content=meta.getAttribute('content').trim();switch(name){case'availableLanguages':availableLangs=getLangRevisionMap(availableLangs,content);break;case'defaultLanguage':const[lang,rev]=getLangRevisionTuple(content);defaultLang=lang;if(!(lang in availableLangs)){availableLangs[lang]=rev;}
-break;case'appVersion':appVersion=content;}}
-return{defaultLang,availableLangs,appVersion};}
-function getLangRevisionMap(seq,str){return str.split(',').reduce((seq,cur)=>{const[lang,rev]=getLangRevisionTuple(cur);seq[lang]=rev;return seq;},seq);}
-function getLangRevisionTuple(str){const[lang,rev]=str.trim().split(':');return[lang,parseInt(rev)];}
-const viewProps=new WeakMap();let View=(function(){function View(client,doc){_classCallCheck(this,View);this.pseudo={'fr-x-psaccent':createPseudo(this,'fr-x-psaccent'),'ar-x-psbidi':createPseudo(this,'ar-x-psbidi')};const initialized=documentReady().then(()=>init(this,client));this._interactive=initialized.then(()=>client);this.ready=initialized.then(langs=>translateView(this,langs));initMutationObserver(this);viewProps.set(this,{doc:doc,ready:false});client.on('languageschangerequest',requestedLangs=>this.requestLanguages(requestedLangs));}
-_createClass(View,[{key:'requestLanguages',value:function requestLanguages(requestedLangs,isGlobal){const method=isGlobal?client=>client.method('requestLanguages',requestedLangs):client=>changeLanguages(this,client,requestedLangs);return this._interactive.then(method);}},{key:'handleEvent',value:function handleEvent(){return this.requestLanguages(navigator.languages);}},{key:'formatEntities',value:function formatEntities(...keys){return this._interactive.then(client=>client.method('formatEntities',client.id,keys));}},{key:'formatValue',value:function formatValue(id,args){return this._interactive.then(client=>client.method('formatValues',client.id,[[id,args]])).then(values=>values[0]);}},{key:'formatValues',value:function formatValues(...keys){return this._interactive.then(client=>client.method('formatValues',client.id,keys));}},{key:'translateFragment',value:function translateFragment(frag){return _translateFragment(this,frag);}},{key:'observeRoot',value:function observeRoot(root){observe(this,root);}},{key:'disconnectRoot',value:function disconnectRoot(root){disconnect(this,root);}}]);return View;})();View.prototype.setAttributes=setAttributes;View.prototype.getAttributes=getAttributes;function createPseudo(view,code){return{getName:()=>view._interactive.then(client=>client.method('getName',code)),processString:str=>view._interactive.then(client=>client.method('processString',code,str))};}
-function init(view,client){const doc=viewProps.get(view).doc;const resources=getResourceLinks(doc.head);const meta=getMeta(doc.head);view.observeRoot(doc.documentElement);return getAdditionalLanguages().then(additionalLangs=>client.method('registerView',client.id,resources,meta,additionalLangs,navigator.languages));}
-function changeLanguages(view,client,requestedLangs){const doc=viewProps.get(view).doc;const meta=getMeta(doc.head);return getAdditionalLanguages().then(additionalLangs=>client.method('changeLanguages',client.id,meta,additionalLangs,requestedLangs)).then(({langs,haveChanged})=>haveChanged?translateView(view,langs):undefined);}
-function getAdditionalLanguages(){if(navigator.mozApps&&navigator.mozApps.getAdditionalLanguages){return navigator.mozApps.getAdditionalLanguages().catch(()=>Object.create(null));}
-return Promise.resolve(Object.create(null));}
-function translateView(view,langs){const props=viewProps.get(view);const html=props.doc.documentElement;if(props.ready){return translateRoots(view).then(()=>setAllAndEmit(html,langs));}
-const translated=langs[0].code===html.getAttribute('lang')?Promise.resolve():translateRoots(view).then(()=>setLangDir(html,langs));return translated.then(()=>{setLangs(html,langs);props.ready=true;});}
-function setLangs(html,langs){const codes=langs.map(lang=>lang.code);html.setAttribute('langs',codes.join(' '));}
-function setLangDir(html,langs){const code=langs[0].code;html.setAttribute('lang',code);html.setAttribute('dir',getDirection(code));}
-function setAllAndEmit(html,langs){setLangDir(html,langs);setLangs(html,langs);html.parentNode.dispatchEvent(new CustomEvent('DOMRetranslated',{bubbles:false,cancelable:false}));}
-const remote=new Remote(fetchResource,broadcast);const client=new Client(remote);document.l10n=new View(client,document);window.addEventListener('languagechange',document.l10n);document.addEventListener('additionallanguageschange',document.l10n);navigator.mozL10n={setAttributes:document.l10n.setAttributes,getAttributes:document.l10n.getAttributes,formatValue:(...args)=>document.l10n.formatValue(...args),translateFragment:(...args)=>document.l10n.translateFragment(...args),once:cb=>document.l10n.ready.then(cb),ready:cb=>document.l10n.ready.then(()=>{document.addEventListener('DOMRetranslated',cb);cb();})};})();;(function(global){'use strict';const helperCache=new Map();const knownObjects={datetime:{create:function(options){const customOptions=Object.assign({},options);if(options.hour){customOptions.hour12=navigator.mozHour12;}
-return Intl.DateTimeFormat(navigator.languages,customOptions);},isAffected:function(obj,reason){if(['languagechange','moztimechange'].includes(reason)){return true;}
-if(reason==='timeformatchange'){return'hour'in obj.options;}
-return false;}},mozdatetime:{create:function(options){const customOptions=Object.assign({},options);if(options.hour){customOptions.hour12=navigator.mozHour12;}
-return mozIntl.DateTimeFormat(navigator.languages,customOptions);},isAffected:function(obj,reason){if(['languagechange','moztimechange'].includes(reason)){return true;}
-if(reason==='timeformatchange'){return'hour'in obj.options;}
-return false;}},mozduration:{create:function(options){return mozIntl.DurationFormat(navigator.languages,options);},isAffected:function(obj,reason){return reason==='languagechange';}},number:{create:function(options){return Intl.NumberFormat(navigator.languages,options);},isAffected:function(obj,reason){return reason==='languagechange';}},collator:{create:function(options){return Intl.Collator(navigator.languages,options);},isAffected:function(obj,reason){return reason==='languagechange';}},};global.IntlHelper={define:function(name,type,options){if(!knownObjects.hasOwnProperty(type)){throw new Error('Unknown type: '+type);}
-if(helperCache.has(name)){throw new Error('Intl Object with name "'+name+'" already exists');}
-helperCache.set(name,{options,type,intlObject:undefined,listeners:new Set()});},get:function(name){if(!helperCache.has(name)){throw new Error('Intl Object with name "'+name+'" is not defined');}
-const obj=helperCache.get(name);if(!knownObjects.hasOwnProperty(obj.type)){throw new Error('Unknown type: '+obj.type);}
-if(obj.intlObject===undefined){obj.intlObject=knownObjects[obj.type].create(obj.options);}
-return obj.intlObject;},observe:function(names,cb){if(typeof names==='string'){names=[names];}
-for(var name of names){if(!helperCache.has(name)){throw new Error('Intl Object with name "'+name+'" is not defined');}
-const obj=helperCache.get(name);obj.listeners.add(cb);}},unobserve:function(name,cb){const obj=helperCache.get(name);obj.listeners.delete(cb);},_fireObservers:function(affectedObjects,...args){const affectedCallbacks=new Set();affectedObjects.forEach(obj=>obj.listeners.forEach(cb=>affectedCallbacks.add(cb)));affectedCallbacks.forEach(cb=>{try{cb(...args);}catch(e){console.error('Error in callback: '+e.toString());console.error(e.stack);}});},handleEvent:function(evt){const affectedObjects=resetObjects(evt.type);if(evt.type==='languagechange'&&navigator.mozL10n){waitOnDOMLocalized(()=>global.IntlHelper._fireObservers(affectedObjects));}else{global.IntlHelper._fireObservers(affectedObjects);}},_resetObjectCache:function(){helperCache.clear();}};function waitOnDOMLocalized(cb){const resolver=()=>{document.removeEventListener('DOMRetranslated',resolver);window.removeEventListener('localized',resolver);cb();};document.addEventListener('DOMRetranslated',resolver,false);window.addEventListener('localized',resolver,false);}
-function resetObjects(reason){const affectedObjects=new Set();helperCache.forEach((obj,name)=>{if(knownObjects[obj.type].isAffected(obj,reason)){obj.intlObject=undefined;affectedObjects.add(obj);}});return affectedObjects;}
-window.addEventListener('timeformatchange',global.IntlHelper,false);window.addEventListener('languagechange',global.IntlHelper,false);window.addEventListener('moztimechange',global.IntlHelper,false);})(this);;'use strict';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;}}}
-if(typeof this.queries[type]!=='undefined'){return this.queries[type].matches;}
-return false;},watch:function sl_watch(name,media){var mediaString=media||this.queries[name].media;if(!mediaString){return;}
-this.unwatch(name);this.queries[name]=window.matchMedia(mediaString);this.boundHandleChange=this.handleChange.bind(this);this.queries[name].addListener(this.boundHandleChange);},unwatch:function sl_unwatch(name){if(this.queries[name]){this.queries[name].removeListener(this.boundHandleChange);}},handleChange:function sl_handleChange(evt){for(var key in this.queries){if(this.queries[key].media!==evt.media){continue;}
-window.dispatchEvent(new CustomEvent('screenlayoutchange',{detail:{name:key,status:evt.matches}}));}}};ScreenLayout.init();;(function(root,factory){'use strict';if(typeof define==='function'&&define.amd){define(factory);}else if(typeof exports==='object'){module.exports=factory();}else{root.Sanitizer=factory();}}(this,function(){'use strict';var Sanitizer={_entity:/[&<>"'/]/g,_entities:{'&':'&','<':'<','>':'>','"':'"','\'':''','/':'/'},getEntity:function(s){return Sanitizer._entities[s];},escapeHTML:function(strings,...values){var result='';for(var i=0;i>16);if(oldClientVersion===0){oldDbVersion=2;oldClientVersion=oldVersion/oldDbVersion;}
-if(0===db.objectStoreNames.length){createObjectStores(db);}else{handleUpgrade(db,transaction,oldDbVersion,oldClientVersion);}};openRequest.onsuccess=function(e){media.db=openRequest.result;media.db.onerror=function(event){console.error('MediaDB: ',event.target.error&&event.target.error.name);};changeState(media,MediaDB.ENUMERABLE);var cursorRequest=media.db.transaction('files','readonly').objectStore('files').index('date').openCursor(null,'prev');cursorRequest.onerror=function(){console.error('MediaDB initialization error',cursorRequest.error);};cursorRequest.onsuccess=function(){var cursor=cursorRequest.result;if(cursor){media.details.newestFileModTime=cursor.value.date;}
-else{media.details.newestFileModTime=0;}
-initDeviceStorage();};};function createObjectStores(db){var filestore=db.createObjectStore('files',{keyPath:'name'});filestore.createIndex('date','date');media.indexes.forEach(function(indexName){if(indexName==='name'||indexName==='date'){return;}
-filestore.createIndex(indexName,indexName);});}
-function enumerateOldFiles(store,callback){var openCursorReq=store.openCursor();openCursorReq.onsuccess=function(){var cursor=openCursorReq.result;if(cursor){callback(cursor.value);cursor.continue();}};}
-function handleUpgrade(db,trans,oldDbVersion,oldClientVersion){media.state=MediaDB.UPGRADING;var evtDetail={'oldMediaDBVersion':oldDbVersion,'oldClientVersion':oldClientVersion,'newMediaDBVersion':MediaDB.VERSION,'newClientVersion':media.version};dispatchEvent(media,'upgrading',evtDetail);var store=trans.objectStore('files');if(media.version!=oldClientVersion){upgradeIndexesChanges(store);}
-var clientUpgradeNeeded=(media.version!=oldClientVersion)&&media.updateRecord;if((2!=oldDbVersion||3!=MediaDB.VERSION)&&!clientUpgradeNeeded){return;}
-enumerateOldFiles(store,function doUpgrade(dbfile){if(2==oldDbVersion&&3==MediaDB.VERSION){upgradeDBVer2to3(store,dbfile);}
-if(clientUpgradeNeeded){handleClientUpgrade(store,dbfile,oldClientVersion);}});}
-function upgradeIndexesChanges(store){var dbIndexes=store.indexNames;var clientIndexes=media.indexes;for(var i=0;i=details.storages.length){setupHandlers();return;}
-var s=details.storages[next++];var name=s.storageName;var req=s.available();req.onsuccess=function(e){details.availability[name]=req.result;getNextAvailability();};req.onerror=function(e){details.availability[name]='unavailable';getNextAvailability();};}}
-function setupHandlers(){for(var i=0;i0){return MediaDB.UNMOUNTED;}
-if(u===n){return MediaDB.NOCARD;}
-return MediaDB.READY;}
-function changeHandler(e){switch(e.reason){case'modified':case'deleted':fileChangeHandler(e);return;case'available':case'unavailable':case'shared':volumeChangeHandler(e);return;default:return;}}
-function volumeChangeHandler(e){var storageName=e.target.storageName;if(details.availability[storageName]===e.reason){return;}
-var oldState=media.state;details.availability[storageName]=e.reason;var newState=getState(details.availability);if(newState!==oldState){changeState(media,newState);if(newState===MediaDB.READY){if(media.autoscan){scan(media);}}
-else{endscan(media);}}
-else if(newState===MediaDB.READY){if(e.reason==='available'){dispatchEvent(media,'ready');if(media.autoscan){scan(media);}}
-else if(e.reason==='unavailable'){dispatchEvent(media,'cardremoved');deleteAllFiles(storageName);}}}
-function fileChangeHandler(e){var filename=e.path;if(ignoreName(media,filename)){return;}
-if(e.reason==='modified'){insertRecord(media,filename);}else{deleteRecord(media,filename);}}
-function deleteAllFiles(storageName){var storagePrefix=storageName?'/'+storageName+'/':'';var store=media.db.transaction('files').objectStore('files');var cursorRequest=store.openCursor();cursorRequest.onsuccess=function(){var cursor=cursorRequest.result;if(cursor){if(cursor.value.name.startsWith(storagePrefix)){deleteRecord(media,cursor.value.name);}
-cursor.continue();}};}}}
-MediaDB.prototype={close:function close(){this.db.close();for(var i=0;i0){media.details.firstscan=false;options={since:new Date(timestamp+1)};}
-else{media.details.firstscan=true;media.details.records=[];options={};}
-scanDS(media.details.storages,null,options).then(processFiles).catch(handleScanError);function processFiles(files){if(!media.scanning){return;}
-files=files.sort(function(a,b){return b.lastModifiedDate-a.lastModifiedDate;});for(var i=0;idbfile.name){deleteRecord(media,dbfile.name);dbindex++;continue;}
-console.error('Assertion failed');}
-insertRecord(media,null);}}
-function scanDS(storage,directory,options){if(Array.isArray(storage)){return Promise.all(storage.map((s)=>scanDS(s,directory,options))).then(function(arrays){return Array.prototype.concat.apply([],arrays);});}
-return new Promise(function(resolve,reject){var files=[];var cursor=storage.enumerate(directory||'',options||{});cursor.onerror=function(){if(cursor.error.name==='NotFoundError'){resolve(files);}
-else{reject(cursor.error);}};cursor.onsuccess=function(){var result=cursor.result;if(result){if(!ignore(media,result)){files.push(result);}
-cursor.continue();}
-else{resolve(files);}};});}}
-function endscan(media){if(media.scanning){media.initialScanComplete=true;media.scanning=false;media.parsingBigFiles=false;dispatchEvent(media,'scanend');}}
-function insertRecord(media,fileOrName,oldMetadata){var details=media.details;details.pendingInsertions.push([fileOrName,oldMetadata]);if(details.processingQueue){return;}
-processQueue(media);}
-function deleteRecord(media,filename){var details=media.details;details.pendingDeletions.push(filename);if(details.processingQueue){return;}
-processQueue(media);}
-function whenDoneProcessing(media,f){var details=media.details;if(details.processingQueue){details.whenDoneProcessing.push(f);}else{f();}}
-function processQueue(media){var details=media.details;details.processingQueue=true;next();function next(){if(details.pendingDeletions.length>0){deleteFiles();}
-else if(details.pendingInsertions.length>0){insertFile(...details.pendingInsertions.shift());}
-else{details.processingQueue=false;if(details.whenDoneProcessing.length>0){var functions=details.whenDoneProcessing;details.whenDoneProcessing=[];functions.forEach(function(f){f();});}}}
-function deleteFiles(){var transaction=media.db.transaction('files','readwrite');var store=transaction.objectStore('files');deleteNextFile();function deleteNextFile(){if(details.pendingDeletions.length===0){next();return;}
-var filename=details.pendingDeletions.shift();var request=store.delete(filename);request.onerror=function(){console.warn('MediaDB: Unknown file in deleteRecord:',filename,request.error);deleteNextFile();};request.onsuccess=function(){queueDeleteNotification(media,filename);deleteNextFile();};}}
-function insertFile(f,oldMetadata){if(f===null){sendNotifications(media);endscan(media);next();return;}
-if(typeof f==='string'){var storage=navigator.getDeviceStorage(media.mediaType);var getreq=storage.get(f);getreq.onerror=function(){console.warn('MediaDB: Unknown file in insertRecord:',f,getreq.error);next();};getreq.onsuccess=function(){if(media.mimeTypes&&ignore(media,getreq.result)){next();}else{parseMetadata(getreq.result,f,oldMetadata);}};}
-else{parseMetadata(f,f.name,oldMetadata);}}
-function parseMetadata(file,filename,oldMetadata){if(!file.lastModifiedDate){console.warn('MediaDB: parseMetadata: no lastModifiedDate for',filename,'using Date.now() until #793955 is fixed');}
-var fileinfo={name:filename,type:file.type,size:file.size,date:file.lastModifiedDate?file.lastModifiedDate.getTime():Date.now()};if(fileinfo.date>details.newestFileModTime){details.newestFileModTime=fileinfo.date;}
-media.metadataParser(file,gotMetadata,metadataError,parsingBigFile);function parsingBigFile(){media.parsingBigFiles=true;}
-function metadataError(e){console.warn('MediaDB: error parsing metadata for',filename,':',e);fileinfo.fail=true;storeRecord(fileinfo);}
-function gotMetadata(metadata){fileinfo.metadata=metadata;if(oldMetadata&&media.reparsedRecord){fileinfo.metadata=media.reparsedRecord(oldMetadata,metadata);}
-storeRecord(fileinfo);if(!media.scanning){media.parsingBigFiles=false;}}}
-function storeRecord(fileinfo){if(media.details.firstscan){media.details.records.push(fileinfo);if(!fileinfo.fail){queueCreateNotification(media,fileinfo);}
-next();}
-else{var transaction=media.db.transaction('files','readwrite');var store=transaction.objectStore('files');var request=store.add(fileinfo);request.onsuccess=function(){if(!fileinfo.fail){queueCreateNotification(media,fileinfo);}
-next();};request.onerror=function(event){if(request.error.name==='ConstraintError'){event.stopPropagation();event.preventDefault();var putrequest=store.put(fileinfo);putrequest.onsuccess=function(){queueDeleteNotification(media,fileinfo.name);if(!fileinfo.fail){queueCreateNotification(media,fileinfo);}
-next();};putrequest.onerror=function(){console.error('MediaDB: unexpected ConstraintError','in insertRecord for file:',fileinfo.name);next();};}
-else{console.error('MediaDB: unexpected error in insertRecord:',request.error,'for file:',fileinfo.name);next();}};}}}
-function queueCreateNotification(media,fileinfo){var creates=media.details.pendingCreateNotifications;creates.push(fileinfo);if(media.batchSize&&creates.length>=media.batchSize){sendNotifications(media);}else{resetNotificationTimer(media);}}
-function queueDeleteNotification(media,filename){var deletes=media.details.pendingDeleteNotifications;deletes.push(filename);if(media.batchSize&&deletes.length>=media.batchSize){sendNotifications(media);}else{resetNotificationTimer(media);}}
-function resetNotificationTimer(media){var details=media.details;if(details.pendingNotificationTimer){clearTimeout(details.pendingNotificationTimer);}
-details.pendingNotificationTimer=setTimeout(function(){sendNotifications(media);},media.scanning?media.batchHoldTime:100);}
-function sendNotifications(media){var details=media.details;if(details.pendingNotificationTimer){clearTimeout(details.pendingNotificationTimer);details.pendingNotificationTimer=null;}
-if(details.pendingDeleteNotifications.length>0){var deletions=details.pendingDeleteNotifications;details.pendingDeleteNotifications=[];dispatchEvent(media,'deleted',deletions);}
-if(details.pendingCreateNotifications.length>0){var creations=details.pendingCreateNotifications;details.pendingCreateNotifications=[];if(details.firstscan&&details.records.length>0){var transaction=media.db.transaction('files','readwrite');var store=transaction.objectStore('files');for(var i=0;i=1024&&i<(units.length-1)){size/=1024;++i;}
-var sizeDecimal=i<2?Math.round(size):Math.round(size*10)/10;return document.l10n.formatValue('byteUnit-'+units[i]).then((unit)=>{return{size:sizeDecimal,unit};});},getLocalizedSize:function(size){return this.getLocalizedSizeTokens(size).then((args)=>{return document.l10n.formatValue('fileSize',args);});},formatDuration:function(duration){function padLeft(num,length){var r=String(num);while(r.lengthblob.size){fail('offset larger than blob size');}
-if(offset+length>blob.size){length=blob.size-offset;}
-var slice=blob.slice(offset,offset+length);var reader=new FileReader();reader.readAsArrayBuffer(slice);reader.onloadend=function(){var result=null;if(reader.result){result=new BlobView(blob,offset,length,reader.result,0,length,littleEndian||false);}
-callback(result,reader.error);};};BlobView.getFromArrayBuffer=function(buffer,offset,length,littleEndian){return new BlobView(null,offset,length,buffer,offset,length,littleEndian);};BlobView.prototype={constructor:BlobView,getMore:function(offset,length,callback){if(!this.blob){fail('no blob backing this BlobView');}
-if(offset>=this.sliceOffset&&offset+length<=this.sliceOffset+this.sliceLength){callback(new BlobView(this.blob,this.sliceOffset,this.sliceLength,this.slice,offset-this.sliceOffset,length,this.littleEndian));}
-else{BlobView.get(this.blob,offset,length,callback,this.littleEndian);}},littleEndian:function(){this.littleEndian=true;},bigEndian:function(){this.littleEndian=false;},getUint8:function(offset){return this.view.getUint8(offset);},getInt8:function(offset){return this.view.getInt8(offset);},getUint16:function(offset,le){return this.view.getUint16(offset,le!==undefined?le:this.littleEndian);},getInt16:function(offset,le){return this.view.getInt16(offset,le!==undefined?le:this.littleEndian);},getUint32:function(offset,le){return this.view.getUint32(offset,le!==undefined?le:this.littleEndian);},getInt32:function(offset,le){return this.view.getInt32(offset,le!==undefined?le:this.littleEndian);},getFloat32:function(offset,le){return this.view.getFloat32(offset,le!==undefined?le:this.littleEndian);},getFloat64:function(offset,le){return this.view.getFloat64(offset,le!==undefined?le:this.littleEndian);},readByte:function(){return this.view.getInt8(this.index++);},readUnsignedByte:function(){return this.view.getUint8(this.index++);},readShort:function(le){var val=this.view.getInt16(this.index,le!==undefined?le:this.littleEndian);this.index+=2;return val;},readUnsignedShort:function(le){var val=this.view.getUint16(this.index,le!==undefined?le:this.littleEndian);this.index+=2;return val;},readInt:function(le){var val=this.view.getInt32(this.index,le!==undefined?le:this.littleEndian);this.index+=4;return val;},readUnsignedInt:function(le){var val=this.view.getUint32(this.index,le!==undefined?le:this.littleEndian);this.index+=4;return val;},readFloat:function(le){var val=this.view.getFloat32(this.index,le!==undefined?le:this.littleEndian);this.index+=4;return val;},readDouble:function(le){var val=this.view.getFloat64(this.index,le!==undefined?le:this.littleEndian);this.index+=8;return val;},tell:function(){return this.index;},remaining:function(){return this.byteLength-this.index;},seek:function(index){if(index<0){fail('negative index');}
-if(index>this.byteLength){fail('index greater than buffer size');}
-this.index=index;},advance:function(n){var index=this.index+n;if(index<0){fail('advance past beginning of buffer');}
-if(index>this.byteLength){fail('advance past end of buffer');}
-this.index=index;},getUnsignedByteArray:function(offset,n){return new Uint8Array(this.buffer,offset+this.viewOffset,n);},readUnsignedByteArray:function(n){var val=new Uint8Array(this.buffer,this.index+this.viewOffset,n);this.index+=n;return val;},getBit:function(offset,bit){var byte=this.view.getUint8(offset);return(byte&(1<=blob.size){if(handlers.eofHandler){handlers.eofHandler();}
-return;}
-else{data.getMore(offset,16,parseAtom);}}}
-MP4Parser(blob,{errorHandler:function(msg){rotationCallback(msg);},eofHandler:function(){rotationCallback(null);},defaultHandler:'skip',moov:'children',trak:'children',tkhd:function(data){data.advance(48);var a=data.readUnsignedInt();var b=data.readUnsignedInt();data.advance(4);var c=data.readUnsignedInt();var d=data.readUnsignedInt();if(a===0&&d===0){if(b===0x00010000&&c===0xFFFF0000){rotationCallback(90);}
-else if(b===0xFFFF0000&&c===0x00010000){rotationCallback(270);}
-else{rotationCallback('unexpected rotation matrix');}}
-else if(b===0&&c===0){if(a===0x00010000&&d===0x00010000){rotationCallback(0);}
-else if(a===0xFFFF0000&&d===0xFFFF0000){rotationCallback(180);}
-else{rotationCallback('unexpected rotation matrix');}}
-else{rotationCallback('unexpected rotation matrix');}
-return'done';}});};(function(exports){'use strict';function ManifestHelper_get(prop){var manifest=this;var value=manifest[prop];var lang=document.documentElement.lang||'';if(manifest.locales){[lang,lang.substr(0,lang.indexOf('-'))].some(function tryLanguage(lang){if(this[lang]&&this[lang][prop]){value=this[lang][prop];return true;}},manifest.locales);}
-if(typeof value==='object'&&!(value instanceof Array)){value=new ManifestHelper(value);}
-return value;}
-function ManifestHelper(manifest){for(var prop in manifest){Object.defineProperty(this,prop,{get:ManifestHelper_get.bind(manifest,prop),enumerable:true});}}
-Object.defineProperty(ManifestHelper.prototype,'displayName',{get:function displayName(){return this.short_name||this.name;}});exports.ManifestHelper=ManifestHelper;}(window));;'use strict';var VideoStats=(function(){var startQuality;var startTime;var endQuality;var endTime;var video;function start(v){video=v;if(!video.getVideoPlaybackQuality){return;}
-startQuality=video.getVideoPlaybackQuality();startTime=video.currentTime;endQuality=null;endTime=null;}
-function stop(){if(!video){dump('Warning: no stats have been recorded. '+'Make sure start() has been called');return;}
-if(!video.getVideoPlaybackQuality){return;}
-endQuality=video.getVideoPlaybackQuality();endTime=video.currentTime;}
-function print(){if(!video){dump('Warning: no stats have been recorded. '+'Make sure start() has been called');return;}
-if(!video.getVideoPlaybackQuality){return;}
-if(!startQuality||!endQuality){dump('Warning: no stats have been recorded. '+'Make sure start() and stop() have been called');return;}
-var segmentDuration=(endQuality.creationTime-startQuality.creationTime)/1000;var startRendered=startQuality.totalVideoFrames-startQuality.droppedVideoFrames;var endRendered=endQuality.totalVideoFrames-endQuality.droppedVideoFrames;var segmentRendered=endRendered-startRendered;var fps=segmentRendered/segmentDuration;dump('Video statistics start');dump('Dimensions: '+video.videoWidth+'x'+video.videoHeight);dump('Complete duration: '+MediaUtils.formatDuration(video.duration));dump('Start time: '+MediaUtils.formatDuration(startTime));dump('End time: '+MediaUtils.formatDuration(endTime));dump('Total frames: '+endQuality.totalVideoFrames);dump('Decoded frames: '+
-(endQuality.totalVideoFrames-endQuality.corruptedVideoFrames));dump('Corrupted frames: '+endQuality.corruptedVideoFrames);dump('Rendered frames: '+
-(endQuality.totalVideoFrames-endQuality.droppedVideoFrames));dump('Dropped frames: '+endQuality.droppedVideoFrames);dump('Average rendered FPS: '+fps.toFixed(2));dump('Video statistics end');}
-return{start:start,stop:stop,dump:print};})();;'use strict';var LazyLoader=(function(){function LazyLoader(){this._loaded={};this._isLoading={};}
-LazyLoader.prototype={_js:function(file,callback){var script=document.createElement('script');script.src=file;script.async=false;script.addEventListener('load',callback);document.head.appendChild(script);this._isLoading[file]=script;},_css:function(file,callback){var style=document.createElement('link');style.type='text/css';style.rel='stylesheet';style.href=file;document.head.appendChild(style);callback();},_html:function(domNode,callback){if(domNode.getAttribute('is')){this.load(['/shared/js/html_imports.js'],function(){HtmlImports.populate(callback);}.bind(this));return;}
-for(var i=0;i{deferred.resolve=resolve;});if(!Array.isArray(files)){files=[files];}
-var loadsRemaining=files.length,self=this;function perFileCallback(file){if(self._isLoading[file]){delete self._isLoading[file];}
-self._loaded[file]=true;if(--loadsRemaining===0){deferred.resolve();if(callback){callback();}}}
-for(var i=0;ispace&&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)});;'use strict';window.GaiaMenu=(function(win){var proto=Object.create(HTMLElement.prototype);var baseurl=window.GaiaMenuBaseurl||'/shared/elements/gaia_menu/';proto.createdCallback=function(){var shadow=this.createShadowRoot();this._template=template.content.cloneNode(true);var cancelButton=this._template.querySelector('.gaia-menu-cancel button');cancelButton.addEventListener('click',function(){this.hide();this.dispatchEvent(new CustomEvent('gaiamenu-cancel'));}.bind(this));shadow.appendChild(this._template);ComponentUtils.style.call(this,baseurl);document.l10n.ready.then(this.localize.bind(this));document.addEventListener('DOMRetranslated',this.localize.bind(this));};proto.localize=function(){document.l10n.formatValue('gaia-menu-cancel').then(value=>{this.shadowRoot.querySelector('button').textContent=value;});};proto.show=function(){this.removeAttribute('hidden');};proto.hide=function(){this.setAttribute('hidden','hidden');};var template=document.createElement('template');template.innerHTML=``;return document.registerElement('gaia-menu',{prototype:proto});})(window);;'use strict';var metadataQueue=[];var processingQueue=false;var stopParsingMetadataCallback=null;var noMoreWorkCallback=null;function addToMetadataQueue(fileinfo){metadataQueue.push(fileinfo);startParsingMetadata();}
-function startParsingMetadata(){if(processingQueue){return;}
-if(metadataQueue.length===0){if(noMoreWorkCallback){noMoreWorkCallback();noMoreWorkCallback=null;}
-return;}
-if(document.hidden||playerShowing){return;}
-if(videodb.state!==MediaDB.READY){return;}
-processingQueue=true;showThrobber();processFirstQueuedItem();}
-function stopParsingMetadata(callback){if(!processingQueue){if(callback){callback();}
-return;}
-stopParsingMetadataCallback=callback||true;}
-function processFirstQueuedItem(){if(stopParsingMetadataCallback){var callback=stopParsingMetadataCallback;stopParsingMetadataCallback=null;processingQueue=false;hideThrobber();if(callback!==true){callback();}
-return;}
-if(metadataQueue.length===0){processingQueue=false;hideThrobber();updateDialog();if(typeof(noMoreWorkCallback)==='function'){noMoreWorkCallback();}
-return;}
-var fileinfo=metadataQueue.shift();videodb.getFile(fileinfo.name,function(file){getMetadata(file,function(metadata){fileinfo.metadata=metadata;videodb.updateMetadata(fileinfo.name,metadata,function(){if(metadata.isVideo){videodb.getFileInfo(fileinfo.name,function(dbfileinfo){addVideo(dbfileinfo);});}});setTimeout(processFirstQueuedItem);});},function(err){console.error('getFile error: ',fileinfo.name,err);processFirstQueuedItem();});}
-function getMetadata(videofile,callback){var offscreenVideo=document.createElement('video');var metadata={};if(!offscreenVideo.canPlayType(videofile.type)){metadata.isVideo=false;callback(metadata);return;}
-var url=URL.createObjectURL(videofile);offscreenVideo.preload='metadata';offscreenVideo.src=url;const METADATA_TIMEOUT=3000;var timeoutId=setTimeout(function(){console.error('No loadedmetadata or error events seen yet.','Assuming video file is corrupt:',videofile.name);offscreenVideo.onerror();},METADATA_TIMEOUT);offscreenVideo.onerror=function(e){console.error('Can\'t play video',videofile.name,e);metadata.isVideo=false;clearTimeout(timeoutId);unload();callback(metadata);};offscreenVideo.onloadedmetadata=function(){clearTimeout(timeoutId);if(!offscreenVideo.videoWidth){metadata.isVideo=false;unload();callback(metadata);return;}
-metadata.isVideo=true;metadata.title=readFromMetadata('title')||fileNameToVideoName(videofile.name);metadata.duration=offscreenVideo.duration;metadata.width=offscreenVideo.videoWidth;metadata.height=offscreenVideo.videoHeight;if(/.3gp$/.test(videofile.name)){getVideoRotation(videofile,function(rotation){if(typeof rotation==='number'){metadata.rotation=rotation;}
-else if(typeof rotation==='string'){console.warn('Video rotation:',rotation);}
-createThumbnail();});}else{metadata.rotation=0;createThumbnail();}};function readFromMetadata(lowerCaseKey){var tags=offscreenVideo.mozGetMetadata();for(var key in tags){if(key.toLowerCase()===lowerCaseKey){return tags[key];}}
-return;}
-function createThumbnail(){var t=Math.min(5,offscreenVideo.duration/10);offscreenVideo.fastSeek(t);var failed=false;var timeout=setTimeout(fail,10000);offscreenVideo.onerror=fail;function fail(){console.warn('Seek failed while creating thumbnail for',videofile.name,'. Ignoring corrupt file.');failed=true;clearTimeout(timeout);offscreenVideo.onerror=null;metadata.isVideo=false;unload();callback(metadata);}
-offscreenVideo.onseeked=function(){if(failed){return;}
-clearTimeout(timeout);captureFrame(offscreenVideo,metadata,function(poster){metadata.poster=poster;unload();callback(metadata);});};}
-function unload(){URL.revokeObjectURL(offscreenVideo.src);offscreenVideo.removeAttribute('src');offscreenVideo.load();}
-function fileNameToVideoName(filename){filename=filename.split('/').pop().replace(/\.(webm|ogv|ogg|mp4|3gp)$/,'').replace(/[_\.]/g,' ');return filename.charAt(0).toUpperCase()+filename.slice(1);}}
-function captureFrame(player,metadata,callback){try{var canvas=document.createElement('canvas');canvas.width=THUMBNAIL_WIDTH;canvas.height=THUMBNAIL_HEIGHT;var ctx=canvas.getContext('2d',{willReadFrequently:true});var vw=player.videoWidth,vh=player.videoHeight;var tw,th;switch(metadata.rotation){case 90:ctx.translate(THUMBNAIL_WIDTH,0);ctx.rotate(Math.PI/2);tw=THUMBNAIL_HEIGHT;th=THUMBNAIL_WIDTH;break;case 180:ctx.translate(THUMBNAIL_WIDTH,THUMBNAIL_HEIGHT);ctx.rotate(Math.PI);tw=THUMBNAIL_WIDTH;th=THUMBNAIL_HEIGHT;break;case 270:ctx.translate(0,THUMBNAIL_HEIGHT);ctx.rotate(-Math.PI/2);tw=THUMBNAIL_HEIGHT;th=THUMBNAIL_WIDTH;break;default:tw=THUMBNAIL_WIDTH;th=THUMBNAIL_HEIGHT;break;}
-var scale=Math.min(tw/vw,th/vh),w=scale*vw,h=scale*vh,x=(tw-w)/2/scale,y=(th-h)/2/scale;ctx.scale(scale,scale);ctx.drawImage(player,x,y);canvas.toBlob(done,'image/jpeg');}
-catch(e){console.error('Exception in captureFrame:',e,e.stack);done(null);}
-function done(blob){canvas.width=0;ctx=canvas=null;callback(blob);}};'use strict';function initDB(){videodb=new MediaDB('videos',null,{excludeFilter:/DCIM\/\d{3}MZLLA\/\.VID_\d{4}\.(?:3gp|mp4|webm)$/});videodb.onupgrading=function(evt){storageState=MediaDB.UPGRADING;updateDialog();};videodb.onunavailable=function(event){storageState=event.detail;if(playerShowing){hidePlayer(true);}
-updateDialog();};videodb.oncardremoved=function(){if(playerShowing){hidePlayer(true);}};videodb.onscanend=function(){if(!firstScanEnded){firstScanEnded=true;window.performance.mark('fullyLoaded');}
-updateDialog();updateLoadingSpinner();};videodb.oncreated=function(event){event.detail.forEach(videoCreated);};videodb.ondeleted=function(event){event.detail.forEach(videoDeleted);};videodb.onenumerable=function(){storageState=false;updateDialog();enumerateDB();};videodb.onready=function(){storageState=false;updateDialog();startParsingMetadata();};}
-var enumerated=false;function enumerateDB(){if(enumerated){return;}
-enumerated=true;var firstBatchDisplayed=false;var batch=[];var batchSize=4;videodb.enumerate('date',null,'prev',function(videoinfo){if(videoinfo===null){flush();return;}
-var isVideo=videoinfo.metadata.isVideo;if(isVideo===false){return;}
-if(isVideo===undefined){addToMetadataQueue(videoinfo);return;}
-if(isVideo===true){batch.push(videoinfo);if(batch.length>=batchSize){flush();batchSize*=2;}}});function flush(){batch.forEach(addVideo);batch.length=0;if(!firstBatchDisplayed){firstBatchDisplayed=true;window.performance.mark('visuallyLoaded');window.performance.mark('contentInteractive');setTimeout(function(){var headers=document.querySelectorAll('gaia-header');for(var i=0;i1){currentThumbnail=this.itemGroups[1].thumbnails[0];}else{currentThumbnail=null;}}else{var thumbnails=this.itemGroups[idx-1].thumbnails;currentThumbnail=thumbnails[thumbnails.length-1];}}else{idx=currentGroup.thumbnails.indexOf(currentThumbnail);if(idx===0){currentThumbnail=currentGroup.thumbnails[1];}else{currentThumbnail=currentGroup.thumbnails[idx-1];}}
-return currentThumbnail;};ThumbnailList.prototype.localize=function(){this.itemGroups.forEach(function(group){group.localize();});};;'use strict';function ThumbnailDateGroup(item){if(!item){throw new Error('item should not be null or undefined.');}
-this.thumbnails=[];this.groupID=ThumbnailDateGroup.getGroupID(item);this.date=item.date;var wrappedHtmlText=ThumbnailDateGroup.view();var dummyDiv=document.createElement('DIV');dummyDiv.innerHTML=Sanitizer.unwrapSafeHTML(wrappedHtmlText);var domNode=dummyDiv.firstElementChild;if(!domNode){throw new Error('the template is empty');}
-this.htmlNode=domNode;this.container=domNode.querySelector('.thumbnail-group-container');this.header=domNode.querySelector('.thumbnail-group-header');this.localize();}
-ThumbnailDateGroup.view=function(){return Sanitizer.createSafeHTML`
-
-
- `;};ThumbnailDateGroup.getGroupID=function(item){var dateObj=new Date(item.date);var month=dateObj.getMonth()+1;return'group_'+dateObj.getFullYear()+'-'+
-(month<10?'0'+month:month);};ThumbnailDateGroup.compareGroupID=function(id1,id2){return id1>id2?1:(id1_this.thumbnails[0].data.date){return 0;}
-else if(thumbnail.data.date<_this.thumbnails[_this.thumbnails.length-1].data.date){return _this.thumbnails.length;}
-else{return MediaUtils.binarySearch(_this.thumbnails,thumbnail,function(a,b){return b.data.date-a.data.date;});}}
-var thumbnail=new ThumbnailItem(item);var insertPosition=getInsertPosition(thumbnail);this.container.insertBefore(thumbnail.htmlNode,this.container.children[insertPosition]);this.thumbnails.splice(insertPosition,0,thumbnail);return thumbnail;};ThumbnailDateGroup.prototype.getCount=function(){return this.thumbnails.length;};ThumbnailDateGroup.prototype.removeItem=function(thumbnail){var idx=this.thumbnails.indexOf(thumbnail);if(idx<0){return;}
-this.thumbnails.splice(idx,1);this.container.removeChild(thumbnail.htmlNode);};ThumbnailDateGroup.prototype.localize=function(){var date=new Date(this.date);var formatter=IntlHelper.get('date-group');this.header.textContent=formatter.format(date);this.thumbnails.forEach(function(thumbnail){thumbnail.localize();});};;'use strict';function ThumbnailItem(videoData){if(!videoData){throw new Error('videoData should not be null or undefined.');}
-this.posterNode=null;this.unwatchedNode=null;this.detailNode=null;this.titleNode=null;this.htmlNode=null;this.tapListeners=[];this.data=videoData;var _this=this;render();function convertToDOM(wrappedHTML){var dummyDiv=document.createElement('div');dummyDiv.innerHTML=Sanitizer.unwrapSafeHTML(wrappedHTML);var domNode=dummyDiv.firstElementChild;if(!domNode){throw new Error('the template does not contain any element');}
-_this.htmlNode=domNode;_this.posterNode=domNode.querySelector('.img');_this.detailNode=domNode.querySelector('.details');_this.titleNode=domNode.querySelector('.title');_this.unwatchedNode=domNode.querySelector('.unwatched');_this.sizeNode=domNode.querySelector('.size-text');}
-function render(){var duration='';if(isFinite(_this.data.metadata.duration)){duration=MediaUtils.formatDuration(_this.data.metadata.duration);}
-var videoType='';if(_this.data.type){var pos=_this.data.type.indexOf('/');videoType=(pos>-1?_this.data.type.slice(pos+1):_this.data.type);}
-var wrappedHtmlText=ThumbnailItem.view({'title':_this.data.metadata.title,'durationText':duration,'typeText':videoType});convertToDOM(wrappedHtmlText);_this.updatePoster(_this.data.metadata.bookmark||_this.data.metadata.poster);_this.setWatched(_this.data.metadata.watched);if(_this.detailNode){_this.detailNode.dataset.title=_this.data.metadata.title;}
-_this.htmlNode.addEventListener('click',dispatchClick);_this.localize();}
-function dispatchClick(){_this.tapListeners.forEach(function(listener){if(listener.handleEvent){listener.handleEvent(_this.data);}else if((typeof listener)==='function'){listener(_this.data);}});}}
-ThumbnailItem.titleMaxLines=2;ThumbnailItem.view=function({title,durationText,typeText}){return Sanitizer.createSafeHTML`
-
-
-
-
- ${title}
- ${durationText}
-
-
- ${typeText}
-
-
-
- `;};ThumbnailItem.prototype.addTapListener=function(listener){if(!listener){return;}
-this.tapListeners[this.tapListeners.length]=listener;};ThumbnailItem.prototype.removeTapListener=function(listener){if(!listener){return;}
-var idx=this.tapListeners.indexOf(listener);if(idx>-1){this.tapListeners.splice(idx,1);}};ThumbnailItem.prototype.setWatched=function(watched){if(!this.unwatchedNode){return;}
-this.unwatchedNode.hidden=watched;};ThumbnailItem.prototype.updatePoster=function(imageblob){if(!this.posterNode){return;}
-if(this.posterNode.dataset.uri){URL.revokeObjectURL(this.posterNode.dataset.uri);}
-if(imageblob){this.posterNode.classList.remove('default');var imageUri=URL.createObjectURL(imageblob);this.posterNode.dataset.uri=imageUri;this.posterNode.src=imageUri;}else{this.posterNode.classList.add('default');this.posterNode.dataset.uri='';this.posterNode.src='style/images/default_thumbnail.png';}};ThumbnailItem.prototype.updateTitleText=function(){this.titleNode.textContent=VideoUtils.getTruncated(this.data.metadata.title,{node:this.titleNode,maxLine:ThumbnailItem.titleMaxLines});};ThumbnailItem.prototype.localize=function(){if(this.sizeNode&&isFinite(this.data.size)){MediaUtils.getLocalizedSizeTokens(this.data.size).then((args)=>{document.l10n.setAttributes(this.sizeNode,'fileSize',args);});}};;'use strict';var VideoUtils={fitContainer:function(container,player,videoRotation){var containerWidth=container.clientWidth;var containerHeight=container.clientHeight;if(!player.videoWidth||!player.videoHeight){return;}
-var width,height;var rotation=videoRotation||0;switch(rotation){case 0:case 180:width=player.videoWidth;height=player.videoHeight;break;case 90:case 270:width=player.videoHeight;height=player.videoWidth;}
-var xscale=containerWidth/width;var yscale=containerHeight/height;var scale=Math.min(xscale,yscale);width*=scale;height*=scale;var left=((containerWidth-width)/2);var top=((containerHeight-height)/2);var transform;switch(rotation){case 0:transform='translate('+left.toFixed(4)+'px,'+
-top.toFixed(4)+'px)';break;case 90:transform='translate('+(left+width).toFixed(4)+'px,'+
-top.toFixed(4)+'px) '+'rotate(90deg)';break;case 180:transform='translate('+(left+width).toFixed(4)+'px,'+
-(top+height).toFixed(4)+'px) '+'rotate(180deg)';break;case 270:transform='translate('+left.toFixed(4)+'px,'+
-(top+height).toFixed(4)+'px) '+'rotate(270deg)';break;}
-transform+=' scale('+scale.toFixed(4)+')';player.style.transform=transform;},getTruncated:function(oldName,options){var maxLine=options.maxLine||2;var node=options.node;var ellipsisCharacter=options.ellipsisCharacter||'...';if(node===null){return oldName;}
-var realVisibility=node.style.visibility;var realWordBreak=node.style.wordBreak;node.style.visibility='hidden';node.style.wordBreak='break-all';node.textContent='.';var baseHeight=node.clientHeight;var computedStyle=window.getComputedStyle(node,null);var paddingTop=parseInt(computedStyle.getPropertyValue('padding-top'),10);var paddingBottom=parseInt(computedStyle.getPropertyValue('padding-bottom'),10);var padding=paddingTop+paddingBottom;var lineHeight=baseHeight-padding;node.textContent=oldName;var originalTitleHeight=node.clientHeight-padding;var linesForOriginalTitle=(originalTitleHeight/lineHeight);if(linesForOriginalTitle<=maxLine){node.style.visibility=realVisibility;node.style.wordBreak=realWordBreak;return oldName;}
-function titleTooLong(pos){node.textContent=oldName.slice(0,pos)+ellipsisCharacter;var tempTitleHeight=node.clientHeight-padding;return(tempTitleHeight/lineHeight)>maxLine;}
-var low=0;var high=oldName.length-1;var mid;var ellipsisIndex=0;do{mid=Math.floor((low+high)/2);if(titleTooLong(mid)){high=mid-1;}
-else{ellipsisIndex=mid;low=mid+1;}}
-while(low<=high);var newName=oldName.slice(0,ellipsisIndex)+ellipsisCharacter;node.style.visibility=realVisibility;node.style.wordBreak=realWordBreak;return newName;}};;'use strict';var ForwardRewindController=(function(){var isLongPressing=false;var intervalId=null;var forwardButton;var backwardButton;var videoToolbar;var player;var seekHelper;function init(video,forward,backward,seeker){forwardButton=forward;backwardButton=backward;player=video;seekHelper=seeker;videoToolbar=forward.parentElement;videoToolbar.addEventListener('click',handleButtonClick);videoToolbar.addEventListener('contextmenu',handlePlayerLongPress);videoToolbar.addEventListener('touchend',stopFastSeeking);}
-function uninit(video,forward,backward){videoToolbar.removeEventListener('click',handleButtonClick);videoToolbar.removeEventListener('contextmenu',handlePlayerLongPress);videoToolbar.removeEventListener('touchend',stopFastSeeking);forwardButton=null;backwardButton=null;player=null;}
-function handleButtonClick(event){if(event.target===forwardButton){startFastSeeking(1);}else if(event.target===backwardButton){startFastSeeking(-1);}else{return;}}
-function handlePlayerLongPress(event){if(event.target===forwardButton){isLongPressing=true;startFastSeeking(1);}else if(event.target===backwardButton){isLongPressing=true;startFastSeeking(-1);}else{return;}}
-function startFastSeeking(direction){var offset=direction*10;if(isLongPressing){intervalId=window.setInterval(function(){seekVideo(player.currentTime+offset);},1000);}else{seekVideo(player.currentTime+offset);}}
-function stopFastSeeking(){if(intervalId){window.clearInterval(intervalId);intervalId=null;isLongPressing=false;}}
-function seekVideo(seekTime){if(seekTime>=player.duration||seekTime<0){if(isLongPressing){stopFastSeeking();}
-if(seekTime>=player.duration){seekTime=player.duration;pause();}
-else{seekTime=0;}}
-if(seekHelper){seekHelper.seekTo(seekTime);}
-else{player.fastSeek(seekTime);}}
-return{init:init,uninit:uninit,stopFastSeeking:stopFastSeeking};}());;(function(exports){'use strict';exports.VideoLoadingChecker=VideoLoadingChecker;function VideoLoadingChecker(player,overlay,overlayTitle,overlayText){this.player=player;this.overlay=overlay;this.overlayTitle=overlayTitle;this.overlayText=overlayText;}
-VideoLoadingChecker.prototype={ensureVideoLoads:function vpc_ensureVideoLoads(callback){var self=this;var videoLoadedTimeoutId=setTimeout(showOverlay.bind(null,this),1500);this.player.addEventListener('loadedmetadata',handleLoadedMetadata);function handleLoadedMetadata(){clearTimeout(videoLoadedTimeoutId);videoLoadedTimeoutId=null;self.player.removeEventListener('loadedmetadata',handleLoadedMetadata);hideOverlay(self);if(callback){callback();}}},ensureVideoPlays:function(){this.playingTimeout=setTimeout(showOverlay.bind(null,this),1500);this.playingListener=this.cancelEnsureVideoPlays.bind(this);this.player.addEventListener('timeupdate',this.playingListener);},cancelEnsureVideoPlays:function(){hideOverlay(this);if(this.playingTimeout){clearTimeout(this.playingTimeout);this.playingTimeout=null;}
-if(this.playingListener){this.player.removeEventListener('timeupdate',this.playingListener);this.playingListener=null;}}};function showOverlay(checker){checker.overlayTitle.setAttribute('data-l10n-id','video-hardware-in-use-title');checker.overlayText.setAttribute('data-l10n-id','video-hardware-in-use-text');checker.overlay.classList.remove('hidden');document.body.classList.add('in-use-overlay');}
-function hideOverlay(checker){document.body.classList.remove('in-use-overlay');checker.overlay.classList.add('hidden');}}(window));;(function(exports){'use strict';function Seeker(player){this.player=player;this.seekStartedAt=null;this.currentSeekTime=null;this.nextSeekTime=null;this.player.addEventListener('seeked',this);}
-Seeker.SEEK_INTERVAL=200;Seeker.SEEK_DISTANCE=10;Seeker.prototype.seekTo=function(time){if(this.currentSeekTime===time){return;}
-var now=performance.now();if(this.currentSeekTime===null||time===0||Math.abs(this.currentSeekTime-time)>Seeker.SEEK_DISTANCE||now-this.seekStartedAt>Seeker.SEEK_INTERVAL)
-{this.player.fastSeek(time);this.seekStartedAt=now;this.currentSeekTime=time;this.nextSeekTime=null;}
-else{this.nextSeekTime=time;}};Seeker.prototype.handleEvent=function(){this.currentSeekTime=null;this.seekStartedAt=null;if(this.nextSeekTime!==null){this.seekTo(this.nextSeekTime);}};exports.Seeker=Seeker;}(window));;(function(exports){'use strict';function Captions(player){this.player=player;}
-Captions.prototype.remove=function(){for(var track of this.player.querySelectorAll('track')){if(track.src&&track.src.startsWith('blob:')){URL.revokeObjectURL(track.src);}
-track.remove();}};Captions.prototype.findAndDisplay=function(filepath){if(!filepath){return;}
-var basename=filepath.substring(0,filepath.lastIndexOf('.'));var captionsFilename=basename+'.vtt';var sdcard=navigator.getDeviceStorage('sdcard');if(!sdcard)return;var request=sdcard.get(captionsFilename);request.onsuccess=function(){var track=document.createElement('track');track.src=URL.createObjectURL(request.result);track.default=true;this.player.appendChild(track);track.track.mode='showing';}.bind(this);};exports.Captions=Captions;}(window));;'use strict';const LAYOUT_MODE={list:'layout-list',selection:'layout-selection',fullscreenPlayer:'layout-fullscreen-player'};var dom={};var ids=['thumbnail-list-title','thumbnails','thumbnails-video-button','thumbnails-select-button','thumbnails-delete-button','thumbnails-share-button','thumbnails-select-top','thumbnails-number-selected','player-view','fullscreen-button','spinner-overlay','thumbnails-single-delete-button','thumbnails-single-share-button','thumbnails-single-info-button','info-view','info-close-button','player','overlay','overlay-title','overlay-text','overlay-menu','overlay-action-button','player-header','video-container','videoBar','videoControlBar','close','play','playHead','timeSlider','elapsedTime','video-title','duration-text','elapsed-text','bufferedTime','slider-wrapper','throbber','picker-close','picker-title','picker-header','picker-done','options','options-view','seek-backward','seek-forward','in-use-overlay','in-use-overlay-title','in-use-overlay-text'];ids.forEach(function createElementRef(name){dom[toCamelCase(name)]=document.getElementById(name);});dom.player.mozAudioChannelType='content';function $(id){return document.getElementById(id);}
-var seeker=new Seeker(dom.player);var captions=new Captions(dom.player);var playing=false;var playerShowing=false;var controlShowing=false;var controlFadeTimeout=null;var selectedFileNames=[];var selectedFileNamesToBlobs={};var videodb;var currentVideo;var currentVideoBlob;var firstScanEnded=false;var THUMBNAIL_WIDTH;var THUMBNAIL_HEIGHT;var HAVE_NOTHING=0;var storageState;var currentOverlay;var dragging=false;var touchStartID=null;var sliderRect;var thumbnailList;var pendingPick;var videoHardwareReleased=false;var restoreTime=null;var isPhone;var isPortrait;var currentLayoutMode;var pendingUpdateTitleText=false;var FROMCAMERA=/DCIM\/\d{3}MZLLA\/VID_\d{4}\.(?:3gp|mp4|webm)$/;var loadingChecker=new VideoLoadingChecker(dom.player,dom.inUseOverlay,dom.inUseOverlayTitle,dom.inUseOverlayText);var lastPlayTime=0;init();dom.player.addEventListener('play',function(){lastPlayTime=window.performance.now();});dom.player.addEventListener('mozinterruptbegin',function(){if((window.performance.now()-lastPlayTime)>50){pause();lastPlayTime=0;}});document.addEventListener('visibilitychange',function visibilityChange(){if(document.hidden){stopParsingMetadata();if(playing){pause();}}
-else{if(playerShowing){if(videoHardwareReleased){restoreVideo();}
-setControlsVisibility(true);}else{startParsingMetadata();}}});var acm=navigator.mozAudioChannelManager;if(acm){acm.addEventListener('headphoneschange',function onheadphoneschange(){if(!acm.headphones&&playing){setVideoPlaying(false);}});}
-navigator.mozSetMessageHandler('activity',handleActivityEvents);function init(){window.performance.mark('navigationLoaded');IntlHelper.define('date-group','datetime',{month:'long',year:'numeric',});thumbnailList=new ThumbnailList(ThumbnailDateGroup,dom.thumbnails);ThumbnailItem.titleMaxLines=isPhone?2:(isPortrait?4:2);initDB();addUIEventListeners();initLayout();initThumbnailSize();function addUIEventListeners(){if(!navigator.mozHasPendingMessage('activity')){initOptionsButtons();}
-document.l10n.ready.then(function(){initPlayerControls();});ForwardRewindController.init(dom.player,dom.seekForward,dom.seekBackward,seeker);dom.overlayActionButton.addEventListener('click',function(){if(pendingPick){cancelPick();}else if(currentOverlay==='empty'){launchCameraApp();}});window.performance.mark('navigationInteractive');}}
-function initThumbnailSize(){if(isPhone){THUMBNAIL_WIDTH=210*window.devicePixelRatio;THUMBNAIL_HEIGHT=120*window.devicePixelRatio;}else{var shortEdge=Math.min(window.innerWidth,window.innerHeight);THUMBNAIL_WIDTH=424*window.devicePixelRatio*shortEdge/800;THUMBNAIL_HEIGHT=Math.round(THUMBNAIL_WIDTH*4/7);}}
-function initLayout(){ScreenLayout.watch('portrait','(orientation: portrait)');isPhone=ScreenLayout.getCurrentLayout('tiny');isPortrait=ScreenLayout.getCurrentLayout('portrait');if(isPhone||isPortrait){dom.spinnerOverlay.classList.add('hidden');setDisabled(dom.playerView,false);}else{dom.spinnerOverlay.classList.remove('hidden');setDisabled(dom.playerView,true);}
-window.addEventListener('screenlayoutchange',handleScreenLayoutChange);switchLayout(LAYOUT_MODE.list);}
-function initPlayerControls(){dom.sliderWrapper.addEventListener('touchstart',handleSliderTouchStart);dom.sliderWrapper.addEventListener('touchmove',handleSliderTouchMove);dom.sliderWrapper.addEventListener('touchend',handleSliderTouchEnd);dom.player.addEventListener('timeupdate',timeUpdated);dom.player.addEventListener('seeked',updateVideoControlSlider);dom.player.addEventListener('ended',playerEnded);dom.play.addEventListener('click',handlePlayButtonClick);dom.playerHeader.addEventListener('action',handleCloseButtonClick);dom.pickerDone.addEventListener('click',postPickResult);dom.options.addEventListener('click',showOptionsView);dom.videoContainer.addEventListener('click',toggleVideoControls);dom.timeSlider.addEventListener('keypress',handleSliderKeypress);}
-function initOptionsButtons(){dom.thumbnailsVideoButton.addEventListener('click',launchCameraApp);dom.thumbnailsSelectButton.addEventListener('click',showSelectView);dom.thumbnailsSelectTop.addEventListener('action',hideSelectView);dom.thumbnailsDeleteButton.addEventListener('click',deleteSelectedItems);dom.thumbnailsShareButton.addEventListener('click',shareSelectedItems);dom.infoCloseButton.addEventListener('click',hideInfoView);dom.optionsView.addEventListener('gaiamenu-cancel',hideOptionsView);dom.fullscreenButton.addEventListener('click',toggleFullscreenPlayer);addEventListeners('.single-delete-button','click',deleteCurrentVideo);addEventListeners('.single-share-button','click',shareCurrentVideo);addEventListeners('.single-info-button','click',showInfoView);}
-function addEventListeners(selector,type,listener){var elements=document.body.querySelectorAll(selector);for(var i=0;i-1?currentVideo.type.slice(index+1):currentVideo.type;}
-var resolution=(currentVideo.metadata.width&¤tVideo.metadata.height)?currentVideo.metadata.width+'x'+
-currentVideo.metadata.height:'';return MediaUtils.getLocalizedSizeTokens(currentVideo.size).then((args)=>{var data={'info-name':{raw:currentVideo.metadata.title},'info-length':{raw:length},'info-size':{id:'fileSize',args:args},'info-type':{raw:type},'info-date':{raw:MediaUtils.formatDate(currentVideo.date)},'info-resolution':{raw:resolution}};MediaUtils.populateMediaInfo(data);setNFCSharing(false);dom.infoView.classList.remove('hidden');document.body.classList.add('info-view');});}
-function hideInfoView(){setNFCSharing(true);dom.infoView.classList.add('hidden');document.body.classList.remove('info-view');}
-function showSelectView(){hidePlayer(true,function(){switchLayout(LAYOUT_MODE.selection);thumbnailList.setSelectMode(true);clearSelection();});}
-function hideSelectView(){clearSelection();thumbnailList.setSelectMode(false);switchLayout(LAYOUT_MODE.list);if(!isPhone&&!isPortrait&¤tVideo){showPlayer(currentVideo,false,false,true);}}
-function showOptionsView(){if(playing){pause();}
-dom.optionsView.removeAttribute('hidden');document.body.classList.add('options-view');}
-function hideOptionsView(){dom.optionsView.setAttribute('hidden',true);document.body.classList.remove('options-view');}
-function setDisabled(element,disabled){element.classList.toggle('disabled',disabled);element.setAttribute('aria-disabled',disabled);}
-function setSelected(element,selected){element.classList.toggle('selected',selected);element.setAttribute('aria-selected',selected);}
-function clearSelection(){Array.forEach(selectedFileNames,function(name){setSelected(thumbnailList.thumbnailMap[name].htmlNode,false);});selectedFileNames=[];selectedFileNamesToBlobs={};setDisabled(dom.thumbnailsDeleteButton,true);setDisabled(dom.thumbnailsShareButton,true);document.l10n.setAttributes(dom.thumbnailsNumberSelected,'number-selected2',{n:0});}
-function updateSelection(videodata){var thumbnail=thumbnailList.thumbnailMap[videodata.name];var selected=!thumbnail.htmlNode.classList.contains('selected');setSelected(thumbnail.htmlNode,selected);var filename=videodata.name;if(selected){selectedFileNames.push(filename);videodb.getFile(filename,function(blob){selectedFileNamesToBlobs[filename]=blob;});}
-else{delete selectedFileNamesToBlobs[filename];var i=selectedFileNames.indexOf(filename);if(i!==-1){selectedFileNames.splice(i,1);}}
-var numSelected=selectedFileNames.length;document.l10n.setAttributes(dom.thumbnailsNumberSelected,'number-selected2',{n:numSelected});var noneSelected=numSelected===0;setDisabled(dom.thumbnailsDeleteButton,noneSelected);setDisabled(dom.thumbnailsShareButton,noneSelected);}
-function launchCameraApp(){var a=new MozActivity({name:'record',data:{type:'videos'}});a.onerror=function(){if(a.error.name==='NO_PROVIDER'){document.l10n.formatValue('share-noprovider').then((msg)=>{alert(msg);});}else{console.warn('share activity error:',a.error.name);}};}
-function resetCurrentVideo(){if(!currentVideo){return;}
-var currentThumbnail=thumbnailList.thumbnailMap[currentVideo.name];currentThumbnail.htmlNode.classList.remove('focused');var nextThumbnail=thumbnailList.findNextThumbnail(currentVideo.name);if(nextThumbnail){currentVideo=nextThumbnail.data;nextThumbnail.htmlNode.classList.add('focused');}else{currentVideo=null;}}
-function deleteSelectedItems(){if(selectedFileNames.length===0){return;}
-LazyLoader.load('shared/style/confirm.css',function(){document.body.classList.add('confirm-dialog');Dialogs.confirm({messageId:'delete-n-items?',messageArgs:{n:selectedFileNames.length},cancelId:'cancel',confirmId:'delete',danger:true},function(){for(var i=0;i{alert(msg);});}else{console.warn('share activity error:',a.error.name);}
-restoreVideo();};}
-function updateDialog(){if(thumbnailList.count!==0&&(!storageState||playerShowing)){showOverlay(null);}else if(storageState===MediaDB.UPGRADING){showOverlay('upgrade');}else if(storageState===MediaDB.NOCARD){showOverlay('nocard');}else if(storageState===MediaDB.UNMOUNTED){showOverlay('pluggedin');}else if(firstScanEnded&&thumbnailList.count===0&&metadataQueue.length===0){showOverlay('empty');}}
-function updateLoadingSpinner(){if(processingQueue){noMoreWorkCallback=updateLoadingSpinner;}else{window.performance.mark('scanEnd');dom.spinnerOverlay.classList.add('hidden');setDisabled(dom.playerView,false);if(thumbnailList.count){currentVideo=currentVideo||thumbnailList.itemGroups[0].thumbnails[0].data;if(!isPhone&&!isPortrait){showPlayer(currentVideo,false,false,true);}}}}
-function thumbnailClickHandler(videodata){if(!isPhone&&!isPortrait){if(!firstScanEnded||processingQueue){return;}}
-if(currentLayoutMode===LAYOUT_MODE.list){hidePlayer(true,function(){stopParsingMetadata(function(){var fullscreen=pendingPick||isPhone||isPortrait;showPlayer(videodata,!pendingPick,fullscreen,pendingPick);});});}
-else if(currentLayoutMode===LAYOUT_MODE.selection){updateSelection(videodata);}}
-function showOverlay(id){LazyLoader.load('shared/style/confirm.css',function(){currentOverlay=id;if(id===null){document.body.classList.remove('overlay');dom.overlay.classList.add('hidden');return;}
-var text,title;if(pendingPick||id==='empty'){dom.overlayMenu.classList.remove('hidden');dom.overlayActionButton.classList.remove('hidden');dom.overlayActionButton.setAttribute('data-l10n-id',pendingPick?'overlay-cancel-button':'overlay-camera-button');}else{dom.overlayMenu.classList.add('hidden');dom.overlayActionButton.classList.add('hidden');}
-if(id==='nocard'){title='nocard2-title';text='nocard3-text';}else{title=id+'-title';text=id+'-text';}
-dom.overlayTitle.setAttribute('data-l10n-id',title);dom.overlayText.setAttribute('data-l10n-id',text);dom.overlay.classList.remove('hidden');document.body.classList.add('overlay');});}
-function setControlsVisibility(visible){if(isPhone||isPortrait||currentLayoutMode!==LAYOUT_MODE.list){dom.playerView.classList[visible?'remove':'add']('video-controls-hidden');controlShowing=visible;}else{controlShowing=true;}
-dom.videoContainer.setAttribute('data-l10n-id',controlShowing?'hide-controls-button':'show-controls-button');if(controlShowing){updateVideoControlSlider();}}
-function movePlayHead(percent){if(document.documentElement.dir==='ltr'){dom.playHead.style.left=percent;}
-else{dom.playHead.style.right=percent;}}
-function updateVideoControlSlider(){var percent=(dom.player.currentTime/dom.player.duration)*100;if(isNaN(percent)){return;}
-percent+='%';dom.elapsedText.textContent=MediaUtils.formatDuration(dom.player.currentTime);dom.elapsedTime.style.width=percent;if(!dragging){movePlayHead(percent);}}
-function setVideoPlaying(playing){if(playing){play();}else{pause();}}
-function deleteCurrentVideo(){hideOptionsView();setNFCSharing(false);LazyLoader.load('shared/style/confirm.css',function(){document.body.classList.add('confirm-dialog');Dialogs.confirm({messageId:'delete-video?',cancelId:'cancel',confirmId:'delete',danger:true},function _onSuccess(){deleteFile(currentVideo.name);if(!isPhone&&!isPortrait){if(currentVideo){showPlayer(currentVideo,false,true,true);}}else{hidePlayer(false);}
-document.body.classList.remove('confirm-dialog');},function _onError(){setNFCSharing(true);document.body.classList.remove('confirm-dialog');});});}
-function handlePlayButtonClick(){setVideoPlaying(dom.player.paused);}
-function handleCloseButtonClick(){if(isPhone||isPortrait){hidePlayer(true);}else{toggleFullscreenPlayer();}}
-function postPickResult(){pendingPick.postResult({type:currentVideoBlob.type,blob:currentVideoBlob});cleanupPick();}
-function shareCurrentVideo(){hideOptionsView();videodb.getFile(currentVideo.name,function(blob){share([blob]);});}
-function handleSliderTouchStart(event){if(null!=touchStartID){return;}
-touchStartID=event.changedTouches[0].identifier;dragging=true;sliderRect=dom.sliderWrapper.getBoundingClientRect();if(dom.player.duration===Infinity){return;}
-handleSliderTouchMove(event);}
-function setVideoUrl(player,video,callback){function handleLoadedMetadata(){dom.player.onloadedmetadata=null;callback();}
-function loadVideo(url){loadingChecker.ensureVideoLoads(handleLoadedMetadata);player.src=url;}
-captions.remove();if('name'in video){videodb.getFile(video.name,function(file){captions.findAndDisplay(file.name);var url=URL.createObjectURL(file);loadVideo(url);if(pendingPick){currentVideoBlob=file;}});}else if('url'in video){loadVideo(video.url);}}
-function scheduleVideoControlsAutoHiding(){controlFadeTimeout=setTimeout(function(){setControlsVisibility(false);},250);}
-function setNFCSharing(enable){if(!window.navigator.mozNfc){return;}
-if(enable){window.navigator.mozNfc.onpeerready=function(event){videodb.getFile(currentVideo.name,function(file){event.peer.sendFile(file);});};}else{window.navigator.mozNfc.onpeerready=null;}}
-function showPlayer(video,autoPlay,enterFullscreen,keepControls){if(currentVideo){var old=thumbnailList.thumbnailMap[currentVideo.name];old.htmlNode.classList.remove('focused');}
-currentVideo=video;var thumbnail=thumbnailList.thumbnailMap[currentVideo.name];thumbnail.htmlNode.classList.add('focused');updateDialog();dom.player.preload='metadata';function doneSeeking(){dom.player.onseeked=null;setControlsVisibility(true);if(!keepControls){scheduleVideoControlsAutoHiding();}
-if(autoPlay){play();}else{pause();}
-dom.player.hidden=false;}
-dom.player.hidden=true;setVideoUrl(dom.player,currentVideo,function(){if(enterFullscreen){switchLayout(LAYOUT_MODE.fullscreenPlayer);}
-var formattedDuration=MediaUtils.formatDuration(dom.player.duration);dom.durationText.textContent=formattedDuration;timeUpdated();setButtonPaused(false);playerShowing=true;var rotation;if('metadata'in currentVideo){if(currentVideo.metadata.currentTime===dom.player.duration){currentVideo.metadata.currentTime=0;}
-dom.videoTitle.textContent=currentVideo.metadata.title;dom.player.currentTime=currentVideo.metadata.currentTime||0;rotation=currentVideo.metadata.rotation;}else{dom.videoTitle.textContent=currentVideo.title||'';dom.player.currentTime=0;rotation=0;}
-document.l10n.setAttributes(dom.timeSlider,'seek-bar',{duration:formattedDuration});dom.timeSlider.setAttribute('aria-valuemin',0);dom.timeSlider.setAttribute('aria-valuemax',dom.player.duration);dom.timeSlider.setAttribute('aria-valuenow',dom.player.currentTime);dom.timeSlider.setAttribute('aria-valuetext',MediaUtils.formatDuration(dom.player.currentTime));VideoUtils.fitContainer(dom.videoContainer,dom.player,rotation||0);if(dom.player.seeking){dom.player.onseeked=doneSeeking;}else{doneSeeking();}
-setNFCSharing(true);});}
-function hidePlayer(updateVideoMetadata,callback){if(!playerShowing){if(callback){callback();}
-return;}
-dom.player.pause();setNFCSharing(false);function completeHidingPlayer(){switchLayout(LAYOUT_MODE.list);setButtonPaused(false);playerShowing=false;updateDialog();captions.remove();dom.player.removeAttribute('src');dom.player.load();startParsingMetadata();if(callback){callback();}}
-if(!('metadata'in currentVideo)||!updateVideoMetadata||pendingPick){completeHidingPlayer();return;}
-var video=currentVideo;var thumbnail=thumbnailList.thumbnailMap[video.name];if(dom.player.currentTime===0){video.metadata.bookmark=null;updateMetadata();}
-else{captureFrame(dom.player,video.metadata,function(bookmark){video.metadata.bookmark=bookmark;updateMetadata();});}
-function updateMetadata(){thumbnail.updatePoster(video.metadata.bookmark||video.metadata.poster);if(!video.metadata.watched){video.metadata.watched=true;thumbnail.setWatched(true);}
-video.metadata.currentTime=dom.player.currentTime;videodb.updateMetadata(video.name,video.metadata);completeHidingPlayer();}}
-function playerEnded(){if(dragging){return;}
-if(playing){dom.player.currentTime=0;pause();}}
-function setButtonPaused(paused){dom.play.classList.toggle('paused',paused);dom.play.setAttribute('data-l10n-id',paused?'play-button':'pause-button');}
-function play(){loadingChecker.ensureVideoPlays();setButtonPaused(false);VideoStats.start(dom.player);dom.player.play();playing=true;}
-function pause(){loadingChecker.cancelEnsureVideoPlays();setButtonPaused(true);if(dragging){dragging=false;dom.playHead.classList.remove('active');}
-dom.player.pause();playing=false;VideoStats.stop();VideoStats.dump();}
-function timeUpdated(){if(controlShowing){if(dom.player.duration===Infinity||dom.player.duration===0){return;}
-updateVideoControlSlider();}
-dom.timeSlider.setAttribute('aria-valuenow',dom.player.currentTime);dom.timeSlider.setAttribute('aria-valuetext',MediaUtils.formatDuration(dom.player.currentTime));}
-function handleSliderTouchEnd(event){if(!event.changedTouches.identifiedTouch(touchStartID)){return;}
-touchStartID=null;if(!dragging){return;}
-dragging=false;dom.playHead.classList.remove('active');updateVideoControlSlider();if(dom.player.currentTime===dom.player.duration){pause();}}
-function handleSliderTouchMove(event){if(!dragging){return;}
-var touch=event.changedTouches.identifiedTouch(touchStartID);if(!touch){return;}
-function getTouchPos(){return(document.documentElement.dir==='ltr')?(touch.clientX-sliderRect.left):(sliderRect.right-touch.clientX);}
-var touchPos=getTouchPos();var pos=touchPos/sliderRect.width;pos=Math.max(pos,0);pos=Math.min(pos,1);var percent=pos*100+'%';dom.playHead.classList.add('active');movePlayHead(percent);dom.elapsedTime.style.width=percent;seeker.seekTo(dom.player.duration*pos);}
-function handleSliderKeypress(event){var step=Math.max(dom.player.duration/20,2);if(event.keyCode===event.DOM_VK_DOWN){seeker.seekTo(dom.player.currentTime-step);}else if(event.keyCode===event.DOM_VK_UP){seeker.seekTo(dom.player.currentTime+step);}}
-function toCamelCase(str){return str.replace(/\-(.)/g,function replacer(str,p1){return p1.toUpperCase();});}
-function showPickView(){thumbnailList.setPickMode(true);document.body.classList.add('pick-activity');dom.pickerHeader.addEventListener('action',cancelPick);if(!isPhone&&!isPortrait){thumbnailList.updateAllThumbnailTitles();}}
-function cancelPick(){pendingPick.postError('pick cancelled');cleanupPick();}
-function cleanupPick(){pendingPick=null;currentVideoBlob=null;hidePlayer(false);}
-function showThrobber(){dom.throbber.classList.remove('hidden');dom.throbber.classList.add('throb');}
-function hideThrobber(){dom.throbber.classList.remove('throb');setTimeout(function(){dom.throbber.classList.add('hidden');},100);}
-function releaseVideo(){if(videoHardwareReleased){return;}
-videoHardwareReleased=true;if(dom.player.readyState>0){restoreTime=dom.player.currentTime;}
-else{restoreTime=0;}
-captions.remove();dom.player.removeAttribute('src');dom.player.load();}
-function restoreVideo(){if(!videoHardwareReleased){return;}
-videoHardwareReleased=false;setVideoUrl(dom.player,currentVideo,function(){VideoUtils.fitContainer(dom.videoContainer,dom.player,currentVideo.metadata.rotation||0);dom.player.currentTime=restoreTime;});}
-window.addEventListener('storage',function(e){if(e.key==='view-activity-wants-to-use-hardware'&&e.newValue&&!document.hidden&&playerShowing&&!videoHardwareReleased){console.log('The video app view activity needs to play a video.');console.log('Pausing the video and returning to the thumbnails.');console.log('See bug 1088456.');handleCloseButtonClick();}});
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index f9e1bc7d..0868108a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6,7 +6,6 @@
"": {
"name": "vulpes-maintenance",
"devDependencies": {
- "esprima": "4.0.1",
"prettier": "3.6.2",
"prettier-plugin-java": "2.7.7"
},
@@ -86,20 +85,6 @@
"chevrotain": "^11.0.0"
}
},
- "node_modules/esprima": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
- "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
- "dev": true,
- "license": "BSD-2-Clause",
- "bin": {
- "esparse": "bin/esparse.js",
- "esvalidate": "bin/esvalidate.js"
- },
- "engines": {
- "node": ">=4"
- }
- },
"node_modules/java-parser": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/java-parser/-/java-parser-3.0.1.tgz",
diff --git a/package.json b/package.json
index 187e09ed..8eeeef80 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,6 @@
"format:check": "prettier --check 'host/**/*.{js,mjs,css}' 'services/*.mjs' 'adapters/*.mjs' 'tests/*.mjs' 'android/{extension,ui,tests}/**/*.{js,mjs}' 'android/app/src/main/java/**/*.java'"
},
"devDependencies": {
- "esprima": "4.0.1",
"prettier": "3.6.2",
"prettier-plugin-java": "2.7.7"
}
diff --git a/release.json b/release.json
index d7b51032..ac787205 100644
--- a/release.json
+++ b/release.json
@@ -1,15 +1,15 @@
{
"gaia": "2.7.0",
"channel": "preview",
- "revision": 21,
- "androidVersionCode": 21,
+ "revision": 15,
+ "androidVersionCode": 15,
"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.21",
+ "version": "2.7-preview.15",
"adapters": {
"desktop": "0.1.0-dev",
"android": "0.1.0-dev"
diff --git a/services/activities.mjs b/services/activities.mjs
deleted file mode 100644
index 2344e622..00000000
--- a/services/activities.mjs
+++ /dev/null
@@ -1,171 +0,0 @@
-export function isGaiaActivity(source) {
- return source?.name === 'pick' ||
- (source?.name === 'moz_configure_window' && source.data?.target === 'device') ||
- (source?.name === 'configure' && source.data?.target === 'ringtone') ||
- (source?.name === 'dial' && source.data?.type === 'webtelephony/number') ||
- (source?.name === 'new' && source.data?.type === 'websms/sms') ||
- (['new','update','open'].includes(source?.name) && source.data?.type === 'webcontacts/contact');
-}
-
-function entryPointFor(app, path) {
- if (!path) return undefined;
- const pathname=new URL(path,app.origin).pathname;
- return Object.entries(app.manifest?.entry_points || {}).find(([,point])=>
- new URL(point.launch_path || '/',app.origin).pathname===pathname)?.[0];
-}
-
-// Route Gaia's built-in pickers; only the selected provider can finish a request.
-export class Activities {
- constructor({identity, apps, launch, notify, uuid, choose}) {
- Object.assign(this, {identity, apps, launch, notify, uuid, choose});
- this.pending = new Map();
- this.listeners = new Map();
- this.choices = new Map();
- }
- route(source, caller) {
- const types = [].concat(source?.data?.type || []), name=source?.name;
- let id;
- if (name === 'pick') {
- if (types.some(t=>['ringtone','alerttone'].includes(t))) id='ringtones';
- else if (types.includes('wallpaper')) id='wallpaper';
- else if (types.some(t=>['webcontacts/contact','webcontacts/tel','webcontacts/email','webcontacts/select','text/vcard','text/x-vcard','text/directory'].includes(t))) id='communications';
- else if (types.some(t=>typeof t==='string' && t.startsWith('image/'))) id=!caller || caller.id==='settings'?'wallpaper':'gallery';
- else if (types.some(t=>typeof t==='string' && t.startsWith('audio/'))) id='music';
- else if (types.some(t=>typeof t==='string' && t.startsWith('video/'))) id='video';
- } else if (name === 'configure' && source.data?.target === 'ringtone') id='ringtones';
- else if (name === 'dial' && types.includes('webtelephony/number')) id='communications';
- else if (name === 'new' && types.includes('websms/sms')) id='sms';
- else if (['new','update','open'].includes(name) && types.includes('webcontacts/contact')) id='communications';
- const app = this.apps().find(a=>a.id===id);
- const candidates = [].concat(app?.manifest.activities?.[name] || []);
- const definition = candidates.find(d=>{
- const filter=d.filters?.type;
- const accepted=[].concat(filter?.value || filter || []);
- return !accepted.length || types.some(t=>accepted.includes(t));
- });
- if (!app || !definition?.href) throw Error('ACTIVITY_NOT_SUPPORTED');
- const url=new URL(definition.href,app.origin);
- if (url.origin!==new URL(app.origin).origin) throw Error('ACTIVITY_NOT_SUPPORTED');
- // SMS handles the activity after startup; its old manifest fragment is
- // not a Navigation view in the packaged Gaia build.
- if (id==='sms' && name==='new') url.hash='';
- const entryPoint=entryPointFor(app,url.href);
- return {app,definition,url,entryPoint};
- }
- provider(source) { return this.route(source).app; }
- start(caller, source) {
- const app = this.identity(caller);
- if(source?.name==='moz_configure_window' && source.data?.target==='device') {
- if(app?.id!=='keyboard')throw Error('PERMISSION_DENIED');
- const settings=this.apps().find(a=>a.id==='settings');
- if(!settings)throw Error('ACTIVITY_NOT_SUPPORTED');
- this.launch(settings,settings.manifest.launch_path || '/index.html',{});
- return Promise.resolve(null);
- }
- if (!app || !['settings','system','homescreen','verticalhome','ringtones','sms','communications','email','calendar'].includes(app.id))
- throw Error('PERMISSION_DENIED');
- const types = [].concat(source?.data?.type || []);
- if (source?.name==='pick' && types.length>1 && this.choose) {
- const routes=[];
- for (const type of types) {
- try {
- const route=this.route({...source,data:{...source.data,type}},app);
- if (!routes.some(r=>r.app.id===route.app.id)) routes.push(route);
- } catch (error) { if (error.message!=='ACTIVITY_NOT_SUPPORTED') throw error; }
- }
- if (routes.length>1) {
- if (this.choices.size) throw Error('ACTIVITY_BUSY');
- const id=this.uuid();
- return new Promise((resolve,reject)=>{
- this.choices.set(id,{caller,app,source,routes,resolve,reject});
- this.choose({id,name:source.name,activityType:types.join(','),
- choices:routes.map(r=>{
- const icons=r.app.manifest.icons || {};
- const icon=icons[128] || icons[60] || Object.values(icons)[0];
- return {manifest:r.app.manifestURL,icon:icon?new URL(icon,r.app.origin).href:undefined};
- })});
- });
- }
- if (routes.length===1) return this.open(caller,app,source,routes[0]);
- }
- return this.open(caller,app,source,this.route(source,app));
- }
- select(peer,{id,value}) {
- if (this.identity(peer)?.id!=='system') throw Error('PERMISSION_DENIED');
- const row=this.choices.get(id);
- if (!row) throw Error('ACTIVITY_CANCELLED');
- if (!/^(?:-1|\d+)$/.test(String(value))) throw Error('INVALID_ACTIVITY_CHOICE');
- const index=Number(value);
- if (index!==-1 && !row.routes[index]) throw Error('INVALID_ACTIVITY_CHOICE');
- this.choices.delete(id);
- if (index===-1) {row.reject(Error('ACTIVITY_CANCELLED'));return;}
- try {this.open(row.caller,row.app,row.source,row.routes[index]).then(row.resolve,row.reject);}
- catch(error) {row.reject(error);}
- }
- open(caller,app,source,{app:provider,definition,url,entryPoint}) {
- if ([...this.pending.values()].some(p=>p.provider.id===provider.id)) throw Error('ACTIVITY_BUSY');
- const id = this.uuid();
- return new Promise((resolve,reject) => {
- const row = {id,caller,app,provider,source,resolve,reject,url,entryPoint,
- returnURL:caller.manager?.documentURI?.spec,returns:definition.disposition!=='window'};
- this.pending.set(id,row);
- const listener=[...this.listeners.values()].find(peer=>
- peer.manager?.documentURI?.spec && this.identity(peer)?.id===provider.id && this.matches(peer,row));
- if (listener) {
- this.launch(provider,listener.manager.documentURI.spec,{entryPoint});
- this.deliver(row,listener);
- } else {
- url.searchParams.set('activity',id);
- this.launch(provider,url.href,{reload:true,entryPoint});
- }
- });
- }
- matches(peer,row) {
- const spec=peer.manager?.documentURI?.spec;
- if (!spec) return true;
- const current=new URL(spec);
- if (current.pathname!==row.url.pathname) return false;
- // Gallery selects its picker mode once at startup from #pick. Reusing a
- // normal gallery window would never enable selection/cropping controls.
- return row.provider.id!=='gallery' || current.hash===row.url.hash;
- }
- subscribe(peer) {
- const app = this.identity(peer);
- if (!app) throw Error('PERMISSION_DENIED');
- this.listeners.set(peer,peer);
- for (const row of this.pending.values())
- if (row.provider.id===app.id && this.matches(peer,row)) this.deliver(row,peer);
- }
- deliver(row,peer) {
- if (!peer || row.delivered === peer) return;
- row.delivered = peer;
- this.notify(peer,'activity',{id:row.id,source:row.source});
- if (!row.returns) {this.pending.delete(row.id);row.resolve(null);}
- }
- finish(peer, {id,result,error}) {
- const row = this.pending.get(id);
- if (!row || row.delivered !== peer) throw Error('PERMISSION_DENIED');
- this.pending.delete(id);
- this.launch(row.app,row.returnURL,{entryPoint:entryPointFor(row.app,row.returnURL)});
- if (error) row.reject(Error('ACTIVITY_CANCELLED'));
- else row.resolve(result);
- }
- cancel(peer) {
- if (this.identity(peer)?.id !== 'system') throw Error('PERMISSION_DENIED');
- for (const row of this.pending.values()) row.reject(Error('ACTIVITY_CANCELLED'));
- this.pending.clear();
- for (const row of this.choices.values()) row.reject(Error('ACTIVITY_CANCELLED'));
- this.choices.clear();
- }
- cleanup(peer) {
- for (const [id,row] of this.choices) if (row.caller===peer) {
- this.choices.delete(id);row.reject(Error('ACTIVITY_CANCELLED'));
- }
- for (const [app, listener] of this.listeners) if (listener === peer) this.listeners.delete(app);
- for (const [id,row] of this.pending) {
- if (row.caller === peer || row.delivered === peer) {
- this.pending.delete(id);row.reject(Error('ACTIVITY_CANCELLED'));
- }
- }
- }
-}
diff --git a/services/input-editor.mjs b/services/input-editor.mjs
deleted file mode 100644
index bb529f01..00000000
--- a/services/input-editor.mjs
+++ /dev/null
@@ -1,68 +0,0 @@
-// Shared editing operations for the desktop actor and Android content bridge.
-export class InputEditor {
- constructor(win) { this.contentWindow=win; this.document=win.document; this.editing=false; }
- focus(element) { this.inputElement=element; }
- state() {
- const e=this.inputElement;
- const text=e.isContentEditable ? e.textContent : e.value;
- const selection=this.contentWindow.getSelection();
- let start=e.selectionStart ?? text.length, end=e.selectionEnd ?? start;
- if(e.isContentEditable && selection.rangeCount && e.contains(selection.anchorNode)) {
- const range=this.document.createRange();range.selectNodeContents(e);
- range.setEnd(selection.anchorNode,selection.anchorOffset);start=range.toString().length;
- range.setEnd(selection.focusNode,selection.focusOffset);end=range.toString().length;
- [start,end]=[Math.min(start,end),Math.max(start,end)];
- }
- return {type:e.isContentEditable?'contenteditable':e.localName,inputType:e.type || (e.classList.contains('recipient')?'tel':'text'),
- inputMode:e.inputMode || (e.classList.contains('recipient')?'tel':''),
- text:e.type==='password'?'':text,selectionStart:start,selectionEnd:end};
- }
- edit(p) {
- const e=this.inputElement,win=this.contentWindow;
- if(!e?.isConnected || this.document.hidden) throw Error('INPUT_CONTEXT_EXPIRED');
- this.editing=true;
- try {e.focus();} finally {this.editing=false;}
- // A touch can focus a new contenteditable while Gecko retains the previous
- // field's selection. execCommand follows that selection, not activeElement.
- if(e.isContentEditable) {
- const selection=win.getSelection();
- if(!e.contains(selection.anchorNode) || !e.contains(selection.focusNode)) {
- const range=this.document.createRange();
- range.selectNodeContents(e);range.collapse(false);
- selection.removeAllRanges();selection.addRange(range);
- }
- }
- if(p.operation==='key') {
- let key=typeof p.key==='object'?p.key.key:p.key;
- if(key===8 || key==='Backspace') this.document.execCommand('delete');
- else if(key===13 || key==='Enter') {
- const options={key:'Enter',code:'Enter',keyCode:13,charCode:13,which:13,bubbles:true,cancelable:true};
- const accepted=e.dispatchEvent(new win.KeyboardEvent('keydown',options)) && e.dispatchEvent(new win.KeyboardEvent('keypress',options));
- if(accepted && (e.isContentEditable || e.localName==='textarea')) this.document.execCommand('insertLineBreak');
- e.dispatchEvent(new win.KeyboardEvent('keyup',options));
- } else {
- const text=typeof key==='string'?key:String.fromCodePoint(p.charCode || key);
- if(text.length>16) throw Error('INVALID_VALUE');
- this.document.execCommand('insertText',false,text);
- }
- } else if(['selection','replace'].includes(p.operation)) {
- const state=this.state();
- const start=p.operation==='selection'?p.start:state.selectionStart+(p.offset||0);
- const length=p.length||0;
- if(!Number.isInteger(start)||!Number.isInteger(length)||start<0||length<0||start+length>(e.value ?? e.textContent).length) throw Error('INVALID_VALUE');
- if(e.isContentEditable) {
- const walker=this.document.createTreeWalker(e,win.NodeFilter.SHOW_TEXT);
- const points=[];let offset=0,node;
- while((node=walker.nextNode())) {points.push({node,start:offset,end:offset+node.length});offset+=node.length;}
- const point=index=>{const part=points.find(p=>index<=p.end);return part?[part.node,index-part.start]:[e,0];};
- const range=this.document.createRange();range.setStart(...point(start));range.setEnd(...point(start+length));
- win.getSelection().removeAllRanges();win.getSelection().addRange(range);
- } else e.setSelectionRange(start,start+length);
- if(p.operation==='replace') {
- if(typeof p.text!=='string'||p.text.length>4096) throw Error('INVALID_VALUE');
- this.document.execCommand('insertText',false,p.text);
- }
- } else throw Error('METHOD_NOT_SUPPORTED');
- return this.state();
- }
-}
diff --git a/services/input.mjs b/services/input.mjs
deleted file mode 100644
index 6c73c982..00000000
--- a/services/input.mjs
+++ /dev/null
@@ -1,36 +0,0 @@
-// Only the bundled keyboard can edit the field reported by a content actor.
-export class Input {
- constructor(runtime) { this.runtime=runtime; this.focus=null; this.sequence=0; }
- changed(actor, state) {
- const app=this.runtime.identity(actor);
- if (!app || app.id==='keyboard') return;
- if (!state) { if(this.focus?.actor===actor) this.hide(); return; }
- this.focus={actor,state:{...state,id:++this.sequence}};
- this.runtime.broadcast('input-focus',this.focus.state,'system');
- this.runtime.broadcast('input-focus',this.focus.state,'keyboard');
- }
- hide() {
- this.focus=null;
- this.runtime.broadcast('input-focus',null,'system');
- this.runtime.broadcast('input-focus',null,'keyboard');
- }
- cleanup(actor) { if(this.focus?.actor===actor) this.hide(); }
- async request(actor, p) {
- const app=this.runtime.identity(actor);
- if (!app || !['keyboard','system'].includes(app.id)) throw Error('PERMISSION_DENIED');
- if (p.operation==='hide') { this.hide();return null; }
- if(app.id!=='keyboard') throw Error('PERMISSION_DENIED');
- if(p.operation==='state') return this.focus?.state || null;
- if(p.operation==='height') {
- if(!Number.isFinite(p.height) || p.height<0 || p.height>600) throw Error('INVALID_VALUE');
- this.runtime.broadcast('input-height',{height:p.height},'system');return null;
- }
- if(!this.focus || p.id!==this.focus.state.id) throw Error('INPUT_CONTEXT_EXPIRED');
- const context=this.focus;
- const result=await context.actor.sendQuery('Vulpes:Edit',p);
- // An application can close or focus another field while the edit is pending.
- if(this.focus!==context) throw Error('INPUT_CONTEXT_EXPIRED');
- Object.assign(context.state,result);
- return context.state;
- }
-}
diff --git a/services/mms.mjs b/services/mms.mjs
deleted file mode 100644
index ff96dc15..00000000
--- a/services/mms.mjs
+++ /dev/null
@@ -1,29 +0,0 @@
-// SPDX-License-Identifier: MPL-2.0
-// JSON transport for Gaia attachments; blobs are encoded at the app boundary.
-export const MMS_MAX_BYTES = 600 * 1024;
-export function mmsPayload(message) {
- if (!message || !Array.isArray(message.receivers) || !message.receivers.length || message.receivers.length>10 ||
- message.receivers.some(n=>typeof n!=='string' || !/^\+?[0-9]{3,20}$/.test(n))) throw Error('INVALID_NUMBER');
- const parts=[];
- if(message.smil) parts.push({id:'smil.xml',type:'application/smil',data:utf8Base64(message.smil)});
- for(const [i,a] of (message.attachments || []).entries()) {
- const content=a.content;
- if(!content?.__vulpesBlobV1 || typeof content.base64!=='string' || !/^[A-Za-z0-9+/]*={0,2}$/.test(content.base64)) throw Error('INVALID_MMS');
- parts.push({id:a.location || a.id?.replace(/[<>]/g,'') || 'attachment-'+i,type:content.type,data:content.base64});
- }
- if(!parts.length || parts.length>20 || parts.reduce((n,p)=>n+Math.floor(p.data.length*3/4),0)>MMS_MAX_BYTES) throw Error('MMS_TOO_LARGE');
- return {recipients:message.receivers,subject:message.subject || '',parts};
-}
-function utf8Base64(text) {const bytes=new TextEncoder().encode(text);let s='';for(const byte of bytes)s+=String.fromCharCode(byte);return btoa(s);}
-export function receivedMms(row) {
- let smil='',body='';const attachments=[];
- for(const part of row.parts || []) {
- if(['application/smil','text/plain'].includes(part.type)) {
- const text=new TextDecoder().decode(Uint8Array.from(atob(part.data),c=>c.charCodeAt(0)));
- if(part.type==='application/smil') {smil=text;continue;}
- body+=text;
- }
- attachments.push({id:part.id,location:part.id,content:{__vulpesBlobV1:true,type:part.type,base64:part.data}});
- }
- return {...row,body,smil,attachments,timestamp:row.timestamp || Date.now()};
-}
diff --git a/services/packages.mjs b/services/packages.mjs
deleted file mode 100644
index 7885c980..00000000
--- a/services/packages.mjs
+++ /dev/null
@@ -1,77 +0,0 @@
-// Archived Marketplace packages run as ordinary web apps, never certified Gaia apps.
-const MAX_ZIP = 16 * 1024 * 1024, MAX_FILES = 2048, MAX_EXPANDED = 48 * 1024 * 1024;
-export function packageURL(value) {
- const url = new URL(value);
- if (url.origin !== 'https://vulpes-os.org' || url.pathname !== '/marketplace/index.php' ||
- !/^\d{1,10}$/.test(url.searchParams.get('download') || '') || url.username || url.password)
- throw Error('INVALID_PACKAGE_URL');
- return url.origin+url.pathname+'?download='+url.searchParams.get('download');
-}
-async function bounded(stream, limit) {
- const reader=stream.getReader(), chunks=[]; let size=0;
- try { for (;;) {const {done,value}=await reader.read();if(done)break;size+=value.length;
- if(size>limit)throw Error('PACKAGE_TOO_LARGE');chunks.push(value);}
- } finally {await reader.cancel().catch(()=>{});}
- const bytes=new Uint8Array(size);let offset=0;for(const chunk of chunks){bytes.set(chunk,offset);offset+=chunk.length;}return bytes;
-}
-function crc32(bytes) {let crc=0xffffffff;for(const byte of bytes){crc^=byte;for(let i=0;i<8;i++)crc=(crc>>>1)^((crc&1)?0xedb88320:0);}return (crc^0xffffffff)>>>0;}
-function base64(bytes) {let text='';for(let i=0;iMAX_ZIP)throw Error('PACKAGE_TOO_LARGE');
- const view=new DataView(bytes.buffer,bytes.byteOffset,bytes.byteLength), decoder=new TextDecoder('utf-8',{fatal:true});
- let end=-1;
- for(let p=bytes.length-22;p>=Math.max(0,bytes.length-65557);p--)
- if(view.getUint32(p,true)===0x06054b50 && p+22+view.getUint16(p+20,true)===bytes.length){end=p;break;}
- if(end<0 || view.getUint16(end+4,true) || view.getUint16(end+6,true))throw Error('INVALID_ZIP');
- const count=view.getUint16(end+10,true), centralSize=view.getUint32(end+12,true),start=view.getUint32(end+16,true);
- if(!count || count>MAX_FILES || start+centralSize!==end || view.getUint16(end+8,true)!==count)throw Error('INVALID_ZIP');
- const files=Object.create(null);let cursor=start,total=0;
- for(let i=0;iend || view.getUint32(cursor,true)!==0x02014b50)throw Error('INVALID_ZIP');
- const flags=view.getUint16(cursor+8,true),method=view.getUint16(cursor+10,true),crc=view.getUint32(cursor+16,true),
- packed=view.getUint32(cursor+20,true),size=view.getUint32(cursor+24,true),length=view.getUint16(cursor+28,true),
- extra=view.getUint16(cursor+30,true),comment=view.getUint16(cursor+32,true),attrs=view.getUint32(cursor+38,true),offset=view.getUint32(cursor+42,true);
- if(cursor+46+length+extra+comment>end || flags&1 || ![0,8].includes(method) || ((attrs>>>16)&0xf000)===0xa000)throw Error('UNSUPPORTED_ZIP');
- const name=decoder.decode(bytes.subarray(cursor+46,cursor+46+length));cursor+=46+length+extra+comment;
- if(!name || name.startsWith('/') || name.includes('\\') || /[\x00-\x1f:]/.test(name) || name.split('/').some(p=>p==='..'||p==='.') || name.length>512)throw Error('INVALID_PATH');
- if(name.endsWith('/'))continue;
- if(Object.hasOwn(files,name) || (total+=size)>MAX_EXPANDED || offset+30>start || view.getUint32(offset,true)!==0x04034b50)throw Error('INVALID_ZIP');
- const localName=view.getUint16(offset+26,true),localExtra=view.getUint16(offset+28,true),data=offset+30+localName+localExtra;
- if(data+packed>start || decoder.decode(bytes.subarray(offset+30,offset+30+localName))!==name || view.getUint16(offset+8,true)!==method)throw Error('INVALID_ZIP');
- let content=bytes.subarray(data,data+packed);
- if(method===8)content=await bounded(new Blob([content]).stream().pipeThrough(new DecompressionStream('deflate-raw')),size);
- if(content.length!==size || crc32(content)!==crc)throw Error('CORRUPT_PACKAGE');
- files[name]=base64(content);
- }
- if(cursor!==end || !files['manifest.webapp'])throw Error('MISSING_MANIFEST');
- const original=JSON.parse(decoder.decode(Uint8Array.from(atob(files['manifest.webapp']),c=>c.charCodeAt(0))));
- if(typeof original.name!=='string' || !original.name.trim() || original.name.length>120)throw Error('INVALID_MANIFEST');
- function path(value) {if(typeof value!=='string')throw Error('INVALID_MANIFEST');const u=new URL(value,'https://app.invalid/');
- if(u.origin!=='https://app.invalid' || !Object.hasOwn(files,decodeURIComponent(u.pathname.slice(1))))throw Error('MISSING_APP_FILE');return u.pathname+u.search+u.hash;}
- const launch=path(original.launch_path || '/index.html'),icons={};
- for(const [size,value] of Object.entries(original.icons || {}))if(/^\d+$/.test(size)) {try {icons[size]=path(value);}catch(_){}}
- if(!Object.keys(icons).length)throw Error('MISSING_ICON');
- const hash=await crypto.subtle.digest('SHA-256',new TextEncoder().encode(source));
- const id='user-'+Array.from(new Uint8Array(hash)).map(n=>n.toString(16).padStart(2,'0')).join('').slice(0,24);
- const manifest={name:original.name,description:String(original.description||'').slice(0,1000),version:String(original.version||'1.0').slice(0,60),launch_path:launch,icons,type:'web',permissions:{}};
- return {id,manifest,files,source,bytes:total,requestedPermissions:Object.keys(original.permissions||{})};
-}
-export class Packages {
- constructor({save,fetchPackage=(...args)=>fetch(...args),uuid=()=>crypto.randomUUID(),changed,schedule=(fn,delay)=>setTimeout(fn,delay),unschedule=id=>clearTimeout(id)}) {Object.assign(this,{save,fetchPackage,uuid,changed,schedule,unschedule});this.pending=new Map();}
- async prepare(url) {
- this.cancel();const controller=this.controller=new AbortController();
- const timer=this.schedule(()=>controller.abort(),60000);
- try {
- const source=packageURL(url),response=await this.fetchPackage(source,{redirect:'error',credentials:'omit',signal:controller.signal});
- if(!response.ok)throw Error('DOWNLOAD_FAILED');
- const item=await unpackPackage(await bounded(response.body,MAX_ZIP),source);
- if(controller.signal.aborted)throw Error('DOWNLOAD_CANCELLED');
- this.pending.clear();const token=this.uuid();this.pending.set(token,{item,expires:Date.now()+300000});
- return {token,name:item.manifest.name,bytes:item.bytes,permissions:item.requestedPermissions};
- } finally {this.unschedule(timer);if(this.controller===controller)this.controller=null;}
- }
- cancel(){this.controller?.abort();this.pending.clear();}
- async install(token) {const row=this.pending.get(token);this.pending.delete(token);if(!row || row.expires 6144) throw Error('INVALID_VALUE');
- if (operation === 'deviceSettings' && (!['clock','setTime','setTimezone','setAutomatic'].includes(params.action) || JSON.stringify(params).length > 1024)) throw Error('INVALID_VALUE');
if (operation === 'cameraPreview' &&
(!['start', 'frame', 'stop', 'focus', 'zoom', 'picture'].includes(params.action) ||
(params.action === 'start' && !['back', 'front'].includes(params.camera))))
@@ -55,7 +47,7 @@ export function authorizePlatform(app, operation, params = {}) {
throw Error('INVALID_VALUE');
}
if (operation === 'ackCallHistory' && (!Array.isArray(params.ids) || params.ids.length > 500 || params.ids.some(id => typeof id !== 'string' || !/^\d{1,20}$/.test(id)))) throw Error('INVALID_VALUE');
- if (['wifi', 'bluetooth'].includes(operation) && JSON.stringify(params).length > 4096)
+ if (operation === 'wifi' && JSON.stringify(params).length > 4096)
throw Error('QuotaExceededError');
if (operation === 'notification' && JSON.stringify(params).length > 65536)
throw Error('QuotaExceededError');
diff --git a/tests/ProbeChild.sys.mjs b/tests/ProbeChild.sys.mjs
index a3f182ca..7d3af5d2 100644
--- a/tests/ProbeChild.sys.mjs
+++ b/tests/ProbeChild.sys.mjs
@@ -1,32 +1,6 @@
// Test-only actor, registered by tools/inspect-host.py through Marionette.
export class VulpesProbeChild extends JSWindowActorChild {
async receiveMessage(message) {
- if (message.name === 'Swipe') {
- const {x,y,toX,toY}=message.data, win=this.contentWindow;
- if (![x,y,toX,toY].every(Number.isFinite) ||
- [x,toX].some(v=>v<0 || v>=win.innerWidth) ||
- [y,toY].some(v=>v<0 || v>=win.innerHeight)) throw Error('INVALID_TEST_POINT');
- const {setTimeout}=ChromeUtils.importESModule('resource://gre/modules/Timer.sys.mjs');
- for (let step=0;step<=12;step++) {
- const points=[{identifier:0,offsetX:x+(toX-x)*step/12,
- offsetY:y+(toY-y)*step/12,radiiX:1,radiiY:1}];
- win.synthesizeTouchEvent(step===0?'touchstart':'touchmove',points,0,{isAsyncEnabled:false});
- await new Promise(resolve=>setTimeout(resolve,20));
- }
- win.synthesizeTouchEvent('touchend',[{identifier:0,offsetX:toX,offsetY:toY,radiiX:1,radiiY:1}],0,{isAsyncEnabled:false});
- return true;
- }
- if (message.name === 'MouseClick') {
- const {x,y}=message.data, win=this.contentWindow;
- if (!Number.isFinite(x) || !Number.isFinite(y) || x<0 || y<0 ||
- x>=win.innerWidth || y>=win.innerHeight) throw Error('INVALID_TEST_POINT');
- const {setTimeout}=ChromeUtils.importESModule('resource://gre/modules/Timer.sys.mjs');
- for (const type of ['mousemove','mousedown','mouseup']) {
- win.synthesizeMouseEvent(type,x,y,{button:0,clickCount:1},{isAsyncEnabled:false});
- await new Promise(resolve=>setTimeout(resolve,100));
- }
- return true;
- }
if (message.name === 'Tap') {
const {x,y}=message.data, win=this.contentWindow;
if (!Number.isFinite(x) || !Number.isFinite(y) || x<0 || y<0 ||
diff --git a/tests/activities.test.mjs b/tests/activities.test.mjs
deleted file mode 100644
index a41dfe14..00000000
--- a/tests/activities.test.mjs
+++ /dev/null
@@ -1,121 +0,0 @@
-import assert from 'node:assert/strict';
-import {Activities, isGaiaActivity} from '../services/activities.mjs';
-const apps=[{id:'settings',origin:'http://settings.localhost'},{id:'wallpaper',origin:'http://wallpaper.localhost',manifest:{activities:{pick:{href:'/pick.html'}}}},{id:'ringtones',origin:'http://ringtones.localhost',manifest:{activities:{pick:{href:'/pick.html'}}}}];
-const caller={app:apps[0],manager:{documentURI:{spec:'http://settings.localhost/#display'}}};
-const picker={app:apps[1]}, stranger={app:apps[2]};
-const events=[],launches=[];
-const broker=new Activities({identity:p=>p.app,apps:()=>apps,launch:(...a)=>launches.push(a),notify:(...a)=>events.push(a),uuid:()=> 'request-1'});
-const pending=broker.start(caller,{name:'pick',data:{type:'image/*'}});
-assert.equal(events.length,0);
-broker.subscribe(picker);assert.equal(events.length,1);
-assert.throws(()=>broker.finish(stranger,{id:'request-1',result:{}}),/PERMISSION_DENIED/);
-assert.throws(()=>broker.start(caller,{name:'pick',data:{type:'image/*'}}),/ACTIVITY_BUSY/);
-broker.finish(picker,{id:'request-1',result:{blob:{__vulpesBlobV1:true,type:'image/png',base64:'YWJj'}}});
-assert.equal((await pending).blob.base64,'YWJj');assert.equal(launches.at(-1)[1],'http://settings.localhost/#display');
-const cancelled=broker.start(caller,{name:'pick',data:{type:'image/*'}});
-broker.subscribe(picker);broker.cleanup(picker);await assert.rejects(cancelled,/ACTIVITY_CANCELLED/);
-assert.throws(()=>broker.start({app:{id:'untrusted'}},{name:'pick',data:{type:'image/*'}}),/PERMISSION_DENIED/);
-assert.throws(()=>broker.provider({name:'pick',data:{type:'video/mp4'}}),/ACTIVITY_NOT_SUPPORTED/);
-const abandoned = broker.start(caller,{name:'pick',data:{type:'image/*'}});
-assert.throws(()=>broker.cancel(stranger),/PERMISSION_DENIED/);
-broker.cancel({app:{id:'system'}});
-await assert.rejects(abandoned,/ACTIVITY_CANCELLED/);
-const reopened = broker.start(caller,{name:'pick',data:{type:'image/*'}});
-broker.subscribe(picker);
-broker.finish(picker,{id:'request-1',result:{reopened:true}});
-assert.equal((await reopened).reopened,true);
-console.log('Activity routing, result ownership, cancellation and return navigation: OK');
-const phone={id:'communications',origin:'http://communications.localhost',manifest:{activities:{
- pick:{href:'/contacts/index.html?pick',filters:{type:{value:['webcontacts/tel']}}},
- dial:{href:'/dialer/index.html#keyboard-view',filters:{type:'webtelephony/number'},disposition:'window'},
- open:[{href:'/contacts/views/details/details.html',filters:{type:'webcontacts/contact'}},
- {href:'/contacts/views/vcard_load/vcard_load.html',filters:{type:'text/vcard'}}],
-}}};
-const sms={id:'sms',origin:'http://sms.localhost',manifest:{activities:{new:{href:'/index.html#/activity-new',filters:{type:'websms/sms'},returnValue:true}}}};
-apps.push(phone,sms);
-const smsPeer={app:sms,manager:{documentURI:{spec:'http://sms.localhost/index.html#composer'}}};
-const phonePeer={app:phone};
-const choose=broker.start(smsPeer,{name:'pick',data:{type:'webcontacts/tel'}});
-assert.equal(new URL(launches.at(-1)[1]).searchParams.has('pick'),true);
-broker.subscribe(phonePeer);
-broker.finish(phonePeer,{id:'request-1',result:{number:'5550199',name:'Test'}});
-assert.equal((await choose).number,'5550199');
-assert.equal(launches.at(-1)[1],'http://sms.localhost/index.html#composer');
-const dial=broker.start(smsPeer,{name:'dial',data:{type:'webtelephony/number',number:'5550199'}});
-broker.subscribe(phonePeer);await dial;
-assert.equal(broker.pending.size,0,'dial only opens the keypad, with no stale activity left behind');
-const draft=broker.start(phonePeer,{name:'new',data:{type:'websms/sms',target:'5550199'}});
-assert.equal(new URL(launches.at(-1)[1]).hash,'','activity handler chooses the composer after startup');
-broker.subscribe(smsPeer);
-assert.equal(events.at(-1)[2].source.data.target,'5550199');
-broker.finish(smsPeer,{id:'request-1',result:null});await draft;
-assert.equal(broker.route({name:'open',data:{type:'webcontacts/contact'}}).url.pathname,'/contacts/views/details/details.html');
-assert.throws(()=>broker.route({name:'open',data:{type:'url'}}),/ACTIVITY_NOT_SUPPORTED/);
-console.log('Contacts/SMS picker, contact details, SMS draft and dialer routing: OK');
-
-let serial=0;
-const nested=new Activities({identity:p=>p.app,apps:()=>apps,launch:(...a)=>launches.push(a),notify:(...a)=>events.push(a),uuid:()=>String(++serial)});
-const contactsPeer={app:phone,manager:{documentURI:{spec:'http://communications.localhost/contacts/index.html'}}};
-nested.subscribe(contactsPeer);
-const nestedDraft=nested.start(contactsPeer,{name:'new',data:{type:'websms/sms',target:'5550199'}});
-nested.subscribe(smsPeer);
-const nestedPicker=nested.start(smsPeer,{name:'pick',data:{type:'webcontacts/tel'}});
-assert.equal(launches.at(-1)[2].reload,undefined,'reuse the address book without destroying the outer SMS activity');
-assert.equal(events.at(-1)[0],contactsPeer);
-nested.finish(contactsPeer,{id:'2',result:{number:'5550199'}});
-assert.equal((await nestedPicker).number,'5550199');
-assert.equal(nested.pending.size,1);
-nested.finish(smsPeer,{id:'1',result:null});await nestedDraft;
-assert.equal(nested.pending.size,0);
-
-const gallery={id:'gallery',origin:'http://gallery.localhost',manifest:{activities:{pick:{href:'/index.html#pick',filters:{type:['image/jpeg']},returnValue:true}}}};
-apps.push(gallery);
-const normalGallery={app:gallery,manager:{documentURI:{spec:'http://gallery.localhost/index.html'}}};
-nested.subscribe(normalGallery);
-const photo=nested.start(contactsPeer,{name:'pick',data:{type:'image/jpeg'}});
-assert.equal(launches.at(-1)[2].reload,true,'normal Gallery must reload in picker mode');
-assert.equal(new URL(launches.at(-1)[1]).hash,'#pick');
-const galleryPicker={app:gallery,manager:{documentURI:{spec:launches.at(-1)[1]}}};
-nested.subscribe(galleryPicker);
-const photoRequest=events.at(-1)[2].id;
-assert.throws(()=>nested.finish(normalGallery,{id:photoRequest,result:{}}),/PERMISSION_DENIED/);
-nested.finish(galleryPicker,{id:photoRequest,result:{blob:new Blob(['image'])}});
-assert.equal(await (await photo).blob.text(),'image');
-
-const music={id:'music',origin:'http://music.localhost',manifestURL:'http://music.localhost/manifest.webapp',manifest:{activities:{pick:{href:'/pick.html',filters:{type:['audio/*']}}}}};
-const video={id:'video',origin:'http://video.localhost',manifestURL:'http://video.localhost/manifest.webapp',manifest:{activities:{pick:{href:'/index.html#pick',filters:{type:['video/*']}}}}};
-apps.push(music,video);
-for(const app of apps) app.manifestURL ||= app.origin+'/manifest.webapp';
-gallery.manifest.activities.pick.filters.type.push('image/*');
-phone.manifest.activities.pick.filters.type.value.push('text/vcard');
-let menu;
-const chooser=new Activities({identity:p=>p.app,apps:()=>apps,launch:(...a)=>launches.push(a),notify:(...a)=>events.push(a),uuid:()=>String(++serial),choose:data=>{menu=data;}});
-const attachment={name:'pick',data:{type:['image/*','audio/*','video/*','text/vcard']}};
-const attach=chooser.start(smsPeer,attachment);
-assert.deepEqual(menu.choices.map(c=>c.manifest),[gallery,music,video,phone].map(a=>a.manifestURL));
-assert.throws(()=>chooser.select(smsPeer,{id:menu.id,value:0}),/PERMISSION_DENIED/);
-assert.throws(()=>chooser.select({app:{id:'system'}},{id:menu.id,value:99}),/INVALID_ACTIVITY_CHOICE/);
-chooser.select({app:{id:'system'}},{id:menu.id,value:'0'});
-const peer={app:gallery,manager:{documentURI:{spec:launches.at(-1)[1]}}};
-chooser.subscribe(peer);
-chooser.finish(peer,{id:events.at(-1)[2].id,result:{blob:new Blob(['picked']),name:'photo.jpg'}});
-assert.equal((await attach).name,'photo.jpg');
-const cancelChoice=chooser.start(smsPeer,attachment);
-chooser.select({app:{id:'system'}},{id:menu.id,value:-1});await assert.rejects(cancelChoice,/ACTIVITY_CANCELLED/);
-const closed=chooser.start(smsPeer,attachment);chooser.cleanup(smsPeer);await assert.rejects(closed,/ACTIVITY_CANCELLED/);
-console.log('SMS attachment chooser: four original providers, ownership, cancellation and result: OK');
-
-const tones={id:'ringtones',origin:'http://ringtones.localhost',manifest:{activities:{configure:{href:'/manage.html',filters:{target:{required:true,pattern:'ringtone'}},disposition:'inline'}}}};
-apps[2].manifest.activities.configure=tones.manifest.activities.configure;
-assert.equal(isGaiaActivity({name:'configure',data:{target:'ringtone'}}),true);
-assert.equal(broker.route({name:'configure',data:{target:'ringtone'}}).url.pathname,'/manage.html');
-assert.throws(()=>broker.route({name:'configure',data:{target:'other'}}),/ACTIVITY_NOT_SUPPORTED/);
-apps[0].manifest={launch_path:'/index.html'};
-const keyboardPeer={app:{id:'keyboard'}};
-const configure={name:'moz_configure_window',data:{target:'device'}};
-assert.equal(isGaiaActivity(configure),true);
-await broker.start(keyboardPeer,configure);
-assert.equal(launches.at(-1)[0].id,'settings');
-assert.equal(broker.pending.size,0);
-assert.throws(()=>broker.start({app:{id:'untrusted'}},configure),/PERMISSION_DENIED/);
-console.log('Keyboard returns to Settings without a pending activity: OK');
diff --git a/tests/android-input-bridge.test.mjs b/tests/android-input-bridge.test.mjs
deleted file mode 100644
index 804569f0..00000000
--- a/tests/android-input-bridge.test.mjs
+++ /dev/null
@@ -1,104 +0,0 @@
-import test from 'node:test';
-import assert from 'node:assert/strict';
-import vm from 'node:vm';
-import {readFileSync} from 'node:fs';
-const source=readFileSync(new URL('../android/extension/content.js',import.meta.url),'utf8');
-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:fn=>disconnect=fn}};
- class Editor {
- constructor(){this.editing=false;}
- focus(e){this.inputElement=e;}
- state(){return {inputMode:this.inputElement.getAttribute('inputmode'),text:'',inputType:'text'};}
- edit(){return this.state();}
- }
- const context={InputEditor:Editor,browser:{runtime:{connect:()=>port}},console,
- 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:fn=>timers.push(fn),clearTimeout(){}};
- vm.runInNewContext(source,context);
- if(!defer) receive({id:sent[0].id,reply:{result:enabled}});
- await new Promise(resolve=>setImmediate(resolve));
- return {sent,events,page,receive,context,timers,disconnect};
-}
-function field(mode='numeric') {
- const attrs=new Map([['inputmode',mode]]);
- return {localName:'input',type:'text',classList:{contains:()=>false},
- getAttribute:key=>attrs.get(key)??null,setAttribute:(k,v)=>attrs.set(k,v),removeAttribute:k=>attrs.delete(k)};
-}
-test('Android bridge keeps the intended keypad mode after editing, while suppressing the Android IME',async()=>{
- const s=await setup(),e=field();
- s.events.get('focusin')({target:e});
- assert.equal(e.getAttribute('inputmode'),'none');
- assert.equal(s.sent.at(-1).inputFocus.inputMode,'numeric');
- s.receive({edit:{id:3,params:{operation:'key',key:1}}});
- assert.equal(s.sent.at(-1).editReply,3);
- assert.equal(s.sent.at(-1).result.inputMode,'numeric');
- s.receive({event:{type:'input-mode',data:{enabled:false}}});
- assert.equal(e.getAttribute('inputmode'),'numeric');
- assert.equal(s.page.VulpesNativeInput,false);
-});
-test('Android keyboard remains the default and hidden Gaia restores the editor mode',async()=>{
- const s=await setup(false),e=field();
- s.events.get('focusin')({target:e});
- assert.equal(e.getAttribute('inputmode'),'numeric');
- assert.equal(s.sent.length,1);
- s.receive({event:{type:'input-mode',data:{enabled:true}}});
- s.events.get('focusin')({target:e});
- s.context.document.hidden=true;s.events.get('visibilitychange')();
- 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
deleted file mode 100644
index 66f34be5..00000000
--- a/tests/android-personal-data.test.mjs
+++ /dev/null
@@ -1,59 +0,0 @@
-import test from 'node:test';
-import assert from 'node:assert/strict';
-import {readFileSync} from 'node:fs';
-import {Contacts} from '../host/Contacts.sys.mjs';
-import {Messages} from '../host/Messages.sys.mjs';
-const source=readFileSync('android/extension/personal-data.mjs','utf8')
- .replace("'./Contacts.mjs'",JSON.stringify(new URL('../host/Contacts.sys.mjs',import.meta.url).href))
- .replace("'./Messages.mjs'",JSON.stringify(new URL('../host/Messages.sys.mjs',import.meta.url).href));
-const {AndroidPersonalData,authorizePersonal,smsFromAndroid}=await import('data:text/javascript;base64,'+Buffer.from(source).toString('base64'));
-const app={id:'communications',manifest:{permissions:{contacts:{access:'readwrite'}}}};
-const smsApp={id:'sms',manifest:{permissions:{sms:{},contacts:{access:'readonly'}}}};
-async function setup(){
- const data=new Map([['vulpes-private-contacts',{revision:1,contacts:[{id:'local-1',name:['Local']}]}],['vulpes-private-messages',[{id:100,threadId:200,timestamp:1,body:'Local',delivery:'sent',receiver:'5550100',type:'sms',read:true}]]]);
- const storage={run:async(name,op,args)=>{if(op==='get')return {value:structuredClone(data.get(name))};data.set(name,structuredClone(args[0]));}};
- const contacts=new Contacts(storage,()=>{});await contacts.init();
- const calls=[];let allowed=true;
- const service=new AndroidPersonalData(async p=>{calls.push(p);if(p.operation==='status')return {contacts:allowed,sms:allowed};return {rows:p.kind==='contacts'?[{_id:1,display_name:'Android',tel:[{value:'5550199'}]}]:[{_id:1,thread_id:2,address:'5550199',body:'Android SMS',date:2,type:1,read:0,status:-1}],next:null};},contacts,new Messages(storage),storage);
- return {service,data,calls,revoke(){allowed=false;}};
-}
-test('native contact reads preserve the local address book and search phone numbers',async()=>{
- const s=await setup();const rows=await s.service.contactRequest(app,{operation:'all'});
- assert.deepEqual(rows.map(r=>r.id),['local-1','android:1']);
- assert.equal((await s.service.contactRequest(app,{operation:'find',options:{filterBy:['tel'],filterOp:'match',filterValue:'0199'}}))[0].id,'android:1');
- assert.equal(s.data.get('vulpes-private-contacts').contacts.length,1);
- s.revoke();assert.equal((await s.service.contactRequest(app,{operation:'all'})).length,1);
-});
-test('native messages and threads remain distinct from local IDs and are not copied to storage',async()=>{
- const s=await setup();const rows=await s.service.messageRequest(smsApp,{operation:'getThreads'});
- assert.deepEqual(rows.map(r=>r.id),[-2,200]);
- const [sms]=await s.service.messageRequest(smsApp,{operation:'getMessages',args:[{threadId:-2}]});assert.equal(sms.id,-1);assert.equal(sms.body,'Android SMS');
- assert.equal(s.data.get('vulpes-private-messages').length,1);
- s.revoke();assert.equal((await s.service.messageRequest(smsApp,{operation:'getThreads'})).length,1);
-});
-test('Android writes never fall through to the local store or report false success',async()=>{
- const s=await setup();
- for(const params of [{operation:'save',contact:{id:'android:1'}},{operation:'remove',id:'android:1'},{operation:'clear'}]) await assert.rejects(s.service.contactRequest(app,params),/READ_ONLY/);
- for(const params of [{operation:'delete',args:[[100,-1]]},{operation:'markMessageRead',args:[-1,true]}])await assert.rejects(s.service.messageRequest(smsApp,params),/READ_ONLY/);
- assert.equal(s.data.get('vulpes-private-messages').length,1);
-});
-test('untrusted installed apps cannot reuse the Android permission granted to Gaia',async()=>{
- const s=await setup();
- assert.equal((await s.service.contactRequest({...app,installed:true},{operation:'all'})).length,1);
- assert.equal((await s.service.messageRequest({...smsApp,installed:true},{operation:'getMessages'})).length,1);
- assert.equal(s.calls.length,0);
- assert.throws(()=>authorizePersonal({...app,installed:true},'contacts','page'),/PERMISSION/);
- assert.throws(()=>authorizePersonal(smsApp,'calls','page'),/PERMISSION/);
- assert.throws(()=>authorizePersonal(app,'contacts','permission'),/PERMISSION/);
- assert.doesNotThrow(()=>authorizePersonal({id:'settings'},'sms','permission'));
-});
-test('provider errors and stalled page cursors are surfaced, not presented as an empty book',async()=>{
- const s=await setup();let n=0;
- s.service.native=async p=>p.operation==='status'?{contacts:true}:{rows:[],next:++n===1?1:1};
- await assert.rejects(s.service.rows('contacts'),/INVALID_CURSOR/);
- s.service.native=async()=>{throw Error('denied');};await assert.rejects(s.service.rows('contacts'),/denied/);
-});
-test('SMS mapping does not turn pending or failed sends into delivered messages',()=>{
- for(const [type,delivery] of [[1,'received'],[2,'sent'],[4,'sending'],[5,'error'],[6,'sending']])assert.equal(smsFromAndroid({_id:1,thread_id:1,type}).delivery,delivery);
- assert.throws(()=>smsFromAndroid({_id:Infinity,thread_id:1}),/INVALID_ANDROID_ID/);
-});
diff --git a/tests/android-personal-ui.test.mjs b/tests/android-personal-ui.test.mjs
deleted file mode 100644
index b39fca8d..00000000
--- a/tests/android-personal-ui.test.mjs
+++ /dev/null
@@ -1,85 +0,0 @@
-import test from 'node:test';
-import assert from 'node:assert/strict';
-import vm from 'node:vm';
-import {readFileSync} from 'node:fs';
-function setup(app,path,call){
- const events=new Map(),notices=[];
- const context={console,URL,Map,Set,Number,JSON,Date,Promise,queueMicrotask,setTimeout,clearTimeout,setInterval,clearInterval,
- location:{hostname:app+'.localhost',pathname:path},navigator:{language:'en'},localStorage:{removeItem(){}},
- document:{hidden:false,documentElement:{lang:'en'},readyState:'complete',addEventListener:(type,fn)=>events.set('document-'+type,fn),querySelectorAll(){return[];}},
- MutationObserver:class{observe(){}},addEventListener:(type,fn)=>events.set(type,fn),
- VulpesCompat:{call},VulpesGaiaUI:{notice:value=>notices.push(value)}};
- context.window=context;vm.runInNewContext(readFileSync('android/ui/android-ui.js','utf8'),context);
- return {context,events,notices};
-}
-test('native call rows use Gaia grouping and expose details without changing the local DB',async()=>{
- const s=setup('communications','/dialer/index.html',async(_method,p)=>p.operation==='status'?{calls:true}:[
- {_id:1,date:1000,number:'5550199',duration:42,type:2},
- {_id:2,date:2000,number:'5550199',duration:0,type:2}]);
- let localReads=0;
- s.context.CallLogDBManager={getGroupList(){localReads++;},getGroup(){localReads++;},getGroupAtPosition(){localReads++;},
- _getGroupId:r=>[0,r.number,r.type],_getGroupObject:g=>({...g,id:g.id.join('-'),type:g.id[2]})};
- const db=s.context.CallLogDBManager;
- const rows=await new Promise(resolve=>db.getGroupList(resolve,'lastEntryDate',true,false));
- assert.equal(rows.length,1);assert.equal(rows[0].retryCount,2);assert.equal(rows[0].calls[1].duration,42000);
- assert.equal((await db.getGroup('5550199',1000,'dialing')).calls.length,2);assert.equal(localReads,0);
- const streamed=[];
- await new Promise(resolve=>db.getGroupList(c=>{if(!c.value)return resolve();streamed.push(c.value);c.continue();},'lastEntryDate',true,true));
- assert.equal(streamed.length,1);
-});
-test('SMS provider change refreshes the open conversation without reloading or touching the draft',async()=>{
- const inserted=[];
- const s=setup('sms','/index.html',async()=>[{id:-1,threadId:-2,body:'Received',timestamp:1000}]);
- s.context.Navigation={isCurrentPanel:name=>name==='thread'};
- s.context.ConversationView={activeThread:{id:-2},appendMessage:async message=>inserted.push(message)};
- s.context.Compose={getText:()=> 'Unsent draft'};
- s.context.document.getElementById=()=>null;
- s.context.location.reload=()=>{throw Error('draft lost');};
- s.events.get('vulpes-service-event')({detail:{type:'android-data-changed',data:{kind:'sms'}}});
- await new Promise(resolve=>setImmediate(resolve));
- assert.equal(inserted.length,1);assert.equal(inserted[0].body,'Received');
- assert.equal(s.context.Compose.getText(),'Unsent draft');
-});
-
-test('hidden call log refreshes contact names when shown after Android access changes',()=>{
- const s=setup('communications','/dialer/index.html',async()=>({}));
- let updates=0;
- s.context.CallLog={sticky:{},_contactCache:true,updateListWithContactInfo(){updates++;}};
- s.context.document.hidden=true;
- s.events.get('vulpes-service-event')({detail:{type:'android-data-changed',data:{kind:'contacts'}}});
- assert.equal(updates,0);
- s.context.document.hidden=false;
- s.events.get('visibilitychange')();
- assert.equal(updates,1);
- assert.equal(s.context.CallLog._contactCache,false);
-});
-
-test('Android contact HTML caches are disabled before Gaia can apply stale names',async()=>{
- const s=setup('communications','/contacts/index.html',async()=>({}));
- try {
- let writes=0;
- s.context.Cache={get enabled(){return true;},get active(){return true;},set firstChunk(value){writes++;},apply:async()=> 'stale names'};
- assert.equal(s.context.Cache.enabled,false);
- assert.equal(s.context.Cache.active,false);
- s.context.Cache.firstChunk={cache:'Android names'};
- assert.equal(writes,0);
- await assert.rejects(s.context.Cache.apply());
- } finally{s.events.get('pagehide')();}
-});
-
-test('revoked native contact details close when the hidden app becomes visible',async()=>{
- const s=setup('communications','/contacts/index.html',async()=>[]);
- try {
- let closed=0;
- s.context.MainNavigation={currentView:()=> 'view-contact-details'};
- s.context.Contacts={goBack(){closed++;}};
- s.events.get('document-click')({target:{closest:()=>({dataset:{uuid:'android:1'}})}});
- s.context.document.hidden=true;
- s.events.get('vulpes-service-event')({detail:{type:'android-data-changed',data:{kind:'contacts'}}});
- assert.equal(closed,0);
- s.context.document.hidden=false;
- s.events.get('visibilitychange')();
- await new Promise(resolve=>setImmediate(resolve));
- assert.equal(closed,1);
- } finally{s.events.get('pagehide')();}
-});
diff --git a/tests/android-sms-handoff.test.mjs b/tests/android-sms-handoff.test.mjs
deleted file mode 100644
index 60638c92..00000000
--- a/tests/android-sms-handoff.test.mjs
+++ /dev/null
@@ -1,42 +0,0 @@
-import test from 'node:test';
-import assert from 'node:assert/strict';
-import vm from 'node:vm';
-import {readFileSync} from 'node:fs';
-
-function setup(composer) {
- let click, assimilated=false;
- const calls=[];
- const recipients={numbers:[]};
- const context={console,location:{hostname:'sms.localhost',pathname:'/index.html'},
- addEventListener(){},MutationObserver:class {observe(){}},
- document:{documentElement:{lang:'fr'},readyState:'complete',addEventListener:(name,handler)=>{if(name==='click')click=handler;}},
- VulpesCompat:{call:async(method,params)=>{
- if(method==='platform.capabilities')return {mms:false};
- calls.push({method,params});
- }},
- VulpesPlatform:'android',Navigation:{isCurrentPanel:()=>composer},
- ConversationView:{recipients,activeThread:{participants:['5550123']},assimilateRecipients(){assimilated=true;recipients.numbers=['5550199'];}},
- Compose:{type:'sms',getText:()=> 'Message à confirmer'},
- };
- context.window=context;
- vm.runInNewContext(readFileSync('host/gaia-restoration.js','utf8'),context);
- return {calls,context,send(){
- click({target:{closest:()=>true},preventDefault(){},stopImmediatePropagation(){}});
- assert.equal(assimilated,true);
- }};
-}
-test('Android handoff commits the typed recipient before opening its SMS app',()=>{
- const s=setup(true);s.send();
- assert.equal(s.calls[0].method,'platform.sms');
- assert.equal(s.calls[0].params.number,'5550199');
- assert.equal(s.calls[0].params.body,'Message à confirmer');
-});
-test('Android reply uses the conversation participants and preserves the draft',()=>{
- const s=setup(false);s.send();
- assert.equal(s.calls[0].params.number,'5550123');
- assert.equal(s.context.Compose.getText(),'Message à confirmer');
-});
-test('empty Android messages are not handed off',()=>{
- const s=setup(true);s.context.Compose.getText=()=>'';s.send();
- assert.equal(s.calls.length,0);
-});
diff --git a/tests/bluetooth.test.mjs b/tests/bluetooth.test.mjs
deleted file mode 100644
index 5d127c06..00000000
--- a/tests/bluetooth.test.mjs
+++ /dev/null
@@ -1,61 +0,0 @@
-import test from 'node:test';
-import assert from 'node:assert/strict';
-import vm from 'node:vm';
-import {readFileSync} from 'node:fs';
-import {authorizePlatform} from '../services/platform.mjs';
-function setup(host='settings', platform='tundra') {
- const events=new EventTarget(), requests=[], timers=[];
- const state={available:true,enabled:false,address:'00:11:22:33:44:55',name:'Vulpes',
- discovering:false,discoverable:false,devices:[],pairing:[]};
- const ctx={EventTarget,Event,DOMException,console,navigator:{},document:{hidden:false},
- location:{hostname:host+'.localhost',pathname:'/index.html'},
- addEventListener:events.addEventListener.bind(events),setTimeout:fn=>timers.push(fn),
- setInterval:()=>0,clearInterval(){},VulpesPlatform:platform,VulpesCompat:{async call(method,p){
- requests.push({method,...p});
- if(p.operation==='power')state.enabled=p.enabled;
- if(p.operation==='scan')state.discovering=true;
- if(p.operation==='stopScan')state.discovering=false;
- if(p.operation==='pair')state.devices[0].paired=true;
- if(p.operation==='unpair')state.devices=[];
- return structuredClone(state);
- }}};
- ctx.window=ctx;vm.runInNewContext(readFileSync('host/gaia-bluetooth.js','utf8'),ctx);
- return {ctx,state,events,requests,timers,async ready(){events.dispatchEvent(new Event('DOMContentLoaded'));await new Promise(r=>setImmediate(r));}};
-}
-test('only installed trusted system apps can control Bluetooth',()=>{
- assert.throws(()=>authorizePlatform({id:'gallery',manifest:{}},'bluetooth',{}),/PERMISSION/);
- authorizePlatform({id:'settings',manifest:{}},'bluetooth',{});
- assert.throws(()=>authorizePlatform({id:'settings',manifest:{}},'bluetooth',{name:'x'.repeat(5000)}),/Quota/);
- assert.equal(setup('gallery').ctx.navigator.mozBluetooth,undefined);
- assert.equal(setup('settings','android').ctx.navigator.mozBluetooth,undefined);
-});
-test('Gaia receives adapter, discovery and pairing events in the expected order',async()=>{
- const s=setup();await s.ready();
- const a=s.ctx.navigator.mozBluetooth.defaultAdapter;assert.ok(a);assert.equal(a.state,'disabled');
- await a.enable();assert.equal(a.state,'enabled');
- s.state.devices=[{address:'22:33:44:55:66:77',name:'Test headset',paired:false,connected:false,uuids:[],cod:{}}];
- const h=await a.startDiscovery();let device;h.ondevicefound=e=>device=e.device;
- for(const timer of s.timers)await timer();await new Promise(r=>setImmediate(r));
- assert.equal(device.name,'Test headset');let paired=false;a.ondevicepaired=()=>paired=true;
- await a.pair(device.address);assert.equal(paired,true);assert.equal(a.getPairedDevices().length,1);
- await a.unpair(device.address);assert.equal(a.getPairedDevices().length,0);
- await a.stopDiscovery();assert.equal(a.discovering,false);
-});
-test('unsupported profiles reject instead of reporting a successful transfer',async()=>{
- const s=setup();await s.ready();
- await assert.rejects(s.ctx.navigator.mozBluetooth.defaultAdapter.sendFile('address',{}),{name:'NotSupportedError'});
-});
-test('the original pairing view receives the passkey and submits an explicit decision',async()=>{
- const s=setup('bluetooth');s.ctx.location.pathname='/onpair.html';
- let shown;
- s.ctx.Pairview={init(method,options){shown={method,options};}};
- s.state.pairing=[{id:'request-1',method:'confirmation',deviceName:'Test peer',passkey:'012345'}];
- await s.ready();
- assert.equal(shown.method,'confirmation');
- assert.equal(shown.options.handle.passkey,'012345');
- assert.equal(shown.options.deviceName,'Test peer');
- assert.equal(s.requests.some(r=>r.operation==='reply'),false);
- await shown.options.handle.accept();
- assert.equal(s.requests.find(r=>r.operation==='reply').accept,true);
- assert.equal(s.requests.find(r=>r.operation==='reply').id,'request-1');
-});
diff --git a/tests/call-log-contacts.test.mjs b/tests/call-log-contacts.test.mjs
deleted file mode 100644
index 8c2e4b32..00000000
--- a/tests/call-log-contacts.test.mjs
+++ /dev/null
@@ -1,20 +0,0 @@
-import test from 'node:test';
-import assert from 'node:assert/strict';
-import vm from 'node:vm';
-import {readFileSync} from 'node:fs';
-
-for (const file of ['gaia/apps/communications/dialer/js/call_log.js',
- 'assets/webapps/communications.gaiamobile.org/dialer/js/call_log.js']) {
- test(`call log resolves contacts in its newly rendered section: ${file}`, () => {
- const context = {navigator:{mozContacts:{}},window:{}};
- vm.runInNewContext(readFileSync(file,'utf8'),context);
- const log = context.CallLog, items = [], lookedUp = [];
- const list = {appendChild:row=>items.push(row),querySelectorAll:()=>items};
- log.createSectionForGroup=()=>({getElementsByTagName:()=>[list]});
- log.createGroup=group=>({dataset:{phoneNumber:group.number}});
- log.callLogContainer={appendChild(){}};
- log._updateContact=(_node,number)=>lookedUp.push(number);
- log.renderChunk([{number:'5550199'}]);
- assert.deepEqual(lookedUp,['5550199']);
- });
-}
diff --git a/tests/camera-lifecycle.test.mjs b/tests/camera-lifecycle.test.mjs
deleted file mode 100644
index 89dd441b..00000000
--- a/tests/camera-lifecycle.test.mjs
+++ /dev/null
@@ -1,256 +0,0 @@
-import test from 'node:test';
-import assert from 'node:assert/strict';
-import vm from 'node:vm';
-import {readFileSync} from 'node:fs';
-
-function deferred() {
- let resolve;
- const promise = new Promise(r => { resolve = r; });
- return {promise, resolve};
-}
-
-// Execute the shared adapter, with controllable media promises instead of a sensor.
-function setup(native = false, platformName = 'desktop') {
- const videos = [], streams = [], requests = [], events = new EventTarget();
- const nativeRequests=[], wakeLocks=[];
- let hidden = false, captureGate, playGate, labels = true, requireStopped = false;
- const failures=[];
- class Document extends EventTarget { get hidden() { return hidden; } }
- class Video {
- get srcObject() { return this.stream; }
- set srcObject(value) {
- if(requireStopped && value===null && this.stream?.getTracks().some(t=>t.readyState!=="ended"))
- throw Error("Capture producer is still live");
- this.stream = value;
- }
- constructor() { this.dataset = {}; this.style = {}; this.parentNode={append:canvas=>{canvas.isConnected=true;}}; }
- play() { return playGate?.promise || Promise.resolve(); }
- pause() {}
- remove() { const index=videos.indexOf(this); if(index!==-1)videos.splice(index, 1); }
- }
- const document = new Document();
- Object.assign(document, {
- documentElement: {lang:'en'}, body: {append:video => videos.push(video)},
- querySelector: () => null, querySelectorAll: () => [...videos],
- createElement: tag => tag === 'canvas' ? {style:{},dataset:{},
- remove(){this.isConnected=false;},
- captureStream(){throw Error('Native preview must not recapture the canvas');},
- getContext:()=>new Proxy({
- getShaderParameter:()=>native==='working',getProgramParameter:()=>true,
- },{get(target,name){return target[name] || (()=>({}));}}),
- } : new Video(),
- });
- const navigator = {requestWakeLock(topic){const lock={topic,released:false,unlock(){this.released=true;}};wakeLocks.push(lock);return lock;},language:'en', mediaDevices: {
- async enumerateDevices() {
- return ['rear','selfie'].map(deviceId => ({kind:'videoinput', deviceId,
- label: labels ? (deviceId === 'rear' ? 'Back camera' : 'Front camera') : ''}));
- },
- async getUserMedia(constraints) {
- requests.push(constraints);
- if(failures.length) throw failures.shift();
- const id = constraints.video.deviceId?.exact ||
- (constraints.video.facingMode?.ideal === 'user' ? 'selfie' : 'rear');
- const gate = captureGate;
- captureGate = null;
- if (gate) await gate.promise;
- const track = {readyState:'live', stop() { this.readyState = 'ended'; },
- getSettings: () => ({deviceId:id, width:640, height:480})};
- const stream = Object.assign(new EventTarget(), {
- getTracks: () => [track], getVideoTracks: () => [track],
- });
- streams.push(stream);
- return stream;
- },
- }};
- const context = {document, Document, navigator, HTMLMediaElement:Video, console, performance,
- location:{hostname:'camera.localhost',port:'8765'}, parent:{},
- MutationObserver:class {observe() {}},
- Event, DOMException, MediaStream:class extends EventTarget {getTracks(){return [];}getVideoTracks(){return [];}}, CustomEvent:class extends Event {constructor(type,opts){super(type);this.detail=opts.detail;}},
- setTimeout, clearTimeout, queueMicrotask, setInterval:()=>0, clearInterval(){},
- addEventListener:events.addEventListener.bind(events), dispatchEvent:events.dispatchEvent.bind(events),
- VulpesCompat:{async call(method,params) {
- if(method==='platform.capabilities')return {platform:native?'tundra':platformName,camera:true};
- nativeRequests.push(params);
- if(params.action==='start')return {session:nativeRequests.length,width:640,height:480,orientation:90,focusModes:['fixed'],zoomRatios:[1],pictureSizes:[{width:640,height:480}]};
- if(params.action==='frame')return {sequence:nativeRequests.length,data:new ArrayBuffer(640*480*3/2)};
- }},
- };
- context.window = context;
- vm.runInNewContext(readFileSync(new URL('../host/gaia-camera.js', import.meta.url),'utf8'), context);
- return {api:navigator.mozCameras, streams, requests, videos, nativeRequests, wakeLocks,
- viewfinder:()=>{const video=new Video();videos.push(video);return video;},
- failCapture:error=>failures.push(error),
- unlabelled:()=>{labels=false;},
- requireStoppedBeforeDetach:()=>{requireStopped=true;},
- blockCapture:()=>captureGate=deferred(), blockPlay:()=>playGate=deferred(),
- hide:()=>{hidden=true;events.dispatchEvent(new Event('visibilitychange'));},
- show:()=>{hidden=false;events.dispatchEvent(new Event('visibilitychange'));},
- };
-}
-
-test('unlabelled device identities stay stable after front/back switches', async()=>{
- const s=setup();s.unlabelled();
- for (const [name,id] of [['back','rear'],['front','selfie'],['back','rear'],['front','selfie']]) {
- const {camera}=await s.api.getCamera(name);
- assert.equal(camera.getVideoTracks()[0].getSettings().deviceId,id);
- }
- await s.streams.at(-1).release();
-});
-
-test('native preview setup failure releases the sensor before returning an error', async()=>{
- const s=setup(true);
- await assert.rejects(s.api.getCamera('front'),/Camera shader compilation failed/);
- assert.equal(s.nativeRequests.length,2);
- assert.equal(s.nativeRequests[1].action,'stop');
- assert.equal(s.nativeRequests[1].session,1);
-});
-
-test('switch waits for asynchronous close even after release has detached the current stream', async()=>{
- const s=setup();
- const {camera}=await s.api.getCamera('back');
- const gate=deferred();let calls=0;
- camera.vulpesCameraClose=()=>{calls++;return gate.promise;};
- const release=camera.release();
- assert.equal(camera.release(),release);
- const next=s.api.getCamera('front');
- await new Promise(resolve=>setImmediate(resolve));
- assert.equal(s.requests.length,1);
- gate.resolve();await release;
- const replacement=await next;
- assert.equal(calls,1);
- assert.equal(camera.getTracks()[0].readyState,'ended');
- assert.equal(s.videos.length,1,'only the active capture video is retained');
- await replacement.camera.release();
- assert.equal(s.videos.length,0);
-});
-
-test('failed hardware close still stops tracks and allows reopening', async()=>{
- const s=setup();const {camera}=await s.api.getCamera('back');
- camera.vulpesCameraClose=async()=>{throw Error('device disconnected');};
- await assert.rejects(camera.release(),/device disconnected/);
- assert.equal(camera.getTracks()[0].readyState,'ended');
- const next=await s.api.getCamera('front');
- assert.equal(next.camera.getTracks()[0].readyState,'live');
- await next.camera.release();
-});
-
-test('concurrent switch waits for a pending open and disposes its stale stream', async()=>{
- const s=setup(),gate=s.blockCapture();
- const first=s.api.getCamera('back');
- const rejected=assert.rejects(first,{name:'AbortError'});
- await new Promise(resolve=>setImmediate(resolve));
- const second=s.api.getCamera('front');
- await new Promise(resolve=>setImmediate(resolve));
- assert.equal(s.requests.length,1,'second sensor must not open before first settles');
- gate.resolve();await rejected;
- const {camera}=await second;
- assert.equal(s.streams[0].getTracks()[0].readyState,'ended');
- assert.equal(camera.getVideoTracks()[0].getSettings().deviceId,'selfie');
- await camera.release();
-});
-
-test('hide/show during preview startup leaves no live abandoned stream', async()=>{
- const s=setup(),gate=s.blockPlay();
- const first=s.api.getCamera('back');
- const rejected=assert.rejects(first,{name:'AbortError'});
- await new Promise(resolve=>setImmediate(resolve));
- s.hide();s.show();
- const next=s.api.getCamera('front');
- gate.resolve();await rejected;
- const {camera}=await next;
- assert.equal(s.streams[0].getTracks()[0].readyState,'ended');
- assert.equal(camera.getTracks()[0].readyState,'live');
- await camera.release();
-});
-
-
-test('release stops capture tracks before detaching the preview video',async()=>{
- const s=setup();s.requireStoppedBeforeDetach();
- const {camera}=await s.api.getCamera('back');
- await camera.release();
- assert.equal(camera.getTracks()[0].readyState,'ended');
- const next=await s.api.getCamera('front');
- await next.camera.release();
-});
-
-
-test('release announces stopped before the next camera starts',async()=>{
- const s=setup(), {camera}=await s.api.getCamera('back');
- const states=[];camera.addEventListener('previewstatechange',e=>states.push(e.newState));
- await camera.release();await camera.release();
- assert.deepEqual(states,['stopped']);
-});
-
-test('Gaia viewfinder recomputes a viewport that was hidden at initialization',()=>{
- let view;
- const window={innerWidth:0,innerHeight:0};
- vm.runInNewContext(readFileSync(new URL('../gaia/apps/camera/js/views/viewfinder.js',import.meta.url),'utf8'),{
- window,define(factory){const module={};factory(name=>name==='view'?{extend:value=>value}:name==='debug'?()=>()=>{}:{},{},module);view=module.exports;},
- });
- const sizes=[];
- const instance=Object.assign({},view,{updatePreviewMetrics(){sizes.push([this.container.width,this.container.height]);}});
- instance.getSize();instance.updatePreview({width:480,height:640},0,false);
- assert.equal(sizes.length,0,'hidden window must not produce a zero-size video');
- window.innerWidth=360;window.innerHeight=666;
- instance.updatePreview({width:480,height:640},0,false);
- window.innerWidth=696;window.innerHeight=330;
- instance.updatePreview({width:480,height:640},0,false);
- assert.deepEqual(sizes,[[360,666],[696,330]]);
-});
-
-
-test('native preview uses one canvas, releases its worker and never recaptures video',async()=>{
- const s=setup('working');
- const {camera:first}=await s.api.getCamera('back');
- const canvas=first.vulpesPreviewCanvas;
- assert.ok(first.vulpesTiming.frames>0);
- assert.equal(first.vulpesClosed,false);
- await first.release();
- assert.equal(first.vulpesClosed,true);
- const {camera:second}=await s.api.getCamera('front');
- assert.equal(second.vulpesPreviewCanvas,canvas);
- assert.equal(second.vulpesClosed,false);
- await second.release();
- assert.equal(s.nativeRequests.filter(r=>r.action==='stop').length,2);
- assert.equal(s.wakeLocks.length,2);
- assert.ok(s.wakeLocks.every(lock=>lock.topic==='screen' && lock.released));
-});
-
-
-test('native preview attaches inside Gaia and detaches on camera release',async()=>{
- const s=setup('working'),video=s.viewfinder();
- const {camera}=await s.api.getCamera('front');
- video.mozSrcObject=camera;
- await video.play();
- assert.equal(video.mozSrcObject,camera);
- assert.equal(video.srcObject,undefined,'do not create a native video decoder');
- assert.equal(camera.vulpesPreviewCanvas.isConnected,true);
- assert.equal(video.style.display,'none');
- await camera.release();
- assert.equal(camera.vulpesPreviewCanvas.isConnected,false);
- assert.equal(video.style.display,'');
- assert.equal(camera.vulpesClosed,true);
-});
-
- test('Android waits for a busy camera without changing the selected sensor', async()=>{
- const s=setup(false,'android');
- await s.api.getCamera('back');
- s.failCapture(new DOMException('closing','NotReadableError'));
- const {camera}=await s.api.getCamera('front');
- assert.equal(camera.getVideoTracks()[0].getSettings().deviceId,'selfie');
- assert.equal(s.requests.length,3);
- assert.equal(s.requests[1].video.deviceId.exact,s.requests[2].video.deviceId.exact);
- await camera.release();
- });
- test('Android does not retry camera permission refusal',async()=>{
- const s=setup(false,'android');s.failCapture(new DOMException('denied','NotAllowedError'));
- await assert.rejects(s.api.getCamera('back'),{name:'NotAllowedError'});
- assert.equal(s.requests.length,1);
- });
- test('Android stops camera retry when the app becomes hidden',async()=>{
- const s=setup(false,'android');s.failCapture(new DOMException('closing','NotReadableError'));
- const pending=s.api.getCamera('front');
- const checked=assert.rejects(pending,{name:'AbortError'});
- await new Promise(resolve=>setImmediate(resolve));s.hide();await checked;
- assert.equal(s.requests.length,1);
- });
diff --git a/tests/contacts.test.mjs b/tests/contacts.test.mjs
deleted file mode 100644
index fca77a98..00000000
--- a/tests/contacts.test.mjs
+++ /dev/null
@@ -1,59 +0,0 @@
-import test from 'node:test';
-import assert from 'node:assert/strict';
-import {Contacts} from '../host/Contacts.sys.mjs';
-
-let serial=0;
-globalThis.Services={uuid:{generateUUID:()=>({toString:()=>`test-${++serial}`})}};
-const writer={manifest:{permissions:{contacts:{access:'readwrite'}}}};
-const reader={manifest:{permissions:{contacts:{access:'readonly'}}}};
-
-async function addressBook() {
- let stored;
- const events=[];
- const storage={async run(_store,operation,args) {
- if(operation==='get') return {value:structuredClone(stored)};
- stored=structuredClone(args[0]);
- }};
- const contacts=new Contacts(storage,event=>events.push(event));
- await contacts.init();
- return {contacts,events,storage};
-}
-
-test('contact edits, photo, date and favourite survive reopening the store',async()=>{
- const {contacts,events,storage}=await addressBook();
- const first=await contacts.request(writer,{operation:'save',contact:{
- givenName:['Élodie'],familyName:['Test'],tel:[{value:'+33 6 00 00 00 00'}],
- photo:[new Blob(['portrait'],{type:'image/jpeg'})],bday:new Date('1995-08-19T00:00:00Z'),
- }});
- const edited=await contacts.request(writer,{operation:'save',contact:{...first,familyName:['Modifiée'],category:['favorite']}});
- assert.equal(edited.id,first.id);
- assert.equal(edited.published,first.published);
- const reopened=new Contacts(storage,()=>{});await reopened.init();
- const [row]=await reopened.request(reader,{operation:'find',options:{filterBy:['id'],filterOp:'equals',filterValue:first.id}});
- assert.deepEqual(row.familyName,['Modifiée']);
- assert.deepEqual(row.category,['favorite']);
- assert.equal(await row.photo[0].text(),'portrait');
- assert.equal(row.bday.toISOString(),'1995-08-19T00:00:00.000Z');
- assert.deepEqual(events.map(e=>e.reason),['create','update']);
- await contacts.request(writer,{operation:'remove',id:first.id});
- assert.equal(await contacts.request(reader,{operation:'count'}),0);
-});
-
-test('a failed write emits no contactchange and does not poison later saves',async()=>{
- const {contacts,events,storage}=await addressBook();
- const run=storage.run;
- storage.run=async()=>{throw Error('disk full');};
- await assert.rejects(contacts.request(writer,{operation:'save',contact:{name:['First']}}),/disk full/);
- assert.equal(events.length,0);
- assert.equal(await contacts.request(reader,{operation:'count'}),0);
- storage.run=run;
- await contacts.request(writer,{operation:'save',contact:{name:['Second']}});
- assert.equal(await contacts.request(reader,{operation:'count'}),1);
-});
-
-test('read-only callers cannot change the address book',async()=>{
- const {contacts}=await addressBook();
- for(const operation of ['save','remove','clear'])
- await assert.rejects(contacts.request(reader,{operation,contact:{name:['Denied']},id:'anything'}),/PERMISSION_DENIED/);
- await assert.rejects(contacts.request({manifest:{}},{operation:'find'}),/PERMISSION_DENIED/);
-});
diff --git a/tests/input.test.mjs b/tests/input.test.mjs
index 10dcb79f..0318b17f 100644
--- a/tests/input.test.mjs
+++ b/tests/input.test.mjs
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
-import {Input} from '../services/input.mjs';
+import {Input} from '../host/Input.sys.mjs';
function setup() {
const events=[];
const input=new Input({identity:actor=>actor.app,broadcast:(...args)=>events.push(args)});
@@ -30,20 +30,3 @@ test('current edit updates the focused field; keyboard focus does not replace it
input.changed(field,{text:''});input.changed(keyboard,{text:'unrelated'});
assert.equal((await input.request(keyboard,{operation:'key',id:1})).text,'123');
});
-
-test('a composer button retains input and other buttons dismiss only after click',async()=>{
- const {readFile}=await import('node:fs/promises');
- const {runInNewContext}=await import('node:vm');
- const source=await readFile(new URL('../host/actors/VulpesChild.sys.mjs',import.meta.url),'utf8');
- const events=[];
- const Actor=runInNewContext(source.replace(/^import .*;\n/, '').replace('export class','class')+';VulpesChild',{
- JSWindowActorChild:class{},Services:{cpmm:{sharedData:{get:()=>true}}}
- });
- const actor=new Actor();actor.sendAsyncMessage=(...args)=>events.push(args);
- const button={localName:'button',closest:()=>null};
- actor.handleEvent({type:'pointerdown',target:button});assert.equal(events.length,0);
- actor.handleEvent({type:'click',target:button});assert.equal(events.length,1);assert.equal(events[0][1],null);
- button.closest=()=>button;
- actor.handleEvent({type:'pointerdown',target:button});actor.handleEvent({type:'click',target:button});
- assert.equal(events.length,1,'send keeps the composer focused');
-});
diff --git a/tests/installed_apps_test.py b/tests/installed_apps_test.py
deleted file mode 100644
index f85116dd..00000000
--- a/tests/installed_apps_test.py
+++ /dev/null
@@ -1,28 +0,0 @@
-import base64
-import json
-from pathlib import Path
-import sys
-import tempfile
-import unittest
-sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'tools'))
-from installed_apps import InstalledApps
-
-class StoreTests(unittest.TestCase):
- def test_persistence_removal_and_permission_boundary(self):
- with tempfile.TemporaryDirectory() as folder:
- store=InstalledApps(folder,8765)
- item=dict(id='user-'+'1'*24,manifest=dict(name='Test',type='certified',permissions={'settings':{}}),files={'index.html':base64.b64encode(b'hello').decode()})
- store.save(item)
- restored=InstalledApps(folder,8765)
- self.assertEqual(restored.read(item['id'],'index.html'),b'hello')
- self.assertEqual(restored.records()[0]['manifest']['permissions'],{})
- self.assertEqual(restored.records()[0]['manifest']['type'],'web')
- self.assertEqual(json.loads(restored.read(item['id'],'manifest.webapp'))['permissions'],{})
- self.assertEqual((Path(folder)/'.token').stat().st_mode & 0o777,0o600)
- self.assertIsNone(restored.read(item['id'],'../.token'))
- with self.assertRaises(ValueError):restored.remove('system')
- with self.assertRaises(ValueError):restored.save({**item,'id':'../escape'})
- restored.remove(item['id'])
- self.assertEqual(InstalledApps(folder,8765).records(),[])
-
-if __name__=='__main__':unittest.main()
diff --git a/tests/keyboard-dictionaries.test.mjs b/tests/keyboard-dictionaries.test.mjs
deleted file mode 100644
index f4991d54..00000000
--- a/tests/keyboard-dictionaries.test.mjs
+++ /dev/null
@@ -1,24 +0,0 @@
-import test from 'node:test';
-import assert from 'node:assert/strict';
-import vm from 'node:vm';
-import {readFileSync} from 'node:fs';
-const root=new URL('../',import.meta.url);
-const source=readFileSync(new URL('gaia/apps/keyboard/js/settings/layout_dictionary_downloader.js',root),'utf8');
-test('every offered Latin dictionary is present and has a valid header',()=>{
- const layouts=JSON.parse(readFileSync(new URL('assets/webapps/keyboard.gaiamobile.org/js/settings/layouts.json',root)));
- for(const name of new Set(layouts.filter(l=>l.imEngineId==='latin'&&l.dictFilename).map(l=>l.dictFilename))){
- const data=readFileSync(new URL('gaia/apps/keyboard/js/imes/latin/dictionaries/'+name,root));
- assert.equal(data.subarray(0,8).toString(),'FxOSDICT',name);
- }
-});
-test('adding a language reads and verifies its local dictionary',async()=>{
- let requested,aborted=false;
- class XHR {
- open(method,url){requested=url;}
- send(){const b=readFileSync(new URL('gaia/apps/keyboard'+requested,root));this.response=b.buffer.slice(b.byteOffset,b.byteOffset+b.byteLength);this.onloadend();}
- abort(){aborted=true;}
- }
- const window={};vm.runInNewContext(source,{window,XMLHttpRequest:XHR,ArrayBuffer,Uint8Array,Promise});
- const data=await new window.LayoutDictionaryDownloader('latin','it.dict').load();
- assert.equal(requested,'/js/imes/latin/dictionaries/it.dict');assert.ok(data.byteLength>8);assert.equal(aborted,false);
-});
diff --git a/tests/keyboard-startup.test.mjs b/tests/keyboard-startup.test.mjs
deleted file mode 100644
index 9b7641d8..00000000
--- a/tests/keyboard-startup.test.mjs
+++ /dev/null
@@ -1,40 +0,0 @@
-import test from 'node:test';
-import assert from 'node:assert/strict';
-import vm from 'node:vm';
-import {readFileSync} from 'node:fs';
-
-const source=readFileSync(new URL('../host/gaia-input.js',import.meta.url),'utf8');
-function keyboard() {
- const listeners=new Map();let reply;
- const parent={postMessage(){}};
- const context={VulpesNativeInput:true,location:{hostname:'keyboard.localhost'},
- VulpesCompat:{call:()=>new Promise(resolve=>reply=resolve)},
- navigator:{},document:{readyState:'loading'},parent,console,Event,EventTarget,
- CustomEvent:class extends Event {constructor(name,options){super(name);this.detail=options.detail;}},
- addEventListener(name,fn){listeners.set(name,fn);},dispatchEvent(){}};
- context.window=context;
- vm.runInNewContext(source,context);
- return {
- ime:context.navigator.mozInputMethod,
- state:value=>listeners.get('vulpes-service-event')({detail:{type:'input-focus',data:value}}),
- reply:value=>reply(value),
- viewport:()=>listeners.get('message')({source:parent,origin:'http://system.localhost:8765',
- data:{type:'vulpes-keyboard-viewport',width:360,height:740}})
- };
-}
-test('an old empty snapshot cannot clear a newly focused keyboard',async()=>{
- const k=keyboard();k.viewport();k.state({id:2,text:'current'});k.reply(null);
- await new Promise(resolve=>setImmediate(resolve));
- assert.equal(k.ime.inputcontext.text,'current');
-});
-test('an old active snapshot cannot reopen a dismissed keyboard',async()=>{
- const k=keyboard();k.viewport();k.state(null);k.reply({id:1,text:'old'});
- await new Promise(resolve=>setImmediate(resolve));
- assert.equal(k.ime.inputcontext,null);
-});
-test('focus received before viewport measurement survives a stale initial reply',async()=>{
- const k=keyboard();k.state({id:3,text:'pending'});k.reply(null);
- await new Promise(resolve=>setImmediate(resolve));
- k.viewport();
- assert.equal(k.ime.inputcontext.text,'pending');
-});
diff --git a/tests/marketplace-navigation.test.mjs b/tests/marketplace-navigation.test.mjs
deleted file mode 100644
index bf8ae8e6..00000000
--- a/tests/marketplace-navigation.test.mjs
+++ /dev/null
@@ -1,34 +0,0 @@
-import test from 'node:test';
-import assert from 'node:assert/strict';
-import vm from 'node:vm';
-import {readFileSync} from 'node:fs';
-const source=readFileSync(new URL('../gaia/apps/marketplace/marketplace.js',import.meta.url),'utf8');
-function element(){return Object.assign(new EventTarget(),{style:{},hidden:false,textContent:''});}
-async function setup() {
- const nodes=Object.fromEntries(['catalogue','message','confirm','open','browse','install','cancel','status','title','question','permissions'].map(id=>[id,element()]));
- let poll,download=null,finish;
- const context={document:{getElementById:id=>nodes[id]},navigator:{language:'en'},console,
- addEventListener(){},setInterval:fn=>poll=fn,VulpesCompat:{call:async method=>{
- if(method==='locale.current')return 'en';
- if(method==='marketplace.poll'){const url=download;download=null;return url;}
- if(method==='marketplace.prepare')return new Promise(resolve=>finish=resolve);
- }}};
- await vm.runInNewContext(source,context);
- return {nodes,poll,setDownload:()=>download='https://vulpes-os.org/marketplace/index.php?download=1',finish:()=>finish({name:'Test',bytes:1024,token:'test'})};
-}
-test('closing the Android web view exposes a way back to the catalogue, not a blank frame',async()=>{
- const {nodes:n}=await setup();
- n.catalogue.dispatchEvent(new Event('mozbrowserclose'));
- assert.equal(n.message.hidden,false);assert.equal(n.catalogue.style.visibility,'hidden');
- assert.match(n.status.textContent,/reopen/);assert.equal(n.open.hidden,true);
- n.browse.onclick();
- assert.equal(n.message.hidden,true);assert.equal(n.catalogue.style.visibility,'visible');
- assert.match(n.catalogue.src,/mode=app&lang=en/);
-});
-test('a close notification cannot erase an installation confirmation',async()=>{
- const f=await setup();f.setDownload();const pending=f.poll();
- await new Promise(resolve=>setImmediate(resolve));
- f.nodes.catalogue.dispatchEvent(new Event('mozbrowserclose'));
- assert.match(f.nodes.status.textContent,/Checking/);
- f.finish();await pending;assert.equal(f.nodes.confirm.hidden,false);
-});
diff --git a/tests/messages.test.mjs b/tests/messages.test.mjs
index 68519a97..4b0b58c3 100644
--- a/tests/messages.test.mjs
+++ b/tests/messages.test.mjs
@@ -97,13 +97,3 @@ test('pending transmission status survives a host restart',async()=>{
assert.deepEqual(events,['sent']);
assert.equal((await restarted.request(app,{operation:'getMessages'}))[0].delivery,'sent');
});
-
-test('unsupported MMS never invokes the SMS modem or records a sent message',async()=>{
- let submissions=0;
- const service=new Messages(store(),async()=>{submissions++;return {};});
- for(const operation of ['sendMMS','retrieveMMS']) {
- await assert.rejects(service.request(app,{operation,args:[{}]}),/NotSupportedError/);
- }
- assert.equal(submissions,0);
- assert.deepEqual(await service.request(app,{operation:'getMessages'}),[]);
-});
diff --git a/tests/mms.test.mjs b/tests/mms.test.mjs
deleted file mode 100644
index e8198836..00000000
--- a/tests/mms.test.mjs
+++ /dev/null
@@ -1,34 +0,0 @@
-import test from 'node:test';
-import assert from 'node:assert/strict';
-import {Messages} from '../host/Messages.sys.mjs';
-import {mmsPayload,receivedMms} from '../services/mms.mjs';
-const app={manifest:{permissions:{sms:{}}}};
-const part={id:'photo.jpg',location:'photo.jpg',content:{__vulpesBlobV1:true,type:'image/jpeg',base64:'/9j/2Q=='}};
-const payload={receivers:['+15550199'],subject:'Photo',smil:'',attachments:[part]};
-function store(){let rows=[];return {run:async(_,op,args)=>op==='get'?{value:structuredClone(rows)}:(rows=structuredClone(args[0]))};}
-test('MMS remains pending until MMSC acceptance and keeps attachments',async()=>{
- const events=[];let status={state:'pending'};
- const mms={validate:mmsPayload,send:async p=>{assert.deepEqual(p,payload);return {path:'id',state:'pending'};},status:async()=>status};
- const s=new Messages(store(),null,(t,m)=>events.push([t,m]),null,mms);
- const message=await s.request(app,{operation:'sendMMS',args:[payload]});assert.equal(message.delivery,'sending');
- await s.refreshOutgoing();assert.equal(events.length,1);
- status={state:'sent'};await s.refreshOutgoing();assert.equal(events.at(-1)[0],'sent');
- assert.deepEqual(events.at(-1)[1].attachments,[part]);
- assert.deepEqual((await s.request(app,{operation:'getThreads'}))[0].participants,payload.receivers);
-});
-test('bearer failure retains failed MMS bubble without a sent event',async()=>{
- const events=[];const s=new Messages(store(),null,t=>events.push(t),null,{validate:mmsPayload,send:async()=>({path:'id'}),status:async()=>({state:'failed',error:'MMS_DATA_UNAVAILABLE'})});
- await s.request(app,{operation:'sendMMS',args:[payload]});await s.refreshOutgoing();
- const m=(await s.request(app,{operation:'getMessages'}))[0];assert.equal(m.delivery,'error');assert.equal(m.error,'MMS_DATA_UNAVAILABLE');assert.deepEqual(m.attachments,[part]);assert.deepEqual(events,['sending','failed']);
-});
-test('incoming MMS is deduplicated and preserves native attachment blobs',async()=>{
- const row=receivedMms({receipt:'push-1',type:'mms',sender:'+15550199',receivers:[],parts:[{id:'photo.jpg',type:'image/jpeg',data:'/9j/2Q=='},{id:'text.txt',type:'text/plain',data:btoa('Hello')}]});
- const s=new Messages(store());const a=await s.receive(row),b=await s.receive(row);
- assert.equal(a.id,b.id);assert.equal(a.body,'Hello');assert.equal(a.type,'mms');assert.equal(a.attachments[0].content.base64,part.content.base64);
- assert.equal((await s.request(app,{operation:'getThreads'}))[0].lastMessageType,'mms');
-});
-test('reject invalid recipients, missing blobs and oversized MMS',()=>{
- assert.throws(()=>mmsPayload({...payload,receivers:['file:///etc/passwd']}));
- assert.throws(()=>mmsPayload({...payload,attachments:[{content:{}}]}));
- assert.throws(()=>mmsPayload({...payload,attachments:[{...part,content:{...part.content,base64:'A'.repeat(900000)}}]}));
-});
diff --git a/tests/packages.test.mjs b/tests/packages.test.mjs
deleted file mode 100644
index b5652611..00000000
--- a/tests/packages.test.mjs
+++ /dev/null
@@ -1,35 +0,0 @@
-import assert from 'node:assert/strict';
-import {execFileSync} from 'node:child_process';
-import {unpackPackage,packageURL,Packages} from '../services/packages.mjs';
-const source='https://vulpes-os.org/marketplace/index.php?download=42';
-function zip(extra={},manifest={}) {
- return new Uint8Array(execFileSync('python3',['-c',`import sys,io,zipfile,json
-b=io.BytesIO();args=json.loads(sys.argv[1])
-with zipfile.ZipFile(b,'w',zipfile.ZIP_DEFLATED) as z:
- z.writestr('manifest.webapp',json.dumps(dict(name='Test',launch_path='/index.html',icons={'128':'/icon.png'},**args['manifest'])))
- z.writestr('index.html','Test ');z.writestr('icon.png',b'icon')
- for name,value in args['extra'].items():z.writestr(name,value)
-sys.stdout.buffer.write(b.getvalue())`,JSON.stringify({extra,manifest})]));
-}
-assert.equal(packageURL(source+'&lang=fr'),source);
-for(const url of ['http://vulpes-os.org/marketplace/index.php?download=42','https://evil.test/marketplace/index.php?download=42','https://vulpes-os.org/x?download=42','https://vulpes-os.org/marketplace/index.php?download=../42'])assert.throws(()=>packageURL(url));
-const bytes=zip({}, {type:'certified',permissions:{settings:{access:'readwrite'},'webapps-manage':{}}});
-const item=await unpackPackage(bytes,source);
-assert.equal(item.manifest.type,'web');assert.deepEqual(item.manifest.permissions,{});assert.match(item.id,/^user-[a-f0-9]{24}$/);
-for(const name of ['../escape','/absolute','dir/../../escape','dir\\escape','a:b'])await assert.rejects(unpackPackage(zip({[name]:'bad'}),source));
-await assert.rejects(unpackPackage(bytes.slice(0,-2),source));
-const corrupt=bytes.slice();corrupt[100]^=255;await assert.rejects(unpackPackage(corrupt,source));
-let saved=null,changes=0;
-const service=new Packages({save:async value=>saved=value,changed:async()=>changes++,uuid:()=> 'token',fetchPackage:async()=>new Response(bytes)});
-const prepared=await service.prepare(source);assert.equal(saved,null);assert.equal(prepared.name,'Test');
-assert.equal(await service.install(prepared.token),item.id);assert.equal(changes,1);assert.equal(saved.id,item.id);
-await assert.rejects(service.install(prepared.token),/INSTALL_EXPIRED/);
-console.log('Package URL policy, ZIP boundaries/CRC, permission stripping and one-use confirmation: OK');
-
-// Cancellation during download must not produce a confirmation token.
-let finishDownload;
-const cancelled=new Packages({save:async()=>assert.fail('unexpected save'),changed:async()=>{},
- fetchPackage:()=>new Promise(resolve=>{finishDownload=resolve;})});
-const pending=cancelled.prepare(source);cancelled.cancel();finishDownload(new Response(bytes));
-await assert.rejects(pending,/DOWNLOAD_CANCELLED/);assert.equal(cancelled.pending.size,0);
-console.log('Cancelled download cannot be installed: OK');
diff --git a/tests/platform.test.mjs b/tests/platform.test.mjs
index 838207ab..31653717 100644
--- a/tests/platform.test.mjs
+++ b/tests/platform.test.mjs
@@ -49,40 +49,3 @@ for (const params of [{action:'focus',area:{left:-100,top:-100,right:100,bottom:
for (const params of [{action:'zoom',zoom:NaN},{action:'focus',area:{left:2,right:1,top:0,bottom:1}},
{action:'picture',size:{width:-1,height:100}},{action:'execute'}])
assert.throws(()=>authorizePlatform(camera,'cameraPreview',params),/INVALID_VALUE/);
-
-for (const action of ['sim','smsc','calls','messages','deliveryReports']) {
- const params={action,...(action==='deliveryReports'?{enabled:true}:{})};
- authorizePlatform(settings,'mobileSettings',params);
- for (const id of ['system','communications','sms','camera','marketplace'])
- rejects(app(id),'mobileSettings',params);
-}
-for (const params of [{action:'enterPin',pin:'1234'},{action:'deliveryReports',enabled:'true'}])
- assert.throws(()=>authorizePlatform(settings,'mobileSettings',params),/INVALID_VALUE/);
-console.log('PASS: mobile Settings queries are restricted to Settings; unsafe actions rejected.');
-
-for(const action of ['network','dataEnabled','roaming']) {
- const params={action,...(action==='network'?{}:{enabled:false})};
- authorizePlatform(settings,'mobileSettings',params);
- rejects(app('system'),'mobileSettings',params);
-}
-for(const action of ['clock','setTimezone','setTime']) {
- authorizePlatform(settings,'deviceSettings',{action});
- for(const id of ['system','communications','marketplace'])rejects(app(id),'deviceSettings',{action});
-}
-assert.throws(()=>authorizePlatform(settings,'deviceSettings',{action:'execute'}),/INVALID_VALUE/);
-assert.throws(()=>authorizePlatform(settings,'mobileSettings',{action:'roaming',enabled:1}),/INVALID_VALUE/);
-
-for(const action of ['radioMode','setRadioMode','apns','editApn']) {
- authorizePlatform(settings,'mobileSettings',{action});
- for(const id of ['system','communications','marketplace'])rejects(app(id),'mobileSettings',{action});
-}
-
-for(const params of [{action:'calls'},{action:'forwarding'},{action:'setCallerId',mode:'default'},{action:'setCallWaiting',enabled:true},{action:'setForwarding',reason:'busy',enabled:false,number:''}]) {
- authorizePlatform(settings,'mobileSettings',params);
- for(const id of ['system','communications','marketplace'])rejects(app(id),'mobileSettings',params);
-}
-
-for(const operation of ['hotspotStatus','hotspotSave','hotspotPower']) {
- authorizePlatform(settings,'wifi',{operation});
- for(const id of ['system','communications','marketplace'])rejects(app(id),'wifi',{operation});
-}
diff --git a/tests/settings-codec.test.mjs b/tests/settings-codec.test.mjs
deleted file mode 100644
index 970495b0..00000000
--- a/tests/settings-codec.test.mjs
+++ /dev/null
@@ -1,35 +0,0 @@
-import test from 'node:test';
-import assert from 'node:assert/strict';
-import vm from 'node:vm';
-import {readFileSync} from 'node:fs';
-
-const context = {window:{}, Blob, Date, btoa, atob, Uint8Array};
-vm.runInNewContext(readFileSync(new URL('../host/settings-codec.js',import.meta.url),'utf8'),context);
-const codec=context.window.VulpesSettingsCodec;
-test('native media replies retain the structured-cloned Blob and date', async()=>{
- const blob=new Blob(['jpeg bytes'],{type:'image/jpeg'}), date=new Date();
- const result=codec.decode({blob,lastModified:date});
- assert.equal(result.blob,blob);
- assert.equal(result.lastModified,date);
- assert.equal(await result.blob.text(),'jpeg bytes');
-});
-test('Android JSON media and settings still round-trip binary content',async()=>{
- const source=new Blob([new Uint8Array([0,128,255,10])],{type:'image/png'});
- const wire=JSON.parse(JSON.stringify(await codec.encode({photo:source})));
- const result=codec.decode(wire);
- assert.equal(result.photo.type,'image/png');
- assert.deepEqual(new Uint8Array(await result.photo.arrayBuffer()),new Uint8Array([0,128,255,10]));
-});
-test('a near-match marker is ordinary data, not an injected Blob',()=>{
- const result=codec.decode({__vulpesBlobV1:true,type:'image/png',base64:'AA==',other:1});
- assert.equal(result.other,1);
- assert.equal(result instanceof Blob,false);
-});
-
-test('Android contact birthdays keep their ISO date instead of becoming empty objects',async()=>{
- const date=new Date('1995-08-19T00:00:00.000Z');
- const wire=await codec.encode({bday:date,photo:[new Blob(['portrait'],{type:'image/jpeg'})]});
- const result=codec.decode(JSON.parse(JSON.stringify(wire)));
- assert.equal(result.bday,date.toISOString());
- assert.equal(await result.photo[0].text(),'portrait');
-});
diff --git a/tests/settings-dialogs.test.mjs b/tests/settings-dialogs.test.mjs
deleted file mode 100644
index 2972e775..00000000
--- a/tests/settings-dialogs.test.mjs
+++ /dev/null
@@ -1,53 +0,0 @@
-import test from 'node:test';
-import assert from 'node:assert/strict';
-import vm from 'node:vm';
-import {readFile} from 'node:fs/promises';
-const load=name=>readFile(new URL('../gaia/apps/settings/js/modules/'+name+'.js',import.meta.url),'utf8');
-const serviceSource=await load('dialog_service');
-const managerSource=await load('dialog_manager');
-const defer=()=>{let resolve,reject;const promise=new Promise((a,b)=>{resolve=a;reject=b;});return {promise,resolve,reject};};
-const settle=()=>new Promise(resolve=>setImmediate(resolve));
-function serviceFixture() {
- let service;const opens=[],closes=[];let rejectClose=false;
- const panel=Object.assign(new EventTarget(),{classList:{remove(){}},hidden:false});
- const factory=()=>({panel,cleanup(){}});
- const manager={open(dialog,options){const d=defer();opens.push({options,...d});return d.promise;},close(dialog,type){closes.push(type);return rejectClose?Promise.reject(Error('invalid')):Promise.resolve('value');}};
- vm.runInNewContext(serviceSource,{Promise,CustomEvent,document:{getElementById:()=>panel,querySelector:()=>null},define:fn=>{service=fn(name=>name==='settings'?{currentPanel:'#root'}:name==='modules/defer'?defer:name==='modules/dialog_manager'?manager:factory);}});
- return {service,opens,closes,panel,reject:value=>{rejectClose=value;}};
-}
-test('early cancel waits for opening and queued reopen runs after closure',async()=>{
- const f=serviceFixture();const first=f.service.show('one');const second=f.service.show('two');
- f.opens[0].options.onWrapCancel();f.opens[0].options.onWrapCancel();
- await settle();assert.equal(f.closes.length,0);assert.equal(f.opens.length,1);
- f.opens[0].resolve();assert.equal((await first).type,'cancel');await settle();
- assert.equal(f.closes.length,1);assert.equal(f.opens.length,2);
- f.opens[1].resolve();f.opens[1].options.onWrapCancel();await second;assert.equal(f.service._navigating,false);
-});
-test('failed validation preserves dialog and permits a later submit',async()=>{
- const f=serviceFixture();let failures=0;f.panel.addEventListener('dialogvalidationfailed',()=>failures++);
- const shown=f.service.show('one');f.opens[0].resolve();await settle();
- f.reject(true);f.opens[0].options.onWrapSubmit();await settle();
- assert.equal(failures,1);assert.equal(f.service._navigating,true);
- f.reject(false);f.opens[0].options.onWrapSubmit();assert.equal((await shown).type,'submit');assert.equal(f.service._navigating,false);
-});
-function transitionFixture(duration='0.4s') {
- let manager,next=0;const timers=new Map();const classes=new Set();
- const panel=Object.assign(new EventTarget(),{hidden:true,classList:{toggle(name,on){on?classes.add(name):classes.delete(name);}}});
- vm.runInNewContext(managerSource,{Promise,document:{querySelector:()=>({})},window:{getComputedStyle:()=>({transitionDuration:duration,transitionDelay:'0s'})},
- setTimeout:(fn,ms)=>{timers.set(++next,{fn,ms});return next;},clearTimeout:id=>timers.delete(id),define:fn=>{manager=fn(()=>({}));}});
- return {manager,panel,classes,timers,step(){const [id,timer]=timers.entries().next().value;timers.delete(id);timer.fn();return timer.ms;}};
-}
-test('missing visibility transition and disabled animations both complete',async()=>{
- for(const duration of ['0.4s','0s']) {
- const f=transitionFixture(duration);const opened=f.manager._transit('open',{panel:f.panel},{});
- assert.equal(f.panel.hidden,false);assert.equal(f.step(),150);assert(f.classes.has('current'));
- assert.equal(f.step(),duration==='0s'?100:500);await opened;
- const closed=f.manager._transit('close',{panel:f.panel},{});f.step();f.step();await closed;
- assert.equal(f.panel.hidden,true);assert.equal(f.timers.size,0);
- }
-});
-test('cancelled visibility transition resolves once and removes fallback',async()=>{
- const f=transitionFixture();const p=f.manager._transit('close',{panel:f.panel},{});f.step();
- const e=new Event('transitioncancel');Object.defineProperty(e,'propertyName',{value:'visibility'});f.panel.dispatchEvent(e);await p;
- assert.equal(f.panel.hidden,true);assert.equal(f.timers.size,0);
-});
diff --git a/tests/settings-panel-cache.test.mjs b/tests/settings-panel-cache.test.mjs
deleted file mode 100644
index 01cc7e87..00000000
--- a/tests/settings-panel-cache.test.mjs
+++ /dev/null
@@ -1,29 +0,0 @@
-import test from 'node:test';
-import assert from 'node:assert/strict';
-import vm from 'node:vm';
-import {readFileSync} from 'node:fs';
-const source=readFileSync(new URL('../gaia/apps/settings/js/modules/panel_cache.js',import.meta.url),'utf8');
-function fixture(load) {
- let cache;
- function require(id,ok,fail) {
- if(Array.isArray(id))return load(ok,fail);
- if(id==='shared/lazy_loader')return {load:(_,done)=>done()};
- return ()=>({beforeShow(){}});
- }
- vm.runInNewContext(source,{define:factory=>cache=factory(require),navigator:{addIdleObserver(){}},
- document:{getElementById:()=>({querySelector:()=>({dataset:{path:'panels/storage/panel'}})})}});
- return cache;
-}
-for(const failure of ['dependency','constructor','invalid']) test('Panel cache reports '+failure+' failure without caching a broken panel',()=>{
- let broken=true;
- const expected=new Error('storage unavailable');
- const cache=fixture((ok,fail)=>{
- if(!broken)return ok(()=>({beforeShow(){}}));
- if(failure==='dependency')return fail(expected);
- ok(()=>{if(failure==='constructor')throw expected;return {};});
- });
- let calls=0;
- cache.get('mediaStorage',(panel,error)=>{calls++;assert.equal(panel,null);assert.ok(error);});
- assert.equal(calls,1);broken=false;
- cache.get('mediaStorage',(panel,error)=>{assert.equal(error,undefined);assert.equal(typeof panel.beforeShow,'function');});
-});
diff --git a/tests/settings-startup.test.mjs b/tests/settings-startup.test.mjs
deleted file mode 100644
index 3a18412e..00000000
--- a/tests/settings-startup.test.mjs
+++ /dev/null
@@ -1,19 +0,0 @@
-import test from 'node:test';
-import assert from 'node:assert/strict';
-import vm from 'node:vm';
-import {readFileSync} from 'node:fs';
-const source=readFileSync(new URL('../gaia/apps/settings/js/startup.js',import.meta.url),'utf8');
-const bootstrap=source.slice(source.lastIndexOf('(function() {'));
-test('Settings waits for deferred components before loading AMD modules',()=>{
- let starts=0;
- const document=Object.assign(new EventTarget(),{readyState:'interactive'});
- vm.runInNewContext(bootstrap,{document,AppStarter:()=>({start:()=>starts++})});
- assert.equal(starts,0);
- document.dispatchEvent(new Event('DOMContentLoaded'));assert.equal(starts,1);
- document.dispatchEvent(new Event('DOMContentLoaded'));assert.equal(starts,1);
-});
-test('Settings starts immediately when loaded after the document',()=>{
- let starts=0;
- vm.runInNewContext(bootstrap,{document:{readyState:'complete'},AppStarter:()=>({start:()=>starts++})});
- assert.equal(starts,1);
-});
diff --git a/tests/settings-transitions.test.mjs b/tests/settings-transitions.test.mjs
deleted file mode 100644
index dbd37cca..00000000
--- a/tests/settings-transitions.test.mjs
+++ /dev/null
@@ -1,29 +0,0 @@
-import test from 'node:test';
-import assert from 'node:assert/strict';
-import vm from 'node:vm';
-import {readFile} from 'node:fs/promises';
-const source=await readFile(new URL('../gaia/apps/settings/js/modules/page_transitions.js',import.meta.url),'utf8');
-function fixture(duration='0.4s') {
- let module, timer, delay, calls=0, ready=0;
- const panel=Object.assign(new EventTarget(),{id:'wifi',className:''});
- vm.runInNewContext(source,{define:fn=>{module=fn();},CustomEvent,
- window:{scrollX:0,scrollY:0,dispatchEvent:()=>ready++,getComputedStyle:()=>({transitionDuration:duration,transitionDelay:'0s'})},
- setTimeout:(cb,ms)=>{timer=cb;delay=ms;return 1;},clearTimeout:()=>{}});
- module.oneColumn({id:'root',className:'current'},panel,()=>calls++);
- return {panel,finish:()=>timer(),state:()=>({calls,ready,delay})};
-}
-test('Settings reaches onShow when no transition event arrives',()=>{
- const f=fixture();assert.equal(f.state().calls,0);f.finish();
- assert.deepEqual(f.state(),{calls:1,ready:1,delay:500});
- f.panel.dispatchEvent(new Event('transitionend'));f.finish();
- assert.equal(f.state().calls,1);
-});
-test('normal and cancelled transitions finish exactly once',()=>{
- for(const type of ['transitionend','transitioncancel']){
- const f=fixture();f.panel.dispatchEvent(new Event(type));f.finish();
- assert.equal(f.state().calls,1);assert.equal(f.state().ready,1);
- }
-});
-test('disabled animations do not leave navigation pending',()=>{
- const f=fixture('0s');assert.equal(f.state().delay,100);f.finish();assert.equal(f.state().calls,1);
-});
diff --git a/tests/test_changelog.py b/tests/test_changelog.py
deleted file mode 100644
index a3ac8a12..00000000
--- a/tests/test_changelog.py
+++ /dev/null
@@ -1,152 +0,0 @@
-"""Changelog exports and remote publication boundaries."""
-import copy
-import importlib.util
-import json
-import re
-from pathlib import Path
-import tempfile
-import unittest
-from unittest.mock import patch
-
-ROOT = Path(__file__).resolve().parents[1]
-
-
-def module(name, file):
- spec = importlib.util.spec_from_file_location(name, ROOT/'tools'/file)
- result = importlib.util.module_from_spec(spec)
- spec.loader.exec_module(result)
- return result
-
-
-export = module('export', 'export-changelog.py')
-sync = module('sync', 'sync-forgejo-changelog.py')
-
-
-class ChangelogTests(unittest.TestCase):
- def setUp(self):
- self.source, self.languages = export.load_source(ROOT/'changelog.json')
- self.tag = 'android-2.7-preview.14'
-
- def test_all_outputs_reuse_source_entries(self):
- with tempfile.TemporaryDirectory() as d:
- root = Path(d)
- files = export.generate(ROOT/'changelog.json', root, root/'site.json', root/'releases')
- data = json.loads(files[root/'site.json'])
- self.assertEqual(data['languages'], self.languages)
- for lang, name in [('fr', 'CHANGELOG.md'), ('en', 'CHANGELOG.en.md')]:
- for entry in self.source['entries']:
- self.assertIn(entry['text'][lang], files[root/name])
-
- def test_range_and_summary(self):
- documents = export.release_documents(self.source, self.tag)
- self.assertTrue(documents['release-body.md'].startswith(self.source['releases'][self.tag]['summary']))
- for lang in export.LANGUAGES:
- text = documents[f'CHANGELOG.{lang}.md']
- self.assertIn('## 30/09/2026 : 2.7-preview.14', text)
- self.assertNotIn('## 2.7-156.0.1', text)
- future = copy.deepcopy(self.source['entries'][0])
- future['id'] = 'future'
- self.source['entries'].insert(0, future)
- self.assertEqual(documents, export.release_documents(self.source, self.tag))
-
- def test_scope_and_fixed_ids(self):
- self.assertTrue(any(g['platforms'] == ['pc', 'models']
- for entry in self.languages['fr'] for g in entry['groups']))
- self.assertEqual(len(self.source['entries']), len({e['id'] for e in self.source['entries']}))
-
- def test_invalid_source_rejected_before_output(self):
- with tempfile.TemporaryDirectory() as d:
- p = Path(d)/'source.json'
- del self.source['entries'][0]['text']['en']
- p.write_text(json.dumps(self.source))
- with self.assertRaisesRegex(ValueError, 'Missing translation'):
- export.generate(p, Path(d), Path(d)/'site.json', Path(d)/'releases')
- self.assertFalse((Path(d)/'site.json').exists())
-
- def test_grouped_entries_keep_explicit_platforms_in_both_languages(self):
- for language in export.LANGUAGES:
- for source, rendered in zip(self.source['entries'], self.languages[language]):
- self.assertEqual(rendered['platforms'], source['metadata']['platforms'])
- self.assertEqual(rendered['models'], source['metadata']['models'])
- self.assertEqual(rendered['status'], source['metadata']['status'])
- self.assertEqual({p for group in rendered['groups'] for p in group['platforms']},
- set(rendered['platforms']))
-
- def test_date_first_heading_and_platform_badges(self):
- for language in export.LANGUAGES:
- entry = next(e for e in self.languages[language] if e['id'] == '2.7-preview.20')
- self.assertEqual(entry['id'], '2.7-preview.20')
- self.assertEqual(entry['date'], '2026-10-13')
- common = next(g for g in entry['groups'] if g['common'])
- self.assertEqual(common['platforms'], ['pc', 'apk', 'models'])
- web = next(g for g in entry['groups'] if g['name'] == 'Web')
- self.assertEqual(web['platforms'], ['web'])
- self.assertIn('>Web', web['nameHtml'])
- self.assertEqual(entry['notes'], '')
- self.assertEqual(export.group_scope('')[1], ['pc', 'models'])
- with self.assertRaisesRegex(ValueError, 'Unknown platform'):
- export.group_scope('Unknown')
-
- def test_tags_alphabetical_in_every_platform_group(self):
- for entry in self.source['entries']:
- for lang in export.LANGUAGES:
- for group in re.split(r'^### ', entry['text'][lang], flags=re.M)[1:]:
- tags = re.findall(r'^- !\[([^\]]+)\]', group, re.M)
- self.assertTrue(tags, (entry['id'], lang))
- self.assertEqual(tags, sorted(tags), (entry['id'], lang))
- self.assertLessEqual(set(tags), {'Add', 'Change', 'Fix', 'Note', 'Test'})
-
- def test_release_summary_follows_the_entry_without_copying_text(self):
- tag = 'v2.7-preview.20'
- next(e for e in self.source['entries'] if e['id'] == '2.7-preview.20')['text']['fr'] += '\nA new correction.\n'
- docs = export.release_documents(self.source, tag)
- self.assertIn('A new correction.', docs['release-body.md'])
-
- def test_badges_are_images_with_escaped_attributes(self):
- rendered = export.inline(' **Camera**')
- self.assertIn(' Camera', rendered)
- self.assertNotIn('Fix')
- self.assertNotIn('',
+ b'',
1,
)
elif file.suffix == ".html" and not (root.name == "search.gaiamobile.org" and path.startswith("/home/")):
data = data.replace(
b"",
- b' ',
+ b' ',
1,
)
if (
diff --git a/tools/sync-forgejo-changelog.py b/tools/sync-forgejo-changelog.py
deleted file mode 100644
index 036d67c2..00000000
--- a/tools/sync-forgejo-changelog.py
+++ /dev/null
@@ -1,114 +0,0 @@
-#!/usr/bin/env python3
-"""Compare or publish an existing Forgejo release's notes from changelog.json."""
-import argparse
-import hashlib
-import importlib.util
-import json
-import os
-from pathlib import Path
-import secrets
-import urllib.error
-import urllib.parse
-import urllib.request
-
-ROOT = Path(__file__).resolve().parents[1]
-spec = importlib.util.spec_from_file_location('changelog_export', ROOT/'tools/export-changelog.py')
-export = importlib.util.module_from_spec(spec)
-spec.loader.exec_module(export)
-
-
-def request(url, token=None, method='GET', payload=None, content_type=None):
- headers = {'Accept': 'application/json'}
- if token:
- headers['Authorization'] = 'token ' + token
- if isinstance(payload, dict):
- payload = json.dumps(payload).encode()
- content_type = 'application/json'
- if content_type:
- headers['Content-Type'] = content_type
- req = urllib.request.Request(url, data=payload, headers=headers, method=method)
- with urllib.request.urlopen(req, timeout=60) as response:
- raw = response.read()
- return raw
-
-
-def api_url(release_url):
- url = urllib.parse.urlsplit(release_url)
- if url.scheme != 'https':
- raise ValueError('Forgejo requires HTTPS')
- repository, _, tag = url.path.partition('/releases/tag/')
- prefix, owner, repo = repository.rsplit('/', 2)
- if not tag or url.query or url.fragment:
- raise ValueError('Expected a Forgejo release URL')
- return f'https://{url.netloc}{prefix}/api/v1/repos/{owner}/{repo}'
-
-
-def sync(source_path, tag, publish=False):
- source, _ = export.load_source(source_path)
- release = source['releases'][tag]
- documents = export.release_documents(source, tag)
- api = api_url(release['url'])
- token = os.environ.get('FORGEJO_TOKEN')
- if publish and not token:
- raise ValueError('Set FORGEJO_TOKEN to synchronize Forgejo; no remote changes made')
- data = json.loads(request(api+'/releases/tags/'+urllib.parse.quote(tag, safe=''), token))
- if data['tag_name'] != tag:
- raise ValueError('Remote release tag mismatch')
- endpoint = api+'/releases/'+str(data['id'])
- changes = []
- attachments = {}
- for name in ('CHANGELOG.fr.md', 'CHANGELOG.en.md'):
- matches = [a for a in data['assets'] if a['name'] == name]
- if len(matches) > 1:
- raise ValueError('Duplicate remote attachment: ' + name)
- old = matches[0] if matches else None
- expected = documents[name].encode()
- if old is None or request(old['browser_download_url']) != expected:
- changes.append(name)
- attachments[name] = (old, expected)
- if data['body'].replace('\r\n', '\n').rstrip() != documents['release-body.md'].rstrip():
- changes.append('release-body.md')
- if not publish:
- print('Forgejo: ' + (', '.join(changes) + ' need synchronization' if changes else 'up to date'))
- return not changes
- for name, (old, content) in attachments.items():
- # Upload before removing the old text attachment. APK/checksums are untouched.
- temporary = name + '.' + hashlib.sha256(content).hexdigest()[:12]
- boundary = secrets.token_hex(24)
- payload = (f'--{boundary}\r\nContent-Disposition: form-data; name="attachment"; '
- f'filename="{temporary}"\r\nContent-Type: text/markdown\r\n\r\n').encode()
- payload += content + f'\r\n--{boundary}--\r\n'.encode()
- staged = [a for a in data['assets'] if a['name'] == temporary]
- if len(staged) > 1:
- raise ValueError('Duplicate staged attachment: ' + temporary)
- added = staged[0] if staged else json.loads(request(
- endpoint+'/assets?name='+urllib.parse.quote(temporary), token,
- 'POST', payload, 'multipart/form-data; boundary='+boundary))
- if request(added['browser_download_url']) != content:
- raise ValueError('Uploaded text failed verification: ' + name)
- if old:
- request(endpoint+'/assets/'+str(old['id']), token, 'DELETE')
- request(endpoint+'/assets/'+str(added['id']), token, 'PATCH', {'name': name})
- if 'release-body.md' in changes:
- request(endpoint, token, 'PATCH', {'body': documents['release-body.md']})
- # Check actual remote contents, not just API status codes.
- return sync(source_path, tag, publish=False)
-
-
-def main():
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument('--source', type=Path, default=ROOT/'changelog.json')
- parser.add_argument('--release', required=True, help='Existing Forgejo release tag')
- parser.add_argument('--publish', action='store_true', help='Update notes and FR/EN text attachments only')
- args = parser.parse_args()
- try:
- ok = sync(args.source, args.release, args.publish)
- except urllib.error.HTTPError as error:
- parser.exit(1, f'Forgejo HTTP {error.code}. If publishing, a partial update may have occurred; rerun the check.\n')
- except (ValueError, KeyError, OSError) as error:
- parser.exit(1, f'Changelog sync failed: {error}\n')
- parser.exit(0 if ok else 1)
-
-
-if __name__ == '__main__':
- main()
diff --git a/tools/test-appearance.py b/tools/test-appearance.py
deleted file mode 100644
index ec17ef9a..00000000
--- a/tools/test-appearance.py
+++ /dev/null
@@ -1,59 +0,0 @@
-#!/usr/bin/env python3
-"""Check shared appearance changes in an isolated desktop Gaia profile."""
-import importlib.util
-import json
-from pathlib import Path
-import time
-from marionette_client import Client
-
-spec = importlib.util.spec_from_file_location('probe', Path(__file__).with_name('probe-desktop.py'))
-probe = importlib.util.module_from_spec(spec)
-spec.loader.exec_module(probe)
-checks = []
-with Client(2857) as client:
- probe.register(client)
- def ev(app, code):
- result = probe.evaluate(client, app, code)
- assert 'error' not in result, result
- return result.get('value')
- def wait(app, code):
- for _ in range(80):
- result = ev(app, code)
- if result:
- return result
- time.sleep(.15)
- raise AssertionError((app, code))
- def check(name, result):
- assert result, name
- checks.append(name)
- print(name + ': OK', flush=True)
- def theme(mode):
- ev('settings', f'await navigator.mozSettings.createLock().set({{"vulpes.theme":{json.dumps(mode)}}});return true;')
- ev('system', 'ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});return true;')
- ev('system', 'await VulpesCompat.call("apps.launch",{manifestURL:"http://settings.localhost:8765/manifest.webapp"});return true;')
- wait('settings', 'return document.readyState==="complete";')
- previous = ev('settings', 'return await VulpesCompat.call("appearance.current",{});')
- try:
- ev('settings', 'const s=await new Promise(r=>require(["modules/settings_service"],r));s.navigate("display");return true;')
- wait('settings', 'return !!document.querySelector("#vulpes-theme");')
- theme('light')
- wait('homescreen', 'return document.documentElement.dataset.vulpesTheme==="light";')
- home = ev('homescreen', 'return [getComputedStyle(document.documentElement).backgroundColor,getComputedStyle(document.body).backgroundColor,getComputedStyle(document.body).backgroundImage];')
- # Exercise the actual Settings selector rather than only the broker.
- ev('settings', 'const s=document.getElementById("vulpes-theme");s.value="dark";s.dispatchEvent(new Event("change",{bubbles:true}));return true;')
- for app in ['settings', 'homescreen', 'system']:
- check('dark-live-' + app, wait(app, 'return document.documentElement.dataset.vulpesTheme==="dark";'))
- check('home-background-preserved', ev('homescreen', 'return [getComputedStyle(document.documentElement).backgroundColor,getComputedStyle(document.body).backgroundColor,getComputedStyle(document.body).backgroundImage];') == home)
- check('readable-display-header', ev('settings', 'const h=document.querySelector("#display gaia-header");return getComputedStyle(h.shadowRoot.querySelector(".inner")).backgroundColor==="rgb(37, 52, 66)" && getComputedStyle(h.querySelector("h1")).color==="rgb(241, 245, 249)";'))
- check('selector-label-does-not-overlap', ev('settings', 'const label=document.querySelector("#vulpes-theme-label"),l=label.getBoundingClientRect(),s=document.querySelector("#vulpes-theme").getBoundingClientRect();return parseFloat(getComputedStyle(label).height)>0 && l.bottom<=s.top && s.bottom<=document.querySelector("#vulpes-theme-row").getBoundingClientRect().bottom;'))
- ev('system', 'await VulpesCompat.call("apps.launch",{manifestURL:"http://sms.localhost:8765/manifest.webapp"});return true;')
- wait('sms', 'return document.readyState==="complete";')
- old_time = ev('sms', 'setTimeout(()=>location.reload(),50);return performance.timeOrigin;')
- check('reloaded-app-reads-saved-theme', wait('sms', f'return performance.timeOrigin!=={old_time} && document.documentElement.dataset.vulpesTheme==="dark";'))
- theme('system')
- check('system-preference', wait('sms', 'return document.documentElement.dataset.vulpesTheme===(matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light");'))
- theme('light')
- check('return-to-light', wait('sms', 'return document.documentElement.dataset.vulpesTheme==="light";'))
- finally:
- theme(previous or 'light')
-print(json.dumps({'checks':checks}, ensure_ascii=False, indent=2))
diff --git a/tools/test-call-screen.py b/tools/test-call-screen.py
index ef5003dd..a8f06980 100644
--- a/tools/test-call-screen.py
+++ b/tools/test-call-screen.py
@@ -31,7 +31,6 @@ with Client(2857) as c:
Runtime.platform.request=async (actor,app,op,params)=>{
if(op==='capabilities')return {...await original(actor,app,op,params),callControl:true};
if(op==='calls')return Runtime.testCalls;
- if(op==='dial'){Runtime.testCommands.push({op,params});Runtime.testCalls=[{path:'/ril_0/voicecall02',number:params.number,state:'dialing'}];return {path:'/ril_0/voicecall02'};}
if(op==='callAudio')return {muted:false,speaker:false,volume:1};
if(op==='callHistory')return Runtime.testHistory;
if(op==='ackCallHistory'){Runtime.testHistory=Runtime.testHistory.filter(i=>!params.ids.includes(i.id));return null;}
@@ -54,10 +53,7 @@ with Client(2857) as c:
wait('system','return !document.querySelector(".callscreenWindow")?.classList.contains("active");')
calls('incoming')
wait('callscreen','return navigator.mozTelephony?.calls[0]?.state==="incoming" && document.body?.innerText.includes("0123456789");')
- for _ in range(20):
- if c.execute("const w=Services.wm.getMostRecentWindow('vulpes:host');return w.document.getElementById('screen').getBoundingClientRect().bottom<=w.document.getElementById('home').getBoundingClientRect().top+1;"):break
- time.sleep(.1)
- else:raise AssertionError('Home overlaps incoming-call controls')
+ assert c.execute("const w=Services.wm.getMostRecentWindow('vulpes:host');return w.document.getElementById('screen').getBoundingClientRect().bottom<=w.document.getElementById('home').getBoundingClientRect().top+1;"), 'Home overlaps incoming-call controls'
ev('callscreen','document.getElementById("callbar-answer").click();return true;')
wait('callscreen','return navigator.mozTelephony?.calls[0]?.state==="connected";')
calls(None)
@@ -76,16 +72,6 @@ with Client(2857) as c:
time.sleep(.2)
else:raise AssertionError('Call history was not acknowledged')
print('PASS: original call log imports the completed call.',flush=True)
- ev('communications','document.querySelector(".log-item").click();return true;')
- wait('callscreen','return navigator.mozTelephony?.calls[0]?.state==="dialing";')
- commands=c.execute("return ChromeUtils.importESModule('resource://vulpes/host/Runtime.sys.mjs').Runtime.testCommands.filter(c=>c.op==='dial');")
- assert len(commands)==1 and commands[0]['params']['number']=='0123456789',commands
- assert ev('communications','return navigator.mozTelephony.calls[0] instanceof TelephonyCall;')
- calls('active')
- wait('communications','return navigator.mozTelephony.calls[0]?.state==="connected";')
- calls(None)
- wait('communications','return navigator.mozTelephony.calls.length===0;')
- print('PASS: call-log redial reaches the modem once and follows the call lifecycle.',flush=True)
ev('system','dispatchEvent(new Event("home"));return true;')
def key(edge):ev('system','dispatchEvent(new CustomEvent("vulpes-service-event",{detail:{type:"hardware-key",data:{type:'+json.dumps(edge)+'}}}));return true;')
key('sleep-button-press');time.sleep(.15);key('sleep-button-release')
@@ -96,7 +82,7 @@ with Client(2857) as c:
assert ev('system','return document.getElementById("sleep-menu").classList.contains("visible");')
key('sleep-button-release');ev('system','dispatchEvent(new Event("home"));return true;')
key('volume-up-button-press');time.sleep(.15);key('volume-up-button-release')
- assert ev('system','const v=document.getElementById("volume"),r=v.getBoundingClientRect();return v.classList.contains("visible") && r.height>=30 && r.width>100 && v.contains(document.elementFromPoint(r.x+r.width/2,r.y+r.height/2));'), 'Gaia volume HUD must be painted, not only marked visible'
+ assert ev('system','return document.getElementById("volume").classList.contains("visible");')
ev('system','navigator.battery.level=.73;navigator.battery.charging=true;navigator.battery.dispatchEvent(new Event("levelchange"));return true;')
assert ev('system','const i=document.getElementById("statusbar-battery");return !i.hidden && i.dataset.level==="70" && i.dataset.charging==="true";')
print('PASS: short Power press sleeps/wakes, long press shows Gaia menu, Gaia volume and battery icons.',flush=True)
diff --git a/tools/test-camera.py b/tools/test-camera.py
index dfa8d792..cf3d2f7a 100644
--- a/tools/test-camera.py
+++ b/tools/test-camera.py
@@ -65,24 +65,23 @@ with Client(2857) as c:
)
results["unavailable_controls"] = ev(
"camera",
- 'return [".js-flash"].every(s=>document.querySelector(s).getAttribute("aria-disabled")==="true");',
+ 'return [".js-flash",".mode-switch"].every(s=>document.querySelector(s).getAttribute("aria-disabled")==="true");',
)
assert results["unavailable_controls"]
ev(
"camera",
- 'window.__beforeCapture=(await VulpesCompat.call("media.request",{type:"pictures",operation:"enumerate"})).map(f=>f.name);window.__streamBefore=app.camera.mozCamera;document.querySelector(".js-flash").click();return true;',
+ 'window.__beforeCapture=(await VulpesCompat.call("media.request",{type:"pictures",operation:"enumerate"})).map(f=>f.name);window.__streamBefore=app.camera.mozCamera;document.querySelector(".js-flash").click();document.querySelector(".mode-switch .inner").click();return true;',
)
assert ev(
"camera",
'return app.camera.mode==="picture" && app.camera.mozCamera.flashMode==="off";',
)
- # Drive the desktop mouse through the normal touch simulator. Wait for
- # layout after reload: a decoded video can precede a visible app frame.
- point = wait("camera", 'const b=document.querySelector(".js-capture"),r=b.getBoundingClientRect(),x=r.x+r.width/2,y=r.y+r.height/2;return x>0 && y>0 && xdone(true),e=>done({error:String(e)}));""".replace('POINT',json.dumps(point))
+find(root).currentWindowGlobal.getActor('VulpesProbe').sendQuery('Tap',POINT).then(()=>done(true),e=>done({error:String(e)}));""".replace('POINT',json.dumps(point))
assert c.call('WebDriver:ExecuteAsyncScript',{'script':script,'args':[],
'newSandbox':True,'sandbox':'system','scriptTimeout':10000})['value'] is True
photo = wait(
diff --git a/tools/test-changelog-render.py b/tools/test-changelog-render.py
deleted file mode 100644
index 9bc0546f..00000000
--- a/tools/test-changelog-render.py
+++ /dev/null
@@ -1,75 +0,0 @@
-#!/usr/bin/env python3
-"""Regression checks for generated changelogs and publication integration."""
-import copy
-import importlib.util
-import json
-from pathlib import Path
-import shutil
-import subprocess
-import sys
-import tempfile
-import unittest
-
-ROOT = Path(__file__).resolve().parents[1]
-spec = importlib.util.spec_from_file_location("render_changelog", ROOT / "tools/render-changelog.py")
-renderer = importlib.util.module_from_spec(spec)
-spec.loader.exec_module(renderer)
-
-
-class ChangelogTests(unittest.TestCase):
- def setUp(self):
- self.source = json.loads((ROOT / "changelog.json").read_text())
-
- def test_no_translation_or_entry_is_lost(self):
- documents = renderer.render(self.source)
- for language, filename in renderer.OUTPUTS.items():
- for entry in self.source["entries"]:
- self.assertIn(entry["text"][language].strip(), documents[filename])
- self.assertIn(' ', documents[filename])
-
- def test_duplicate_ids_and_missing_translation_are_rejected(self):
- duplicate = copy.deepcopy(self.source)
- duplicate["entries"].append(duplicate["entries"][0])
- with self.assertRaises(ValueError):
- renderer.render(duplicate)
- del self.source["entries"][0]["text"]["en"]
- with self.assertRaises(KeyError):
- renderer.render(self.source)
-
- def test_check_mode_does_not_overwrite_files(self):
- with tempfile.TemporaryDirectory() as directory:
- root = Path(directory)
- (root / "changelog.json").write_text(json.dumps(self.source))
- (root / "CHANGELOG.md").write_text("Old content")
- self.assertEqual(len(renderer.generate(root, check=True)), 2)
- self.assertEqual((root / "CHANGELOG.md").read_text(), "Old content")
- renderer.generate(root)
- self.assertEqual(renderer.generate(root, check=True), [])
-
- def test_publication_renders_updates_without_touching_working_index(self):
- with tempfile.TemporaryDirectory() as directory:
- root = Path(directory)
- (root / "tools").mkdir()
- for name in ["render-changelog.py", "prepare-publication.py"]:
- shutil.copy2(ROOT / "tools" / name, root / "tools" / name)
- for name in ["LICENSE", "NOTICE", "README.md", "README.fr.md"]:
- (root / name).write_text("Publication fixture\n")
- (root / "changelog.json").write_text(json.dumps(self.source))
- subprocess.run(["git", "init", "-q", str(root)], check=True)
- subprocess.run(["git", "add", "."], cwd=root, check=True)
- before = (root / ".git/index").read_bytes()
- self.source["entries"][0]["text"]["fr"] += "\n\nPublication integration check."
- (root / "changelog.json").write_text(json.dumps(self.source))
- command = [sys.executable, str(root / "tools/prepare-publication.py"),
- "--index", str(root / ".git/publication-index"),
- "--report", str(root / ".git/publication-report.json")]
- subprocess.run(command, cwd=root, check=True, capture_output=True)
- report = json.loads((root / ".git/publication-report.json").read_text())
- published = subprocess.check_output(["git", "show", report["tree"] + ":CHANGELOG.md"], cwd=root, text=True)
- self.assertIn("Publication integration check.", published)
- self.assertIn("CHANGELOG.en.md", report["manifest"])
- self.assertEqual(before, (root / ".git/index").read_bytes())
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tools/test-composer-actions.py b/tools/test-composer-actions.py
deleted file mode 100644
index b72f1161..00000000
--- a/tools/test-composer-actions.py
+++ /dev/null
@@ -1,56 +0,0 @@
-#!/usr/bin/env python3
-"""Check touch send and attachment selection on the isolated desktop, with a fake modem."""
-import importlib.util
-import json
-from pathlib import Path
-import time
-from marionette_client import Client
-
-ROOT=Path(__file__).resolve().parents[1]
-spec=importlib.util.spec_from_file_location('probe',ROOT/'tools/probe-desktop.py')
-probe=importlib.util.module_from_spec(spec);spec.loader.exec_module(probe)
-with Client(2857) as c:
- probe.register(c)
- c.execute("Services.prefs.setCharPref('layout.css.devPixelsPerPx','1');const w=Services.wm.getMostRecentWindow('vulpes:host');w.resizeTo(360,720);return true;")
- time.sleep(.5)
- def ev(app,code):
- value=probe.evaluate(c,app,code)
- if 'value' not in value:raise RuntimeError(value)
- return value['value']
- def wait(app,code):
- for _ in range(80):
- if probe.evaluate(c,app,code).get('value'):return
- time.sleep(.1)
- raise AssertionError(code)
- def tap(app,selector):
- rect=ev(app,'return document.querySelector('+json.dumps(selector)+').getBoundingClientRect().toJSON();')
- point={'x':rect['x']+rect['width']/2,'y':rect['y']+rect['height']/2}
- script='''const done=arguments[arguments.length-1];const w=Services.wm.getMostRecentWindow('vulpes:host');
-function find(b){if(b.currentWindowGlobal?.documentURI.spec.startsWith(PREFIX))return b;for(const child of b.children){const f=find(child);if(f)return f;}}
-find(w.document.getElementById('system').browsingContext).currentWindowGlobal.getActor('VulpesProbe').sendQuery('Tap',POINT).then(done,e=>done({error:String(e)}));'''
- result=c.call('WebDriver:ExecuteAsyncScript',{'script':script.replace('PREFIX',json.dumps('http://'+app+'.localhost:8765/')).replace('POINT',json.dumps(point)),'args':[],'newSandbox':True,'sandbox':'system'})['value']
- assert result is True,result
- time.sleep(.35)
- ev('system','ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});await VulpesCompat.call("activities.cancel",{});dispatchEvent(new CustomEvent("home"));await VulpesCompat.call("apps.launch",{manifestURL:"http://sms.localhost:8765/manifest.webapp"});return true;')
- wait('sms',"return typeof Navigation?.toPanel==='function';")
- c.execute("const {Runtime}=ChromeUtils.importESModule('resource://vulpes/host/Runtime.sys.mjs');Runtime.messages.__saved=[Runtime.messages.radio,Runtime.messages.radioStatus];Runtime.messages.__count=0;Runtime.messages.radio=async()=>{Runtime.messages.__count++;return {state:'sent'};};Runtime.messages.radioStatus=null;return true;")
- try:
- ev('sms','await Navigation.toPanel("thread-list");await Navigation.toPanel("composer");Compose.clear();ConversationView.recipients.length=0;ConversationView.recipients.add({name:"5550199",number:"5550199",source:"manual"});Compose.append("One touch send regression");return true;')
- tap('sms','#messages-input')
- wait('system','return document.querySelector("#vulpes-gaia-keyboard")?.getBoundingClientRect().height>100;')
- tap('sms','#messages-send-button')
- wait('sms','return [...document.querySelectorAll(".message-content-body")].some(e=>e.textContent==="One touch send regression");')
- count=c.execute("return ChromeUtils.importESModule('resource://vulpes/host/Runtime.sys.mjs').Runtime.messages.__count;")
- assert count==1,count
- assert ev('system','return document.querySelector("#vulpes-gaia-keyboard")?.getBoundingClientRect().height>100;'),'Keyboard closed after send'
- tap('sms','#messages-attach-button')
- wait('system','return document.querySelector("#action-menu")?.checkVisibility() && document.querySelectorAll("#action-menu-list button").length>=4;')
- labels=ev('system','return [...document.querySelectorAll("#action-menu-list button")].map(e=>e.textContent);')
- tap('system','#action-menu-list button')
- wait('gallery','return location.hash==="#pick";')
- report={'singleTapSend':True,'modemSubmissions':count,'keyboardRetained':True,'attachmentChoices':labels,'originalGalleryPicker':True}
- print(json.dumps(report,ensure_ascii=False))
- finally:
- ev('system','await VulpesCompat.call("activities.cancel",{});dispatchEvent(new CustomEvent("home"));return true;')
- ev('sms','const ids=await new Promise(resolve=>{const a=[];const c=navigator.mozMobileMessage.getMessages({numbers:["5550199"]});c.onsuccess=()=>{if(c.done)resolve(a);else{a.push(c.result.id);c.continue();}};});if(ids.length)await navigator.mozMobileMessage.delete(ids);return true;')
- c.execute("const {Runtime}=ChromeUtils.importESModule('resource://vulpes/host/Runtime.sys.mjs');[Runtime.messages.radio,Runtime.messages.radioStatus]=Runtime.messages.__saved;delete Runtime.messages.__saved;delete Runtime.messages.__count;return true;")
diff --git a/tools/test-contacts.py b/tools/test-contacts.py
deleted file mode 100644
index 3a8dd226..00000000
--- a/tools/test-contacts.py
+++ /dev/null
@@ -1,183 +0,0 @@
-#!/usr/bin/env python3
-"""Exercise Gaia Contacts and its SMS/dialer activities in the desktop test profile.
-
-Creates one temporary contact; never sends a message or starts a call.
-"""
-import importlib.util
-import json
-import time
-import sys
-from pathlib import Path
-from marionette_client import Client
-
-spec = importlib.util.spec_from_file_location('probe', Path(__file__).with_name('probe-desktop.py'))
-probe = importlib.util.module_from_spec(spec)
-spec.loader.exec_module(probe)
-results = {}
-contact_id = None
-photo_name = None
-name = 'Vulpes QA ' + str(time.time_ns())
-
-with Client(2857) as client:
- client.socket.settimeout(90)
- probe.register(client)
-
- def ev(app, code):
- result = probe.evaluate(client, 'communications' if app == 'dialer' else app, code, '/dialer/' if app == 'dialer' else '/contacts/' if app == 'communications' else '/')
- if 'error' in result:
- raise AssertionError(result)
- return result.get('value')
-
- def wait(app, code):
- for _ in range(100):
- try:
- value = ev(app, code)
- except AssertionError as error:
- if 'Actor' not in str(error) or 'destroyed' not in str(error):
- raise
- value = None
- if value:
- return value
- time.sleep(.15)
- raise TimeoutError(app + ': ' + code)
-
- def click(app, selector):
- print("click", app, selector, file=sys.stderr, flush=True)
- point = wait(app, 'for(const e of document.querySelectorAll(' + json.dumps(selector) + ')) {'
- 'const r=e.getBoundingClientRect(),x=r.x+r.width/2,y=r.y+r.height/2;'
- 'if(x>0&&y>0&&xdone(true),e=>done({error:String(e)}));'''.replace('PREFIX', json.dumps('http://' + app + '.localhost:8765/')).replace('POINT', json.dumps(point))
- clicked = client.call('WebDriver:ExecuteAsyncScript', {'script': script, 'args': [],
- 'newSandbox': True, 'sandbox': 'system', 'scriptTimeout': 10000})['value']
- assert clicked is True, clicked
-
- def field(selector, value):
- wait('communications', 'return !!document.querySelector(' + json.dumps(selector) + ');')
- ev('communications', 'const e=document.querySelector(' + json.dumps(selector) + ');'
- 'e.value=' + json.dumps(value) + ';e.dispatchEvent(new Event("input",{bubbles:true}));return true;')
-
- def contact():
- return ev('communications', '''const r=navigator.mozContacts.find({filterBy:['givenName'],
-filterOp:'equals',filterValue:NAME});return await new Promise((ok,no)=>{
-r.onsuccess=()=>ok(r.result[0]||null);r.onerror=()=>no(r.error);});'''.replace('NAME', json.dumps(name)))
-
- try:
- wait('system', 'return document.body?.getAttribute("ready-state")==="fullyLoaded";')
- ev('system', "ScreenManager.turnScreenOn(true);if(Service.query('locked'))await Service.request('unlock',{forcibly:true});await VulpesCompat.call('apps.launch',{manifestURL:'http://communications.localhost:8765/manifest.webapp',entryPoint:'contacts'});return true;")
- wait('communications', 'return document.readyState==="complete"&&!document.hidden;')
- ev('system', "await VulpesCompat.call('apps.launch',{manifestURL:'http://gallery.localhost:8765/manifest.webapp'});return true;")
- wait('gallery', 'return document.readyState==="complete"&&!document.hidden;')
- fixture='vulpes-contacts-qa-'+str(time.time_ns())+'.jpg'
- photo_name=ev('gallery', 'const c=document.createElement("canvas");c.width=c.height=400;'
- 'const ctx=c.getContext("2d");ctx.fillStyle="#2372ae";ctx.fillRect(0,0,400,400);'
- 'const blob=await new Promise(r=>c.toBlob(r,"image/jpeg"));'
- 'return await VulpesCompat.call("media.request",{operation:"add",type:"pictures",name:'+json.dumps(fixture)+',blob});')
- ev('system', "await VulpesCompat.call('apps.launch',{manifestURL:'http://communications.localhost:8765/manifest.webapp',entryPoint:'contacts'});return true;")
- token = str(time.time_ns())
- ev('communications', 'setTimeout(()=>location.href=location.origin+"/contacts/index.html?qa='+token+'",0);return true;')
- wait('communications', 'return location.search==="?qa='+token+'" && document.readyState==="complete";')
- wait('communications', 'return typeof Loader!=="undefined" && !!window.Contacts && MainNavigation.currentView()==="view-contacts-list";')
- click('communications', '#add-contact-button')
- wait('communications', 'return window.MainNavigation?.currentView()==="view-contact-form" && !!document.querySelector("#number_0");')
- field('#givenName', name)
- field('#familyName', 'Élodie Test')
- field('#number_0', '5550199')
- field('#email_0', 'qa@example.invalid')
- click('communications', '#save-button')
- for _ in range(80):
- row = contact()
- if row:
- contact_id = row['id']
- break
- time.sleep(.15)
- assert contact_id
- results['create'] = row['tel'][0]['value'] == '5550199'
- item = '[data-uuid=' + json.dumps(contact_id) + ']'
- click('communications', '#search-start')
- wait('communications', "return MainNavigation.currentView()==='search-view' && !!document.querySelector('#search-contact');")
- field('#search-contact', 'elodie')
- results['search_without_accent'] = bool(wait('communications', 'return !!document.querySelector(' + json.dumps('#search-list [data-uuid="' + contact_id + '"]') + ');'))
- field('#search-contact', name + ' absent')
- results['search_no_results'] = bool(wait('communications', "return !document.querySelector('#no-result').classList.contains('hide');"))
- click('communications', '#cancel-search')
- wait('communications', "return MainNavigation.currentView()==='view-contacts-list';")
- click('communications', item)
- click('communications', '#edit-contact-button')
- wait('communications', 'return MainNavigation.currentView()==="view-contact-form";')
- click('communications', '#photo-button')
- wait('gallery', 'return !document.hidden && typeof picking!=="undefined" && picking;')
- thumb='.thumbnailImage[data-filename='+json.dumps(photo_name)+']'
- click('gallery',thumb)
- wait('gallery','return !document.querySelector("#crop-done-button").disabled;')
- click('gallery','#crop-done-button')
- wait('communications', 'return !document.hidden && getComputedStyle(document.querySelector("#thumbnail-photo")).backgroundImage!=="none";')
- field('#familyName', 'Élodie Modifiée')
- click('communications', '#save-button')
- results['edit_detail_refresh'] = bool(wait('communications', 'return document.querySelector("#contact-name-title")?.textContent.includes("Élodie Modifiée");'))
- assert contact()['familyName'] == ['Élodie Modifiée']
- results['contact_photo']=bool(ev('communications', 'const r=navigator.mozContacts.find({filterBy:["id"],filterOp:"equals",filterValue:'+json.dumps(contact_id)+'});const c=await new Promise(ok=>{r.onsuccess=()=>ok(r.result[0]);});return c.photo?.[0] instanceof Blob && c.photo[0].size>0;'))
- assert results['contact_photo']
- ev('communications', 'document.querySelector("#toggle-favorite").scrollIntoView({block:"center"});return true;')
- click('communications', '#toggle-favorite')
- results['favorite'] = bool(wait('communications', 'const r=navigator.mozContacts.find({filterBy:["id"],filterOp:"equals",filterValue:'+json.dumps(contact_id)+'});const c=await new Promise(ok=>{r.onsuccess=()=>ok(r.result[0]);});return c.category?.includes("favorite");'))
- ev('communications', 'document.querySelector("#send-sms-button-0").scrollIntoView({block:"center"});return true;')
- click('communications', '#send-sms-button-0')
- results['sms_recipient'] = bool(wait('sms', 'return !document.hidden&&window.ConversationView?.recipients?.numbers.includes("5550199");'))
- click('sms', '#messages-contact-pick-button')
- wait('communications', 'return !document.hidden&&ActivityHandler.currentlyHandling;')
- click('communications', item)
- wait('communications', 'return !!document.querySelector("[data-l10n-id=pick_destination]");')
- click('communications', '[data-l10n-id=pick_destination]')
- wait('communications', 'return !ActivityHandler.currentlyHandling;')
- results['picker_return'] = bool(wait('sms', 'return !document.hidden&&ConversationView.recipients.numbers.includes("5550199");'))
- # This activity only prefills the original dialer; do not press Call.
- ev('sms', 'window.__dialQA=new MozActivity({name:"dial",data:{type:"webtelephony/number",number:"5550199"}});return true;')
- results['dialer_prefill'] = bool(wait('dialer', 'return !document.hidden&&document.querySelector("#phone-number-view")?.value==="5550199";'))
- ev('system', "await VulpesCompat.call('apps.launch',{manifestURL:'http://communications.localhost:8765/manifest.webapp',entryPoint:'contacts'});return true;")
- wait('communications', 'return !document.hidden;')
- click('communications', item)
- click('communications', '#edit-contact-button')
- wait('communications', 'return MainNavigation.currentView()==="view-contact-form";')
- ev('communications', 'document.querySelector("#delete-contact").scrollIntoView({block:"center"});return true;')
- click('communications', '#delete-contact')
- click('communications', '#confirmation-message button[data-l10n-id=cancel]')
- results['delete_cancel'] = contact() is not None
- wait('communications', 'return document.querySelector("#confirmation-message").classList.contains("hide");')
- click('communications', '#delete-contact')
- click('communications', '#confirmation-message button[data-l10n-id=delete]')
- for _ in range(100):
- if contact() is None:
- results['delete_confirm'] = True
- results['temporary_contact_removed'] = True
- contact_id = None
- break
- time.sleep(.15)
- assert results.get('delete_confirm')
- results['passed'] = all(results.values())
- finally:
- try:
- if contact_id:
- # Return to the address book before removing only our temporary entry.
- ev('system', "await VulpesCompat.call('apps.launch',{manifestURL:'http://communications.localhost:8765/manifest.webapp',entryPoint:'contacts'});return true;")
- ev('communications', 'const r=navigator.mozContacts.remove(' + json.dumps(contact_id) + ');await new Promise((ok,no)=>{r.onsuccess=ok;r.onerror=()=>no(r.error);});return true;')
- results['temporary_contact_removed'] = contact() is None
- except Exception as error:
- results['cleanup_error'] = str(error)
- results['temporary_contact_id'] = contact_id
- finally:
- if photo_name:
- try:
- ev('gallery','await VulpesCompat.call("media.request",{operation:"delete",type:"pictures",name:'+json.dumps(photo_name)+'});return true;')
- results['temporary_photo_removed']=True
- except Exception as error:
- results['photo_cleanup_error']=str(error)
- results['temporary_photo_name']=photo_name
- print(json.dumps(results, indent=2, ensure_ascii=False))
-
-if not results.get('passed') or results.get('cleanup_error') or results.get('photo_cleanup_error'):
- raise SystemExit('Contacts qualification failed; see the report above.')
diff --git a/tools/test-forgejo-feedback.py b/tools/test-forgejo-feedback.py
deleted file mode 100644
index 4f3b29d4..00000000
--- a/tools/test-forgejo-feedback.py
+++ /dev/null
@@ -1,78 +0,0 @@
-#!/usr/bin/env python3
-"""Exercise the October Settings regressions in the isolated Gaia test profile."""
-import importlib.util
-import json
-import 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'))
-probe=importlib.util.module_from_spec(spec);spec.loader.exec_module(probe)
-checks={}
-with Client(2857) as client:
- probe.register(client)
- def ev(app,code):
- r=probe.evaluate(client,app,code)
- assert 'error' not in r,r
- return r.get('value')
- def wait(app,code):
- for _ in range(80):
- v=ev(app,code)
- if v:return v
- time.sleep(.15)
- raise AssertionError((app,code))
- def check(name,value):
- checks[name]=value
- assert value,(name,value)
- print(name+': OK',flush=True)
- def launch(app):
- ev('system',f"await VulpesCompat.call('apps.launch',{{manifestURL:'http://{app}.localhost:8765/manifest.webapp'}});return true;")
- wait(app,"return document.readyState==='complete';")
- def panel(name,extra='{}'):
- ev('settings',f"const s=await new Promise(r=>require(['modules/settings_service'],r));s.navigate('{name}',{extra});return true;")
- wait('settings',f"return document.getElementById('{name}')?.classList.contains('current');")
- wait('system',"return document.body?.getAttribute('ready-state')==='fullyLoaded';")
- ev('system',"ScreenManager.turnScreenOn(true);if(Service.query('locked'))await Service.request('unlock',{forcibly:true});return true;")
- launch('settings')
- # An IME commits text through input, without a synthetic legacy keypress.
- ev('settings', "const s=await new Promise(r=>require(['modules/dialog_service'],r));if(!s._navigating)s.show('screenLock-passcode',{mode:'create'});return true;")
- wait('settings', "return document.querySelector('#screenLock-passcode input')?.type==='password';")
- check('pin-ime-input',ev('settings',"""
-const p=document.querySelector('#screenLock-passcode'),i=p.querySelector('input');
-i.value='12341234';i.dispatchEvent(new InputEvent('input',{bubbles:true,inputType:'insertText',data:'12341234'}));
-return p.dataset.passcodeStatus==='success' && i.type==='password' && i.inputMode==='numeric' && p.querySelectorAll('[data-dot]').length===8;
-"""))
- check('pin-backspace',ev('settings',"""
-const p=document.querySelector('#screenLock-passcode'),i=p.querySelector('input');
-i.value='1234123';i.dispatchEvent(new InputEvent('input',{bubbles:true,inputType:'deleteContentBackward'}));
-return p.dataset.passcodeStatus!=='success' && p.querySelectorAll('[data-dot]').length===7;
-"""))
- ev('settings', "document.querySelector('#screenLock-passcode button[type=reset]').click();return true;")
- panel('appPermissions')
- check('permissions-list-without-retired-api',ev('settings',"""
-const List=await new Promise(r=>require(['panels/app_permissions_list/app_permissions_list'],r));
-const table=await (await fetch('/resources/permissions_table.json')).json();
-const list=List(),ul=document.createElement('ul');list.init(ul);list.setPermissionsTable(table);
-list._initExplicitPermissionsTable(await navigator.mozApps.getSelf());list._apps=[];
-list._loadApps([{origin:'http://test.localhost:8765',manifestURL:'http://test.localhost:8765/manifest.webapp',manifest:{name:'Test installed app',type:'web'}}]);
-return ul.querySelector('li span')?.textContent==='Test installed app';
-"""))
- panel('about-legal')
- for lang,header in [('fr','Paramètres'),('en-US','Settings'),('el','Ρυθμίσεις')]:
- ev('settings',f"await navigator.mozSettings.createLock().set({{'language.current':'{lang}'}});return true;")
- wait('settings',f"return document.querySelector('#root h1')?.textContent==={json.dumps(header)};")
- wait('homescreen',f"return document.documentElement.lang==='{lang}';")
- check('language-'+lang,ev('settings',"return document.querySelector('#vulpes-credits').textContent.startsWith("+json.dumps('Vulpes OS repose' if lang=='fr' else 'Vulpes OS builds')+");"))
- ev('settings',"await navigator.mozSettings.createLock().set({'lockscreen.enabled':false});return true;")
- ev('system',"dispatchEvent(new CustomEvent('vulpes-service-event',{detail:{type:'screen-off',data:{}}}));return true;")
- time.sleep(.5)
- check('disabled-lock-stays-unlocked',ev('system',"return !Service.query('locked') && !document.querySelector('#screen').classList.contains('locked');"))
- panel('sound')
- ev('settings',"document.querySelector('.manage-tones-button').click();return true;")
- wait('ringtones',"return location.pathname==='/manage.html' && document.body?.dataset.ready==='true';")
- check('manage-tones-opens',True)
- ev('system',"dispatchEvent(new CustomEvent('home'));return true;")
- ev('system',"await navigator.mozSettings.createLock().set({'homescreen.manifestURL':'http://verticalhome.localhost:8765/manifest.webapp'});return true;")
- wait('verticalhome',"return window.app?.itemStore.ready && app.grid.getItems().length>10;")
- check('alternate-home-first-start',True)
- ev('system',"await navigator.mozSettings.createLock().set({'homescreen.manifestURL':'http://homescreen.localhost:8765/manifest.webapp','language.current':'fr','lockscreen.enabled':true});return true;")
-print(json.dumps(checks,ensure_ascii=False,indent=2))
diff --git a/tools/test-gaia-dialogs.py b/tools/test-gaia-dialogs.py
deleted file mode 100644
index eaed21a8..00000000
--- a/tools/test-gaia-dialogs.py
+++ /dev/null
@@ -1,50 +0,0 @@
-#!/usr/bin/env python3
-"""Regression: visible Gaia errors and retained MMS composer, without real sends."""
-import importlib.util,json,time
-from marionette_client import Client
-spec=importlib.util.spec_from_file_location('probe','tools/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(app,code):
- result=p.evaluate(c,app,code,"/dialer/" if app=="communications" else "/")
- assert 'value' in result,result
- return result['value']
- def wait(app,code):
- for _ in range(100):
- if p.evaluate(c,app,code,"/dialer/" if app=="communications" else "/").get('value'):return
- time.sleep(.1)
- raise AssertionError(code)
- def visible(app,selector):
- return ev(app,'''const f=document.querySelector(SELECTOR),s=f.querySelector('section'),m=f.querySelector('menu');
- const r=s.getBoundingClientRect(),b=m.getBoundingClientRect(),v=f.getBoundingClientRect();
- return {text:s.innerText,visible:r.top>=v.top && r.bottom<=b.top && r.right<=v.right && r.height>0,
- section:r.toJSON(),menu:b.toJSON()};'''.replace('SELECTOR',json.dumps(selector)))
- wait('system',"return document.body?.getAttribute('ready-state')==='fullyLoaded';")
- ev('system','ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});await VulpesCompat.call("apps.launch",{manifestURL:"http://communications.localhost:8765/manifest.webapp",entryPoint:"dialer"});return true;')
- wait('communications','return !!window.CallHandler;')
- time.sleep(1.5)
- ev('communications','await new Promise(r=>LazyLoader.load("/shared/js/dialer/telephony_messages.js",r));TelephonyMessages.displayMessage("NoNetwork");return true;')
- time.sleep(.5)
- result=visible('communications','#confirmation-message');assert result['visible'] and result['text'],result
- print('PASS: original Gaia network-error text stays above its buttons.',flush=True)
- ev('communications','ConfirmDialog.hide();return true;')
- ev('system','await VulpesCompat.call("apps.launch",{manifestURL:"http://sms.localhost:8765/manifest.webapp"});return true;')
- wait('sms','return !!window.ConversationView && !!window.Compose;')
- time.sleep(1.5)
- ev('sms','''await Navigation.toPanel('thread-list');await Navigation.toPanel('composer');Compose.clear();ConversationView.recipients.length=0;
- ConversationView.recipients.add({name:'5550199',number:'5550199',source:'manual'});Compose.append('MMS draft regression');
- const canvas=document.createElement('canvas');canvas.width=16;canvas.height=16;canvas.getContext('2d').fillRect(0,0,16,16);
- const blob=await new Promise(r=>canvas.toBlob(r,'image/png'));
- Compose.append(new Attachment(blob,{name:'regression.png',isDraft:true}));return true;''')
- wait('sms','return Compose.type==="mms";')
- for lang in ['fr','en-US']:
- ev('sms',f'document.documentElement.lang={json.dumps(lang)};document.getElementById("messages-send-button").click();return true;')
- time.sleep(.3)
- result=visible('sms','#vulpes-gaia-notice');assert result['visible'] and 'MMS' in result['text'],result
- assert ('pas encore disponible' if lang=='fr' else 'not yet available') in result['text'],result
- ev('sms','document.querySelector("#vulpes-gaia-notice button").click();return true;')
- assert ev('sms','return Compose.type==="mms" && Compose.getText()==="MMS draft regression" && Compose.getContent().some(i=>i instanceof Attachment) && Navigation.isCurrentPanel("composer");')
- print('PASS: French/English MMS notice visible; text, attachment and composer retained.',flush=True)
- ev('sms','Compose.clear();ConversationView.recipients.length=0;return true;')
- ev('system','dispatchEvent(new Event("home"));return true;')
diff --git a/tools/test-gaia-keyboard.py b/tools/test-gaia-keyboard.py
index b0c352c9..a398a5b2 100644
--- a/tools/test-gaia-keyboard.py
+++ b/tools/test-gaia-keyboard.py
@@ -78,7 +78,7 @@ find(w.document.getElementById('system').browsingContext).currentWindowGlobal
evaluate('system', 'ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});return true;')
time.sleep(1)
evaluate('system', 'await VulpesCompat.call("apps.launch",{manifestURL:"http://sms.localhost:8765/manifest.webapp"});return true;')
- wait('sms', "return typeof window.Navigation?.toPanel === 'function';")
+ wait('sms', 'return !!window.Navigation;')
evaluate('sms', 'await Navigation.toPanel("thread-list");await Navigation.toPanel("composer");return true;')
tap('sms', '.recipient[contenteditable]')
wait('keyboard', 'return navigator.mozInputMethod?.inputcontext?.inputType==="tel" && !!document.querySelector("button[aria-label=\\"2\\"]");')
@@ -93,7 +93,7 @@ find(w.document.getElementById('system').browsingContext).currentWindowGlobal
tap('keyboard', 'button[aria-label="a"]')
text = evaluate('sms', 'return document.querySelector("#messages-input").textContent;')
assert text.lower() == 'a', text
- assert evaluate('sms', 'return document.querySelector(`.recipient[data-number="23"]`)?.dataset.number || document.querySelector(".recipient[contenteditable=true]").textContent.trim();') == number.strip(), 'Letter changed the recipient'
+ assert evaluate('sms', 'return document.querySelector(".recipient[contenteditable]").textContent;') == number, 'Letter changed the recipient'
evaluate('sms', 'getSelection().setPosition(document.querySelector("#messages-input").firstChild,0);return true;')
tap('keyboard', 'button[aria-label="b"]')
assert evaluate('sms', 'return document.querySelector("#messages-input").textContent.toLowerCase();') == 'ba'
diff --git a/tools/test-hotspot-ui.py b/tools/test-hotspot-ui.py
deleted file mode 100644
index b23a22a0..00000000
--- a/tools/test-hotspot-ui.py
+++ /dev/null
@@ -1,71 +0,0 @@
-#!/usr/bin/env python3
-"""Exercise real Gaia hotspot dialogs in the isolated PC profile with a fake radio.
-
-This tests UI behavior only. Native hotspot qualification is a separate test.
-"""
-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'))
-probe=importlib.util.module_from_spec(spec);spec.loader.exec_module(probe)
-with Client(2857) as client:
- probe.register(client)
- def ev(code):
- result=probe.evaluate(client,'settings',code)
- if 'error' in result:raise AssertionError(result['error'])
- return result.get('value')
- def wait(code):
- for _ in range(100):
- if ev(code):return
- time.sleep(.1)
- state=ev("const d=await new Promise(r=>require(['modules/dialog_service'],r));const p=document.getElementById('hotspot-wifiSettings');return {navigating:d._navigating,pending:d._pendingRequests.length,hidden:p.hidden,current:Array.from(document.querySelectorAll('section.current'),p=>p.id),transition:getComputedStyle(p).transition,visibility:getComputedStyle(p).visibility,notes:Array.from(p.querySelectorAll('[role=status]'),p=>p.textContent)};")
- raise AssertionError((code,state))
- for _ in range(100):
- ready=probe.evaluate(client,'system','return document.body?.getAttribute("ready-state")==="fullyLoaded";')
- if ready.get('value'):break
- time.sleep(.2)
- probe.evaluate(client,'system','ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});await VulpesCompat.call("apps.launch",{manifestURL:"http://settings.localhost:8765/manifest.webapp"});return true;')
- wait('return !!document.querySelector("#root.current") && typeof require==="function";')
- ev('location.reload();return true;');time.sleep(.7)
- wait('return !!document.querySelector("#root.current") && typeof require==="function";')
- ev('''window.__hotspotOriginalCall=VulpesCompat.call;
- window.__hotspotFake={enabled:false,configured:false,ssid:'Vulpes OS',security:'wpa2-psk',password:''};
- window.__hotspotWrites=[];window.__hotspotFail=false;
- VulpesCompat.call=async(method,p)=>{
- if(method!=='platform.wifi')return __hotspotOriginalCall(method,p);
- if(p.operation==='hotspotSave'){
- if(__hotspotFail)throw Error('HOTSPOT_SAVE_FAILED');
- __hotspotWrites.push('save');Object.assign(__hotspotFake,{configured:true,ssid:p.ssid,security:p.security,password:p.password});
- } else if(p.operation==='hotspotPower'){__hotspotWrites.push(p.enabled?'on':'off');__hotspotFake.enabled=p.enabled;}
- return {...__hotspotFake};
- };
- window.VulpesNativeWifi=true;
- const script=document.createElement('script');script.src='/_vulpes/gaia-wifi.js';document.head.append(script);return true;''')
- try:
- wait('return !!window.VulpesWifiSettings;')
- ev("require(['modules/settings_service'],s=>s.navigate('hotspot'));return true;")
- wait("return !!document.querySelector('#hotspot.current #tethering-wifi-enabled:not([disabled])');")
- def open_dialog():
- ev("document.querySelector('#hotspot-settings-section a').click();return true;")
- wait("return document.querySelector('#hotspot-wifiSettings').classList.contains('current');")
- def closed():
- wait("const d=await new Promise(r=>require(['modules/dialog_service'],r));return !d._navigating && !document.querySelector('#hotspot-wifiSettings').classList.contains('current') && !document.querySelector('#tethering-wifi-enabled').disabled;")
- open_dialog()
- ev("document.querySelector('#hotspot-wifiSettings button[type=reset]').click();return true;");closed()
- open_dialog()
- ev("const p=document.querySelector('#hotspot-wifiSettings');p.querySelector('input[type=text]').value='UI test';p.querySelector('input[name=password]').value='short';p.querySelector('button[type=submit]').click();return true;")
- time.sleep(.3)
- assert ev("return document.querySelector('#hotspot-wifiSettings').classList.contains('current') && __hotspotWrites.length===0;")
- ev("window.__hotspotFail=true;const p=document.querySelector('#hotspot-wifiSettings');p.querySelector('input[name=password]').value='testpassword';p.querySelector('button[type=submit]').click();return true;")
- wait("return !!document.querySelector('#hotspot-wifiSettings [role=status]').textContent;")
- state=ev("const p=document.getElementById('hotspot-wifiSettings');return {preserved:p.querySelector('input[name=password]').value==='testpassword',writes:__hotspotWrites,current:p.classList.contains('current'),message:p.querySelector('[role=status]').textContent,disabled:p.querySelector('button[type=submit]').disabled};")
- assert state['preserved'] and not state['writes'],state
- ev("window.__hotspotFail=false;document.querySelector('#hotspot-wifiSettings button[type=submit]').click();return true;");closed()
- assert ev("return __hotspotFake.configured && __hotspotWrites.join(',')==='save';")
- for enabled in (True,False):
- ev("const c=document.querySelector('#tethering-wifi-enabled');c.checked="+json.dumps(enabled)+";c.dispatchEvent(new Event('change',{bubbles:true}));return true;")
- wait("return !document.querySelector('#tethering-wifi-enabled').disabled && __hotspotFake.enabled==="+json.dumps(enabled)+';')
- assert ev("return __hotspotWrites.join(',')==='save,on,off';")
- print('PASS: Gaia hotspot cancel/reopen, invalid input, failed save retention, retry, start/stop. Fake radio only.')
- finally:
- ev("VulpesCompat.call=__hotspotOriginalCall;location.reload();return true;")
diff --git a/tools/test-notification-swipe.py b/tools/test-notification-swipe.py
deleted file mode 100644
index 2fed42c3..00000000
--- a/tools/test-notification-swipe.py
+++ /dev/null
@@ -1,32 +0,0 @@
-#!/usr/bin/env python3
-"""Dismiss a synthetic Gaia notification with touch events in the test profile."""
-import importlib.util
-import json
-from pathlib import Path
-import time
-from marionette_client import Client
-spec=importlib.util.spec_from_file_location('probe',Path(__file__).with_name('probe-desktop.py'))
-probe=importlib.util.module_from_spec(spec);spec.loader.exec_module(probe)
-with Client(2857) as c:
- probe.register(c)
- def ev(code):
- r=probe.evaluate(c,'system',code)
- assert 'value' in r,r
- return r['value']
- for _ in range(80):
- if probe.evaluate(c,'system','return document.body?.getAttribute("ready-state")==="fullyLoaded";').get('value'):break
- time.sleep(.2)
- ev('ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});dispatchEvent(new Event("home"));return true;')
- try:
- ev('NotificationScreen.addNotification({id:"vulpes-swipe-test",title:"Gesture test",body:"Temporary test notification",timestamp:Date.now()});NotificationScreen.closeToast();UtilityTray.show(true);return true;')
- time.sleep(.5)
- point=ev('const e=document.querySelector("#notifications-container [data-notification-id=\\"vulpes-swipe-test\\"]"),r=e.getBoundingClientRect();return {x:r.x+r.width*.2,y:r.y+r.height*.5,toX:r.x+r.width*.92,toY:r.y+r.height*.5};')
- code='''const done=arguments[arguments.length-1];const w=Services.wm.getMostRecentWindow('vulpes:host');w.document.getElementById('system').browsingContext.currentWindowGlobal.getActor('VulpesProbe').sendQuery('Swipe',POINT).then(done,e=>done({error:String(e)}));'''.replace('POINT',json.dumps(point))
- result=c.call('WebDriver:ExecuteAsyncScript',{'script':code,'args':[],'newSandbox':True,'sandbox':'system'})['value']
- assert result is True,result
- time.sleep(1)
- result=ev('return {dismissed:!document.querySelector("#notifications-container [data-notification-id=\\"vulpes-swipe-test\\"]"),rootStable:scrollX===0&&scrollY===0&&visualViewport.offsetLeft===0&&visualViewport.offsetTop===0,lockGesture:getComputedStyle(document.getElementById("lockscreen")).touchAction};')
- assert result['dismissed'] and result['rootStable'],result
- print(json.dumps(result))
- finally:
- ev('NotificationScreen.removeNotification("vulpes-swipe-test");UtilityTray.hide(true);return true;')
diff --git a/tools/test-settings-followup.py b/tools/test-settings-followup.py
deleted file mode 100644
index 23a54162..00000000
--- a/tools/test-settings-followup.py
+++ /dev/null
@@ -1,25 +0,0 @@
-#!/usr/bin/env python3
-import json,sys
-from pathlib import Path
-from marionette_client import Client
-ROOT=Path(__file__).resolve().parents[1]
-with Client(2857) as c:
- c.socket.settimeout(180)
- c.call("WebDriver:SetTimeouts",{"script":150000})
- c.execute('Services.prefs.setBoolPref("media.navigator.streams.fake",true);Services.prefs.setBoolPref("media.navigator.permission.fake",true);const p=Services.scriptSecurityManager.createContentPrincipal(Services.io.newURI("http://camera.localhost:8765"),{});for(const kind of ["camera","microphone"])Services.perms.addFromPrincipal(p,kind,Ci.nsIPermissionManager.ALLOW_ACTION);return true;')
- # The actor evaluation is the same mechanism used by the phone's private probe.
- source=(ROOT/'host/SettingsFollowupAudit.sys.mjs').read_text().replace('export async function','async function',1)
- script='''const done=arguments[arguments.length-1];
-try{ChromeUtils.registerWindowActor('VulpesProbe',{child:{esModuleURI:'resource://vulpes/tests/ProbeChild.sys.mjs'},allFrames:true,safeForUntrustedWebProcess:true});}catch(_){}
-const sleep=ms=>new Promise(r=>setTimeout(r,ms));
-async function evaluate(app,code){const root=Services.wm.getMostRecentWindow('vulpes:host').document.getElementById('system').browsingContext;function find(b){if(b.currentWindowGlobal?.documentURI.spec.startsWith('http://'+app+'.localhost:8765/'))return b;for(const c of b.children){const f=find(c);if(f)return f;}}const b=find(root);if(!b)throw Error('Missing '+app);return b.currentWindowGlobal.getActor('VulpesProbe').sendQuery('Evaluate',code);}
-async function wait(app,code,timeout=15000){const end=Date.now()+timeout;let error;while(Date.now(){await IOUtils.writeJSON(PathUtils.join(PathUtils.profileDir,'followup.json'),data);}}).then(done,e=>done({error:String(e)}));'''
- try:
- result=c.call('WebDriver:ExecuteAsyncScript',{'script':script,'args':[],'newSandbox':True,'sandbox':'system','scriptTimeout':150000})['value']
- print(json.dumps(result,indent=2))
- (ROOT/'logs/settings-followup-20261012/desktop-followup.json').write_text(json.dumps(result,indent=2)+'\n')
- if not result.get('passed'):sys.exit(1)
- finally:c.execute('Services.prefs.clearUserPref("media.navigator.streams.fake");Services.prefs.clearUserPref("media.navigator.permission.fake");return true;')
diff --git a/tools/test-settings-selector.py b/tools/test-settings-selector.py
deleted file mode 100644
index 425ebae2..00000000
--- a/tools/test-settings-selector.py
+++ /dev/null
@@ -1,46 +0,0 @@
-#!/usr/bin/env python3
-"""Use trusted input to open Gaia's selector, commit/cancel and reopen it."""
-import importlib.util,json,time,os
-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):
- r=p.evaluate(c,'settings',code);assert 'error' not in r,r
- return r.get('value')
- def wait(code):
- for _ in range(80):
- v=ev(code)
- if v:return v
- time.sleep(.1)
- raise AssertionError(code)
- def tap(selector, gesture=None):
- point=ev('const e=document.querySelector('+json.dumps(selector)+');e.scrollIntoView({block:"center"});const r=e.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2};')
- method=gesture or ('Tap' if os.environ.get('VULPES_DIRECT_TOUCH') and selector=='#vulpes-theme' else 'MouseClick')
- if method=='Swipe':point.update(toX=point['x']+30,toY=point['y'])
- result=c.call('WebDriver:ExecuteAsyncScript',{'script':'''const done=arguments[arguments.length-1];const w=Services.wm.getMostRecentWindow('vulpes:host');function find(b){if(b.currentWindowGlobal?.documentURI.spec.startsWith('http://settings.localhost:8765/'))return b;for(const child of b.children){const found=find(child);if(found)return found;}}const bc=find(w.document.getElementById('system').browsingContext);bc.currentWindowGlobal.getActor('VulpesProbe').sendQuery(METHOD,POINT).then(done,e=>done({error:String(e)}));'''.replace('POINT',json.dumps(point)).replace('METHOD',json.dumps(method)),'args':[],'newSandbox':True,'sandbox':'system','scriptTimeout':10000})
- assert result['value'] is True,result
- time.sleep(.3)
- p.evaluate(c,'system','ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});await VulpesCompat.call("apps.launch",{manifestURL:"http://settings.localhost:8765/manifest.webapp"});return true;')
- wait('return document.readyState==="complete" && typeof require==="function";')
- previous=ev('return await VulpesCompat.call("appearance.current",{});')
- try:
- ev('const s=await new Promise(r=>require(["modules/settings_service"],r));await s.navigate("display");await navigator.mozSettings.createLock().set({"vulpes.theme":"light"});return true;')
- wait('return !!document.querySelector("#vulpes-theme");')
- time.sleep(1)
- tap('#vulpes-theme','Swipe')
- assert ev('return !document.getElementById("vulpes-value-selector");'), 'A drag must not open the selector'
- tap('#vulpes-theme')
- wait('return !!document.querySelector("#vulpes-value-selector");')
- wait('return getComputedStyle(document.querySelector("#vulpes-value-selector")).backgroundColor!=="rgba(0, 0, 0, 0)";')
- tap('#vulpes-value-selector [data-value="dark"]')
- tap('#vulpes-value-selector [data-action="confirm"]')
- wait('return document.documentElement.dataset.vulpesTheme==="dark" && !document.getElementById("vulpes-value-selector");')
- tap('#vulpes-theme');tap('#vulpes-value-selector [data-value="light"]');tap('#vulpes-value-selector [data-action="cancel"]')
- assert ev('return document.documentElement.dataset.vulpesTheme==="dark";')
- tap('#vulpes-theme');tap('#vulpes-value-selector [data-value="light"]');tap('#vulpes-value-selector [data-action="confirm"]')
- wait('return document.documentElement.dataset.vulpesTheme==="light";')
- print('PASS '+('direct touch' if os.environ.get('VULPES_DIRECT_TOUCH') else 'mouse via touch simulator')+': drag ignored, open, styled selector, dark, cancel, reopen, light')
- finally:ev('await navigator.mozSettings.createLock().set({"vulpes.theme":'+json.dumps(previous)+'});return true;')
diff --git a/tools/test-system-banner.py b/tools/test-system-banner.py
deleted file mode 100644
index ceb9a395..00000000
--- a/tools/test-system-banner.py
+++ /dev/null
@@ -1,38 +0,0 @@
-#!/usr/bin/env python3
-"""Exercise Gaia's transient banner in the isolated desktop host."""
-import importlib.util
-import json
-from pathlib import Path
-from marionette_client import Client
-spec = importlib.util.spec_from_file_location('probe', Path(__file__).with_name('probe-desktop.py'))
-probe = importlib.util.module_from_spec(spec)
-spec.loader.exec_module(probe)
-with Client(2857) as client:
- probe.register(client)
- result = probe.evaluate(client, 'system', '''
-await LazyLoader.load('js/system_banner.js');
-const banner=new SystemBanner();
-const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
-try {
- await banner.show({id:'app-install-success',args:{appName:'Vulpes test'}});
- const shown=banner._banner.isConnected;
- await sleep(4900);
- const dismissed=!banner._banner.isConnected;
- await banner.show('app-install-success');
- await sleep(1000);
- await banner.show('app-install-success');
- await sleep(3300);
- const replacementRetained=banner._banner.isConnected;
- await sleep(1600);
- const replacementDismissed=!banner._banner.isConnected;
- await banner.show('app-install-success');
- banner._banner.click();
- await sleep(800);
- const clickDismissed=!banner._banner.isConnected;
- return {shown,dismissed,replacementRetained,replacementDismissed,clickDismissed};
-} finally {await banner.hide();banner._banner?.remove();}
-''')
- assert 'error' not in result, result
- checks=result['value']
- assert all(checks.values()), checks
- print(json.dumps(checks, indent=2))
diff --git a/tools/test-ticket-settings.py b/tools/test-ticket-settings.py
deleted file mode 100644
index 455bea32..00000000
--- a/tools/test-ticket-settings.py
+++ /dev/null
@@ -1,48 +0,0 @@
-#!/usr/bin/env python3
-"""Regression checks for storage navigation and unavailable-feature contrast."""
-import importlib.util,json,time
-from pathlib import Path
-from marionette_client import Client
-spec=importlib.util.spec_from_file_location('probe',Path(__file__).with_name('probe-desktop.py'))
-p=importlib.util.module_from_spec(spec);spec.loader.exec_module(p)
-with Client(2857) as c:
- p.register(c)
- def ev(code):
- for _ in range(80):
- reply=p.evaluate(c,'settings',code)
- if not reply.get('missing'):break
- time.sleep(.1)
- if 'value' not in reply:raise RuntimeError(reply)
- return reply['value']
- def wait(code):
- for _ in range(80):
- if ev(code):return
- time.sleep(.1)
- raise TimeoutError(code)
- p.evaluate(c,'system','if(Service.query("locked"))await Service.request("unlock",{forcibly:true});await VulpesCompat.call("apps.launch",{manifestURL:"http://settings.localhost:8765/manifest.webapp"});return true;')
- ev('location.reload();return true;');time.sleep(.6)
- wait('return document.querySelector("section.current")?.id==="root" && require.defined("modules/settings_service");')
- for panel in ['mediaStorage','root','applicationStorage','root','usbStorage','root']:
- ev('await new Promise(resolve=>require(["modules/settings_service"],s=>s.navigate('+json.dumps(panel)+',{},resolve)));return true;')
- wait('return document.querySelector("section.current")?.id==='+json.dumps(panel)+';')
- errors=ev('return window.__vulpesDiagnostics;')
- assert not errors,errors
- # A failed dependency must not leave the navigation lock set.
- ev('const cache=require("modules/panel_cache"),get=cache.get;cache.get=function(id,cb){cache.get=get;cb(null,new Error("test dependency failure"));};await new Promise(resolve=>require("modules/settings_service").navigate("mediaStorage",{},resolve));return true;')
- time.sleep(.3)
- ev('await new Promise(resolve=>require("modules/settings_service").navigate("applicationStorage",{},resolve));return true;')
- wait('return document.querySelector("section.current")?.id==="applicationStorage";')
- ratios={}
- for theme in ['light','dark']:
- ev('document.documentElement.dataset.vulpesTheme='+json.dumps(theme)+';VulpesGaiaUI.notice("This feature is not available on this platform yet.");return true;')
- time.sleep(.2)
- colors=ev('const p=document.querySelector("#vulpes-gaia-notice p");return [getComputedStyle(p).color,getComputedStyle(p).backgroundColor];')
- def luminance(css):
- rgb=[float(v)/255 for v in css.removeprefix('rgb(').removesuffix(')').split(',')]
- linear=[v/12.92 if v<=.04045 else ((v+.055)/1.055)**2.4 for v in rgb]
- return sum(a*b for a,b in zip(linear,[.2126,.7152,.0722]))
- a,b=sorted(map(luminance,colors));ratio=(b+.05)/(a+.05);assert ratio>=4.5,(theme,colors,ratio)
- ratios[theme]=round(ratio,2)
- ev('document.querySelector("#vulpes-gaia-notice").requestSubmit();return true;')
- ev('require("modules/settings_service").navigate("root");return true;')
- print(json.dumps({'storagePanels':3,'returnNavigation':True,'failureRecovery':True,'contrast':ratios}))
diff --git a/tundra/boot/sparse-backup.c b/tundra/boot/sparse-backup.c
deleted file mode 100644
index bfb82c98..00000000
--- a/tundra/boot/sparse-backup.c
+++ /dev/null
@@ -1,46 +0,0 @@
-/* SPDX-License-Identifier: MPL-2.0
- * Read stdin only. Emit offsets and nonzero 64 KiB blocks for a host-side backup.
- * No device is opened or written by this process.
- */
-#define _start unused_stage0_start
-#include "init.c"
-#undef _start
-static unsigned long buffer[8192];
-static void stop(long code) {
- call(NR_exit, code, 0, 0, 0, 0);
- for (;;) {}
-}
-static void write_all(const void *data, long length) {
- const char *p = data;
- while (length) {
- long n = call(NR_write, 1, (long)p, length, 0, 0);
- if (n == -4) continue;
- if (n <= 0) stop(3);
- p += n;
- length -= n;
- }
-}
-__attribute__((noreturn)) void _start(void) {
- unsigned long offset = 0;
- write_all("TUNRAW1\0", 8);
- for (;;) {
- long n = call(NR_read, 0, (long)buffer, sizeof(buffer), 0, 0);
- if (n == -4) continue;
- if (n < 0) stop(2);
- if (!n) break;
- unsigned long bits = 0;
- long i = 0;
- for (; i + 8 <= n; i += 8) bits |= buffer[i / 8];
- for (; i < n; ++i) bits |= ((unsigned char *)buffer)[i];
- if (bits) {
- unsigned long header[2] = {offset, (unsigned long)n};
- write_all(header, sizeof(header));
- write_all(buffer, n);
- }
- offset += n;
- }
- unsigned long end[2] = {offset, 0};
- write_all(end, sizeof(end));
- call(NR_exit, 0, 0, 0, 0, 0);
- for (;;) {}
-}
diff --git a/tundra/boot/standalone-mount.sh b/tundra/boot/standalone-mount.sh
deleted file mode 100644
index 384d0438..00000000
--- a/tundra/boot/standalone-mount.sh
+++ /dev/null
@@ -1,14 +0,0 @@
-#!/bin/busybox sh
-# SPDX-License-Identifier: MPL-2.0
-# Mount only the prepared Tundra volume. Never format or guess an old filesystem.
-set -eu
-# VOLUME_CONFIGURATION
-mkdir -p /volume
-blkid "$VOLUME_DEVICE" | grep -F "UUID=\"$VOLUME_UUID\"" >/dev/null
-repaired=0
-e2fsck -p "$VOLUME_DEVICE" || repaired=$?
-test "$repaired" -le 1
-mount -t ext4 -o "$VOLUME_OPTIONS" "$VOLUME_DEVICE" /volume
-test "$(cat /volume/tundra-volume-id)" = "$VOLUME_UUID"
-echo TUNDRA_STANDALONE_VOLUME_READY
-exec /bin/sh /native-root.sh
diff --git a/tundra/droidian/export-runtime.py b/tundra/droidian/export-runtime.py
index e52f5433..59bb93e7 100644
--- a/tundra/droidian/export-runtime.py
+++ b/tundra/droidian/export-runtime.py
@@ -140,12 +140,11 @@ def main():
ROOT_PACKAGES.extend(['network-manager', 'wpasupplicant'])
if args.with_hardware:
ROOT_PACKAGES.extend(['ofono', 'pulseaudio', 'pulseaudio-utils',
- 'pulseaudio-modules-droid-jb2q', 'pulseaudio-modules-droid-hidl', 'audiosystem-passthrough',
+ 'pulseaudio-modules-droid-jb2q', 'pulseaudio-modules-droid-hidl',
'gstreamer1.0-droid', 'bluebinder', 'libgbinder-radio',
'libgbinder-tools',
'libofonobinderpluginext', 'ofono-binder-plugin',
'ofono-configs-binder-common', 'ofono-scripts', 'ofono2mm',
- 'mobile-broadband-provider-info',
'pulseaudio-config-droid', 'droidian-camera',
'qt5-cameraplugin-aal', 'libhybris'])
packages = closure(installed_packages(), ROOT_PACKAGES)
diff --git a/tundra/droidian/probe.sys.mjs b/tundra/droidian/probe.sys.mjs
index dfdcff26..0949a2aa 100644
--- a/tundra/droidian/probe.sys.mjs
+++ b/tundra/droidian/probe.sys.mjs
@@ -67,11 +67,8 @@ export async function run(win) {
.then(async response=>dump(`TUNDRA_HTTP ${response.status} ${(await response.text()).length}\n`))
.catch(error=>dump('TUNDRA_HTTP_ERROR '+error+' offline='+Services.io.offline+'\n'));
async function evaluate(app, code, message = 'Evaluate') {
- const prefix=app==='contacts'?'http://communications.localhost:8765/contacts/':
- app==='dialer'?'http://communications.localhost:8765/dialer/':`http://${app}.localhost:8765/`;
function find(context) {
- const uri=context.currentWindowGlobal?.documentURI.spec;
- if(uri?.startsWith(prefix) || (app==='homescreen' && uri?.startsWith('http://verticalhome.localhost:8765/'))) return context;
+ if(context.currentWindowGlobal?.documentURI.spec.startsWith(`http://${app}.localhost:8765/`)) return context;
for(const child of context.children) {const found=find(child); if(found) return found;}
return null;
}
@@ -83,25 +80,24 @@ export async function run(win) {
sleep(30000).then(()=>{throw new Error('Content actor did not answer within 30 seconds');})
]);
}
- async function wait(app, code, timeout=90000) {
+ async function wait(app, code) {
dump(`TUNDRA_DROIDIAN_PROBE waiting ${app}: ${code}\n`);
let last;
- const deadline=Date.now()+timeout;
+ const deadline=Date.now()+90000;
while(Date.now()0 && y>0 && x0 && r.height>0 && e.contains(document.elementFromPoint(r.x+r.width/2,r.y+r.height/2));`);
+ await sleep(600);
+ const point = await evaluate(app, `const e=document.querySelector(${JSON.stringify(selector)});const r=e.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2};`);
+ await evaluate(app,point,'Tap');
+ await sleep(350);
+ }
try {
const lock=await IOUtils.readJSON(`${root}/engine-lock.json`);
if(report.engine.version!==lock.version || !report.engine.ABI.startsWith('aarch64'))
@@ -112,7 +108,7 @@ export async function run(win) {
report.checks.system=await wait('system',"return document.body?.getAttribute('ready-state')==='fullyLoaded';");
report.checks.home=await wait('homescreen',"return document.readyState==='complete' && document.body.innerText.length>0;");
report.checks.visible=await wait('system',"return document.visibilityState==='visible';");
- report.checks.bootLogoHidden=await wait('system',"const logo=document.getElementById('os-logo');return !logo || !logo.checkVisibility({checkOpacity:true,checkVisibilityCSS:true});");
+ report.checks.bootLogoHidden=await wait('system',"const logo=document.getElementById('os-logo');return !logo || getComputedStyle(logo).opacity==='0';");
if (Services.env.get('TUNDRA_PERSISTENT') === '1') {
const token = Services.uuid.generateUUID().toString();
report.persistence = await evaluate('system', `
@@ -233,7 +229,241 @@ export async function run(win) {
}
// Fixed diagnostics can be requested again over the private maintenance SSH.
// Markers never contain scripts or arbitrary commands.
- async function requestedControls() {
+ async function requestedUiChecks() {
+ const cameraMarker = `${root}/logs/test-camera`;
+ if (await IOUtils.exists(cameraMarker)) {
+ await IOUtils.remove(cameraMarker);
+ const cameraReport = {bootId:report.bootId, imagesRetained:false, passed:false};
+ try {
+ await evaluate('system', "ScreenManager.turnScreenOn(true);if(Service.query('locked'))await Service.request('unlock',{forcibly:true});return true;");
+ await wait('system', "return !Service.query('locked');");
+ await evaluate('system', "await VulpesCompat.call('apps.launch',{manifestURL:'http://camera.localhost:8765/manifest.webapp'});return true;");
+ await wait('camera', "return !!window.app?.camera?.mozCamera && document.querySelector('video')?.videoWidth>0;");
+ // Gaia remembers the selected lens; establish a known starting state.
+ await evaluate('camera', "if(app.settings.cameras.selected('key')!=='back')app.settings.cameras.next();return true;");
+ await wait('camera', "return app.settings.cameras.selected('key')==='back' && !!app.camera.mozCamera && !app.camera.isBusy && document.querySelector('.viewfinder video')?.videoWidth>0;");
+ cameraReport.gaia = await evaluate('camera', `
+ const video=document.querySelector('video');
+ const camera=app.camera.mozCamera;
+ window.__cameraBeforeSwitch=camera;
+ const capabilities=camera.capabilities;
+ let focus=null;
+ if(capabilities.maxFocusAreas>0) {
+ camera.setFocusAreas([{top:-150,left:-150,bottom:150,right:150}]);
+ await camera.autoFocus();focus=true;
+ }
+ if(capabilities.zoomRatios.length>1) {
+ camera.zoom=2;
+ await new Promise(r=>setTimeout(r,700));
+ }
+ const testedZoom=camera.zoom;
+ camera.zoom=1;
+ const photo=await camera.takePicture();
+ const bitmap=await createImageBitmap(photo);
+ const picture={width:bitmap.width,height:bitmap.height};bitmap.close();
+ return {width:video.videoWidth,height:video.videoHeight,native:camera.vulpesNative===true,
+ picture,focus,zoom:testedZoom,focusAreas:capabilities.maxFocusAreas,
+ jpegBytes:photo.size,jpegType:photo.type,errors:window.__vulpesDiagnostics};
+ `);
+ cameraReport.performance = await evaluate('camera', `
+ const stream=app.camera.mozCamera, before=stream.vulpesTiming?.frames||0;
+ const start=performance.now();await new Promise(r=>setTimeout(r,4000));
+ const elapsed=performance.now()-start;
+ const hud=document.querySelector('.hud');
+ const button=document.querySelector('.js-camera');
+ return {fps:(stream.vulpesTiming.frames-before)*1000/elapsed,
+ conversionMs:stream.vulpesTiming.conversionMs/stream.vulpesTiming.frames,
+ switchVisible:getComputedStyle(button).opacity!=='0' && hud.getAttribute('camera-enabled')==='true',
+ flashVisible:getComputedStyle(document.querySelector('.js-flash')).visibility==='visible'};
+ `);
+ await evaluate('camera', "document.querySelector('.js-camera').click();return true;");
+ await wait('camera', "return !!app.camera.mozCamera && app.camera.mozCamera!==window.__cameraBeforeSwitch && !app.camera.isBusy && document.querySelector('.viewfinder video')?.videoWidth>0;");
+ cameraReport.switched = await evaluate('camera', `
+ const photo=await app.camera.mozCamera.takePicture();
+ window.__cameraBeforeHome=app.camera.mozCamera;
+ return {camera:app.settings.cameras.selected('key'),jpegBytes:photo.size,
+ previousStopped:window.__cameraBeforeSwitch.getTracks().every(t=>t.readyState==='ended')};
+ `);
+ cameraReport.roundTrips = [];
+ for (let i=0; i<4; i++) {
+ await evaluate('camera', "window.__switchFrom=app.camera.mozCamera;return true;");
+ await tap('camera', '.js-camera');
+ await wait('camera', "return !!app.camera.mozCamera && app.camera.mozCamera!==window.__switchFrom && !app.camera.isBusy && document.querySelector('.viewfinder video')?.videoWidth>0;");
+ cameraReport.roundTrips.push(await evaluate('camera', `
+ const camera=app.camera.mozCamera, before=camera.vulpesTiming.frames;
+ await new Promise(r=>setTimeout(r,700));
+ return {camera:app.settings.cameras.selected('key'),
+ frames:camera.vulpesTiming.frames-before,
+ previousStopped:window.__switchFrom.getTracks().every(t=>t.readyState==='ended')};
+ `));
+ }
+ await evaluate('camera', 'window.__cameraBeforeHome=app.camera.mozCamera;return true;');
+ // Test Gaia's Home action, independent of a held pointer on the shell button.
+ cameraReport.beforeHome = await evaluate('system', "return {active:Service.query('getTopMostWindow')?.origin, top:Service.query('getTopMostUI')?.name, locked:Service.query('locked'), screen:ScreenManager.screenEnabled};");
+ await evaluate('system', "dispatchEvent(new CustomEvent('home'));return true;");
+ cameraReport.afterHome = await evaluate('system', "await new Promise(r=>setTimeout(r,1000));return {active:Service.query('getTopMostWindow')?.origin, top:Service.query('getTopMostUI')?.name, locked:Service.query('locked'), screen:ScreenManager.screenEnabled};");
+ cameraReport.hidden = await wait('camera', 'return document.hidden;');
+ cameraReport.stopped = await wait('camera', "return window.__cameraBeforeHome.getTracks().every(t=>t.readyState==='ended');");
+ cameraReport.passed=cameraReport.gaia.native && cameraReport.gaia.picture.width*cameraReport.gaia.picture.height>10000000 && cameraReport.gaia.focus && cameraReport.gaia.zoom>1 && cameraReport.gaia.jpegBytes>1000 && cameraReport.gaia.jpegType==='image/jpeg' && cameraReport.switched.jpegBytes>1000 && cameraReport.switched.previousStopped && cameraReport.stopped && cameraReport.roundTrips.every(r=>r.frames>0 && r.previousStopped);
+ } catch(error) { cameraReport.error=String(error); }
+ finally { win.document.getElementById('home')?.click(); }
+ await IOUtils.writeJSON(`${root}/logs/camera.json`, cameraReport);
+ }
+ const smsMarker = `${root}/logs/test-sms-display`;
+ if (await IOUtils.exists(smsMarker)) {
+ await IOUtils.remove(smsMarker);
+ const smsReport = {bootId:report.bootId, passed:false, sent:false};
+ try {
+ await evaluate('system', "ScreenManager.turnScreenOn(true);if(Service.query('locked'))await Service.request('unlock',{forcibly:true});await VulpesCompat.call('apps.launch',{manifestURL:'http://sms.localhost:8765/manifest.webapp'});return true;");
+ await wait('sms', "return !!window.Navigation && !!window.MessageManager;");
+ smsReport.threads = await evaluate('sms', `
+ const threads=await new Promise((resolve,reject)=>{const rows=[];const c=navigator.mozMobileMessage.getThreads();c.onsuccess=()=>{if(c.done)resolve(rows);else{rows.push(c.result);c.continue();}};c.onerror=()=>reject(c.error);});
+ window.__smsReviewThreads=threads.map(t=>t.id);
+ return {count:threads.length,validDates:threads.every(t=>Number.isFinite(+t.timestamp))};
+ `);
+ smsReport.conversations=[];
+ for(let i=0;iNumber.isFinite(+e.querySelector('time')?.dataset.time)),
+ nonempty:nodes.every(e=>!!e.querySelector('.message-content-body')?.textContent),
+ errorCount:window.__vulpesDiagnostics?.length||0};
+ `));
+ }
+ smsReport.passed=smsReport.threads.validDates && smsReport.conversations.length>0 && smsReport.conversations.every(c=>c.bubbles>0 && c.validDates && c.nonempty);
+ } catch(error) {smsReport.error=String(error);}
+ finally {win.document.getElementById('home')?.click();}
+ await IOUtils.writeJSON(`${root}/logs/sms-display.json`,smsReport);
+ }
+ const inputMarker = `${root}/logs/test-keyboard`;
+ if (await IOUtils.exists(inputMarker)) {
+ await IOUtils.remove(inputMarker);
+ const inputReport = {bootId:report.bootId,recordedAt:Date.now(),passed:false,sent:false};
+ try {
+ await evaluate('system', 'ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});return true;');
+ await sleep(1500);
+ await evaluate('system', `await VulpesCompat.call('apps.launch',{manifestURL:'http://sms.localhost:8765/manifest.webapp'});return true;`);
+ await wait('sms', 'return document.querySelector("#threads-composer-link") && innerWidth>0;');
+ await evaluate('sms', 'await Navigation.toPanel("thread-list");await Navigation.toPanel("composer");return true;');
+ await wait('sms','return !!document.querySelector(".recipient[contenteditable]");');
+ await tap('sms','.recipient[contenteditable]');
+ await wait('keyboard', 'return navigator.mozInputMethod?.inputcontext?.inputType==="tel" && !!document.querySelector("button[aria-label=\\"2\\"]");');
+ await tap('keyboard','button[aria-label="2"]');
+ await tap('keyboard','button[aria-label="3"]');
+ inputReport.recipientTyped = await evaluate('sms','return document.querySelector(".recipient[contenteditable]").textContent.endsWith("23");');
+ const recipientBefore=await evaluate('sms','return document.querySelector(".recipient[contenteditable]").textContent;');
+ await tap('sms','#messages-input');
+ await wait('keyboard', 'return navigator.mozInputMethod?.inputcontext?.inputType==="text" && !!document.querySelector("button[aria-label=\\"a\\"]");');
+ inputReport.keyboard = await evaluate('keyboard', `const e=document.querySelector('button[aria-label="a"]');const r=e.getBoundingClientRect();
+ window.__keyTrial=[];for(const type of ['touchstart','touchend','pointerdown','click']) e.addEventListener(type,()=>__keyTrial.push(type),{once:true});
+ return {width:innerWidth,height:innerHeight,key:r.toJSON(),font:getComputedStyle(document.documentElement).fontSize,portrait:app.viewManager.screenInPortraitMode(),engine:app.inputMethodManager.currentIMEngine?.constructor?.name};`);
+ await tap('keyboard','button[aria-label="a"]');
+ await sleep(2000);
+ inputReport.context = await evaluate('keyboard', 'const c=navigator.mozInputMethod.inputcontext;return c&&{id:c.id,type:c.type,inputType:c.inputType,textLength:c.text.length};');
+ inputReport.message = await evaluate('sms', 'const text=document.querySelector("#messages-input").textContent;return {length:text.length,lowercase:text.includes("a"),uppercase:text.includes("A")};');
+ inputReport.messageTyped = inputReport.message.lowercase || inputReport.message.uppercase;
+ inputReport.events = await evaluate('keyboard','return window.__keyTrial;');
+ inputReport.recipientUnchanged=await evaluate('sms',`return document.querySelector(".recipient[contenteditable]").textContent===${JSON.stringify(recipientBefore)};`);
+ inputReport.passed=inputReport.recipientTyped && inputReport.messageTyped && inputReport.recipientUnchanged && inputReport.keyboard.portrait && inputReport.keyboard.key.height>=44;
+ if(!inputReport.messageTyped) {
+ try {
+ await evaluate('keyboard','await navigator.mozInputMethod.inputcontext.sendKey(0,97);return true;');
+ inputReport.directKey = await evaluate('sms','return document.querySelector("#messages-input").textContent.length>0;');
+ } catch(error) {inputReport.directKeyError=String(error);}
+ }
+ inputReport.fonts=await evaluate('sms',null,'RenderedFonts');
+ for (const face of inputReport.fonts) delete face.samples;
+ } catch(error) {inputReport.error=String(error);}
+ finally {win.document.getElementById('home').click();}
+ await IOUtils.writeJSON(`${root}/logs/keyboard.json`,inputReport);
+ }
+ const sendMarker=`${root}/logs/test-send-sms.json`;
+ if(await IOUtils.exists(sendMarker)) {
+ const request=await IOUtils.readJSON(sendMarker);
+ await IOUtils.remove(sendMarker);
+ const result={bootId:report.bootId,recordedAt:Date.now(),submitted:false,deliveryConfirmed:false};
+ try {
+ if(typeof request.recipient!=='string' || !/^\+[0-9]{8,15}$/.test(request.recipient)) throw Error('INVALID_TEST_RECIPIENT');
+ const {tundraRadio}=ChromeUtils.importESModule('resource://vulpes/host/Tundra.sys.mjs');
+ const state=await tundraRadio('status');
+ if(!['registered','roaming'].includes(state.registration?.[0]?.Status?.data)) throw Error('RADIO_NOT_REGISTERED');
+ await evaluate('system', 'ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});return true;');
+ await sleep(1000);
+ await evaluate('system', `await VulpesCompat.call('apps.launch',{manifestURL:'http://sms.localhost:8765/manifest.webapp'});return true;`);
+ await wait('sms','return !!window.Navigation;');
+ await evaluate('sms','await Navigation.toPanel("thread-list");await Navigation.toPanel("composer");return true;');
+ await evaluate('sms','if(ConversationView.recipients.length || document.querySelector(".recipient[contenteditable]").textContent.trim() || Compose.getText())throw Error("TEST_DRAFT_PRESENT");return true;');
+ await tap('sms','.recipient[contenteditable]');
+ await wait('keyboard','return navigator.mozInputMethod?.inputcontext?.inputType==="tel";');
+ await evaluate('keyboard',`await navigator.mozInputMethod.inputcontext.replaceSurroundingText(${JSON.stringify(request.recipient)},0,0);return true;`);
+ await tap('sms','#messages-input');
+ await wait('keyboard','return navigator.mozInputMethod?.inputcontext?.inputType==="text";');
+ const body='Vulpes OS - test SMS '+new Date().toISOString();
+ await evaluate('keyboard',`await navigator.mozInputMethod.inputcontext.replaceSurroundingText(${JSON.stringify(body)},0,0);return true;`);
+ await wait('sms','return !document.querySelector("#messages-send-button").disabled;');
+ await evaluate('sms',`if(!Navigation.isCurrentPanel('composer') ||
+ JSON.stringify(ConversationView.recipients.numbers)!==JSON.stringify([${JSON.stringify(request.recipient)}]) ||
+ Compose.getText()!==${JSON.stringify(body)})throw Error('TEST_MESSAGE_MISMATCH');
+ document.querySelector('#messages-send-button').click();return true;`);
+ result.submitted=await wait('sms',`return await new Promise((resolve,reject)=>{
+ const cursor=navigator.mozMobileMessage.getMessages(null,true);
+ cursor.onsuccess=()=>{const m=cursor.result;if(!m){resolve(false);return;}
+ if(m.body===${JSON.stringify(body)}){if(m.delivery==='error'){reject(Error('MODEM_TRANSMISSION_FAILED'));return;}resolve(m.delivery==='sent');return;}cursor.continue();};
+ cursor.onerror=()=>reject(cursor.error);
+ });`);
+ } catch(error) {result.error=String(error);}
+ finally {win.document.getElementById('home').click();}
+ await IOUtils.writeJSON(`${root}/logs/send-sms.json`,result);
+ }
+ const notificationMarker = `${root}/logs/test-notifications`;
+ if (await IOUtils.exists(notificationMarker)) {
+ await IOUtils.remove(notificationMarker);
+ const result = {bootId:report.bootId,passed:false,silent:true};
+ let id;
+ try {
+ await evaluate('system', `ScreenManager.turnScreenOn(true);await VulpesCompat.call('apps.launch',{manifestURL:'http://sms.localhost:8765/manifest.webapp'});return true;`);
+ await wait('sms', 'return typeof Notification.get === "function";');
+ await evaluate('system', 'if(!Service.query("locked"))await Service.request("lock");return true;');
+ result.lockState=await evaluate('system','return {service:Service.query("locked"),direct:lockScreen.locked,preview:NotificationScreen.lockscreenPreview};');
+ await wait('system', 'return Service.query("locked") && NotificationScreen.lockscreenPreview;');
+ id = await evaluate('sms', `return await new Promise((resolve,reject)=>{
+ const n=new Notification('Vulpes — test local',{body:'Notification de qualification',tag:'vulpes-qualification',silent:true});
+ n.onshow=()=>resolve(n.id);n.onerror=()=>reject(n.error);
+ });`);
+ const selector = `[data-notification-id="${id}"]`;
+ result.tray = await wait('system', `return !!NotificationScreen.container.querySelector(${JSON.stringify(selector)});`);
+ result.lockscreen = await wait('system', `return !!NotificationScreen.getLockScreenContainer()?.querySelector(${JSON.stringify(selector)});`);
+ await evaluate('sms', `const rows=await Notification.get({tag:'vulpes-qualification'});rows.forEach(n=>n.close());return true;`);
+ result.removed = await wait('system', `return !NotificationScreen.container.querySelector(${JSON.stringify(selector)}) && !NotificationScreen.getLockScreenContainer()?.querySelector(${JSON.stringify(selector)});`);
+ result.passed = result.tray && result.lockscreen && result.removed;
+ } catch(error) { result.error=String(error); }
+ finally {
+ if(id) await evaluate('sms', `for(const n of await Notification.get({tag:'vulpes-qualification'})) n.close();return true;`).catch(()=>{});
+ win.document.getElementById('home').click();
+ }
+ await IOUtils.writeJSON(`${root}/logs/notifications.json`,result);
+ }
+ }
+ await requestedUiChecks();
+ let uiCheckRunning=false;
+ const uiCheckTimer=setInterval(async()=>{
+ if(uiCheckRunning) return;
+ uiCheckRunning=true;
+ try {
+ const reloadMarker=`${root}/logs/test-reload-gaia`;
+ if(await IOUtils.exists(reloadMarker)) {
+ await IOUtils.remove(reloadMarker);
+ win.document.querySelector('browser').reloadWithFlags(Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE);
+ await wait('system', "return document.body?.getAttribute('ready-state')==='fullyLoaded';");
+ await sleep(1500);
+ }
+ await requestedUiChecks();
+ } catch(error) {dump('TUNDRA_UI_CHECK_ERROR '+error+'\n');}
+ finally {uiCheckRunning=false;}
+ },3000);
+ win.addEventListener('unload',()=>clearInterval(uiCheckTimer),{once:true});
const controlsMarker = `${root}/logs/test-controls`;
if (await IOUtils.exists(controlsMarker)) {
await IOUtils.remove(controlsMarker);
@@ -339,569 +569,6 @@ export async function run(win) {
controls.brightness=brightnessCheck;
await IOUtils.writeJSON(`${root}/logs/controls.json`, controls);
}
- }
- async function requestedUiChecks() {
- const appearanceMarker = `${root}/logs/test-appearance`;
- if (await IOUtils.exists(appearanceMarker)) {
- await IOUtils.remove(appearanceMarker);
- const result={bootId:report.bootId,passed:false,checks:{}};
- let previous;
- try {
- await evaluate('system', 'ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});return true;');
- win.document.getElementById('home').click();
- await sleep(600);
- await evaluate('system', 'await VulpesCompat.call("apps.launch",{manifestURL:"http://settings.localhost:8765/manifest.webapp"});return true;');
- await wait('settings', 'return document.readyState==="complete";');
- previous=await evaluate('settings', 'return await VulpesCompat.call("appearance.current",{});');
- await evaluate('settings', 'const s=await new Promise(r=>require(["modules/settings_service"],r));s.navigate("display");return true;');
- await wait('settings', 'return !!document.getElementById("vulpes-theme");');
- await wait('system', 'return Service.query("AppWindowManager.getActiveApp")?.origin==="http://settings.localhost:8765";');
- await sleep(800);
- await evaluate('settings', 'document.getElementById("vulpes-theme").scrollIntoView({block:"center"});return true;');
- await tap('settings','#vulpes-theme');
- result.checks.selectorOpens=await wait('settings','return !!document.getElementById("vulpes-value-selector");',15000);
- await tap('settings','#vulpes-value-selector [data-value="dark"]');
- await tap('settings','#vulpes-value-selector [data-action="confirm"]',15000);
- for(const app of ['settings','system','homescreen'])
- result.checks[app]=await wait(app, 'return document.documentElement.dataset.vulpesTheme==="dark";');
- result.checks.visibleSettings=await wait('system', 'return Service.query("AppWindowManager.getActiveApp")?.origin==="http://settings.localhost:8765";');
- result.checks.header=await evaluate('settings', 'const h=document.querySelector("#display gaia-header");return getComputedStyle(h.shadowRoot.querySelector(".inner")).backgroundColor==="rgb(37, 52, 66)" && getComputedStyle(h.querySelector("h1")).color==="rgb(241, 245, 249)";');
- result.checks.label=await evaluate('settings', 'const l=document.querySelector("#vulpes-theme-label").getBoundingClientRect(),s=document.querySelector("#vulpes-theme").getBoundingClientRect();return l.height>30 && l.bottom<=s.top;');
- await sleep(1000);
- const bitmap=await win.browsingContext.currentWindowGlobal.drawSnapshot(new win.DOMRect(0,0,win.innerWidth,win.innerHeight),1,'white');
- const canvas=win.document.createElementNS('http://www.w3.org/1999/xhtml','canvas');
- canvas.width=bitmap.width;canvas.height=bitmap.height;canvas.getContext('2d').drawImage(bitmap,0,0);bitmap.close();
- const blob=await new Promise(resolve=>canvas.toBlob(resolve,'image/png'));
- await IOUtils.write(`${root}/logs/appearance-dark.png`,new Uint8Array(await blob.arrayBuffer()));
- await evaluate('settings', 'await navigator.mozSettings.createLock().set({"vulpes.theme":"light"});return true;');
- result.checks.returnLight=await wait('homescreen', 'return document.documentElement.dataset.vulpesTheme==="light";');
- result.passed=Object.values(result.checks).every(value=>value===true);
- } catch(error) {result.error=String(error);}
- finally {
- if(previous!==undefined) await evaluate('settings', `await navigator.mozSettings.createLock().set({"vulpes.theme":${JSON.stringify(previous)}});return true;`).catch(()=>{});
- }
- await IOUtils.writeJSON(`${root}/logs/appearance.json`,result);
- }
- const followupMarker=`${root}/logs/test-settings-followup`;
- if(await IOUtils.exists(followupMarker)) {
- await IOUtils.remove(followupMarker);
- const {auditFollowup}=ChromeUtils.importESModule('resource://vulpes/host/SettingsFollowupAudit.sys.mjs');
- const save=data=>IOUtils.writeJSON(`${root}/logs/settings-followup.json`,{bootId:report.bootId,...data});
- const microphonePrincipal=Services.scriptSecurityManager.createContentPrincipal(Services.io.newURI('http://camera.localhost:8765'),{});
- const microphonePermission=Services.perms.testExactPermissionFromPrincipal(microphonePrincipal,'microphone');
- // This private marker explicitly requests a short camera/audio test.
- Services.perms.addFromPrincipal(microphonePrincipal,'microphone',Ci.nsIPermissionManager.ALLOW_ACTION,Ci.nsIPermissionManager.EXPIRE_SESSION);
- try {await auditFollowup({evaluate,wait,sleep,save,tap});}
- catch(error){await save({passed:false,error:String(error)});}
- finally {
- if(microphonePermission===Ci.nsIPermissionManager.UNKNOWN_ACTION)Services.perms.removeFromPrincipal(microphonePrincipal,'microphone');
- else Services.perms.addFromPrincipal(microphonePrincipal,'microphone',microphonePermission);
- }
- }
- const auditMarker=`${root}/logs/test-settings-all`;
- if(await IOUtils.exists(auditMarker)) {
- await IOUtils.remove(auditMarker);
- const {auditSettings}=ChromeUtils.importESModule('resource://vulpes/host/SettingsAudit.sys.mjs');
- const save=data=>IOUtils.writeJSON(`${root}/logs/settings-all.json`,{bootId:report.bootId,...data});
- try {await save(await auditSettings({evaluate,wait,sleep,save}));}
- catch(error) {await IOUtils.writeJSON(`${root}/logs/settings-all-error.json`,{bootId:report.bootId,error:String(error)});}
- }
- const settingsMarker = `${root}/logs/test-settings-navigation`;
- if (await IOUtils.exists(settingsMarker)) {
- await IOUtils.remove(settingsMarker);
- const result={bootId:report.bootId,checks:{},errors:[]};
- try {
- await evaluate('system', 'ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});await VulpesCompat.call("apps.launch",{manifestURL:"http://settings.localhost:8765/manifest.webapp"});return true;');
- await wait('settings','return document.readyState==="complete";');
- await evaluate('settings','window.__vulpesDiagnostics=[];return true;');
- for(const [parent,id,selector] of [
- ['root','sim-manager','#menuItem-simManager'],
- ['root','call','#call-settings a'],
- ['root','messaging','#menuItem-messagingSettings'],
- ['wifi','wifi-manageNetworks','a[href="#wifi-manageNetworks"]'],
- ['wifi','wifi-manageCertificates','a[href="#wifi-manageCertificates"]']]) {
- try {
- await evaluate('settings',`const s=await new Promise(r=>require(["modules/settings_service"],r));await s.navigate(${JSON.stringify(parent)});return true;`);
- await sleep(700);
- await evaluate('settings',`document.querySelector(${JSON.stringify(selector)}).scrollIntoView({block:"center"});return true;`);
- await sleep(200);
- const start=Date.now();
- await tap('settings',selector,15000);
- await wait('settings',`return document.querySelector('section.current')?.id===${JSON.stringify(id)};`,15000);
- await sleep(800);
- result.checks[id]={opened:true,elapsedMs:Math.round(Date.now()-start),detail:await evaluate('settings',`const p=document.getElementById(${JSON.stringify(id)});return {textLength:p.innerText.length,simCards:p.querySelectorAll('.sim-card').length,explanation:!!p.querySelector('[data-vulpes-unavailable]')};`)};
- if(id==='messaging') {
- await wait('settings',`return document.querySelector('#messaging [name="ril.sms.requestStatusReport.enabled"]')?.dataset.vulpesSupported==="true";`,15000);
- const old=await evaluate('settings','return (await VulpesCompat.call("platform.mobileSettings",{action:"messages"})).deliveryReports;');
- try {
- await tap('settings','#messaging [name="ril.sms.requestStatusReport.enabled"]',15000);
- result.checks.smsDeliveryReports=await wait('settings',`return (await VulpesCompat.call("platform.mobileSettings",{action:"messages"})).deliveryReports===${!old};`,15000);
- } finally {
- await evaluate('settings',`await VulpesCompat.call("platform.mobileSettings",{action:"deliveryReports",enabled:${old}});await navigator.mozSettings.createLock().set({"ril.sms.requestStatusReport.enabled":${old}});return true;`);
- }
- }
- } catch(error) {result.checks[id]={error:String(error)};}
- }
- result.errors=await evaluate('settings','return window.__vulpesDiagnostics;');
- result.passed=Object.values(result.checks).every(v=>v===true || v.opened) && !result.errors.some(e=>e.kind==="error" || e.kind==="rejection");
- await evaluate('settings','const s=await new Promise(r=>require(["modules/settings_service"],r));await s.navigate("display");return true;');
- } catch(error) {result.error=String(error);result.passed=false;}
- await IOUtils.writeJSON(`${root}/logs/settings-navigation.json`,result);
- }
- await requestedControls();
- const marketplaceMarker = `${root}/logs/test-marketplace-views`;
- if (await IOUtils.exists(marketplaceMarker)) {
- await IOUtils.remove(marketplaceMarker);
- const result = {bootId:report.bootId,passed:false,checks:{},fixture:'http://127.0.0.1:8767/'};
- const {Views}=ChromeUtils.importESModule('resource://vulpes/host/Views.sys.mjs');
- const {Runtime}=ChromeUtils.importESModule('resource://vulpes/host/Runtime.sys.mjs');
- const until=async check=>{
- for(let i=0;i<80;i++){if(await check())return true;await sleep(250);}
- throw Error('Marketplace view check timed out');
- };
- const visible=()=>[...Views.entries.values()].some(e=>e.browser.style.visibility==='visible');
- const open=async()=>{
- await evaluate('system', 'await VulpesCompat.call("apps.launch",{manifestURL:"http://marketplace.localhost:8765/manifest.webapp"});return true;');
- await wait('marketplace', 'return !!document.getElementById("browse");');
- await sleep(1000);
- await evaluate('marketplace', 'document.getElementById("browse").click();document.getElementById("catalogue").src="http://127.0.0.1:8767/";return true;');
- await until(visible);
- };
- try {
- await evaluate('system', 'ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});return true;');
- await sleep(1000);
- // This fixture tests web-view ownership, not the lockscreen gesture.
- // Service.unlock can leave the legacy overlay visible during clock teardown.
- result.unlockFixture=await evaluate('system', 'let note=null;if(window.lockScreen){try{if(lockScreen.locked)lockScreen.unlock(true);}catch(e){note=String(e);}lockScreen.locked=false;lockScreen.overlay.classList.add("unlocked");}return {forced:true,cleanupError:note};');
- await open();
- result.checks.belowStatusBar=[...Views.entries.values()].filter(e=>e.browser.style.visibility==='visible').every(e=>parseFloat(e.browser.style.top)>=20);
- await evaluate('marketplace', 'document.getElementById("catalogue").src="https://marketplace-offline.invalid/";return true;');
- result.checks.offlineMessage=await wait('marketplace', 'return !document.getElementById("message").hidden && /inaccessible|unavailable/.test(document.getElementById("status").textContent);');
- result.checks.errorHidden=await until(()=>!visible());
- await open();
- result.checks.retry=true;
- win.document.getElementById('home').click();
- result.checks.home=await until(()=>!visible());
- await sleep(1000);
- await open();
- Runtime.broadcast('holdhome',{},'system');
- await wait('system','return appWindowManager.taskManager.isActive();');
- result.checks.taskSwitcher=await until(()=>!visible());
- await sleep(1000);
- await evaluate('system', 'const row=[...appWindowManager.taskManager.appToCardMap].find(([app])=>app.origin.includes("marketplace.localhost"));row[1].element.querySelector(".close-button").click();return true;');
- result.checks.closed=await until(()=>Views.entries.size===0);
- result.passed=Object.values(result.checks).every(Boolean);
- } catch(error) {
- result.error=String(error);
- result.views=[...Views.entries.values()].map(e=>({geometry:e.geometry,style:e.browser.style.cssText}));
- } finally {win.document.getElementById('home').click();}
- await IOUtils.writeJSON(`${root}/logs/marketplace-views.json`,result);
- }
- const ticketMarker = `${root}/logs/test-tickets-20261005`;
- if (await IOUtils.exists(ticketMarker)) {
- await IOUtils.remove(ticketMarker);
- const result = {bootId:report.bootId,passed:false,checks:{}};
- try {
- await evaluate('system', "ScreenManager.turnScreenOn(true);if(Service.query('locked'))await Service.request('unlock',{forcibly:true});return true;");
- await wait('system', "return !Service.query('locked');");
- await evaluate('system', "await VulpesCompat.call('apps.launch',{manifestURL:'http://settings.localhost:8765/manifest.webapp'});return true;");
- await wait('settings', "return document.readyState==='complete';");
- await evaluate('settings', "window.__ticketLanguage=(await VulpesCompat.call('settings.get',{key:'language.current'})).value;return true;");
- for (const lang of ['en-US','fr']) {
- await evaluate('settings', `await VulpesCompat.call('settings.set',{values:{'language.current':${JSON.stringify(lang)}}});return true;`);
- result.checks['language_'+lang] = await wait('homescreen', `return navigator.language===${JSON.stringify(lang)} && document.documentElement.lang===${JSON.stringify(lang)};`);
- }
- result.checks.developmentHidden = await evaluate('homescreen', "return [...document.querySelectorAll('gaia-app-icon')].every(e=>!/(uitest|test-|membuster|template|ds-test)/.test(e.app?.origin || ''));");
- result.checks.snap = await evaluate('homescreen', "return getComputedStyle(document.getElementById('panels')).scrollSnapType==='x mandatory';");
- await evaluate('settings', "window.__ticketPicker=new MozActivity({name:'pick',data:{type:'image/*'}});return true;");
- await wait('wallpaper', "return !!window.Wallpaper?.pickActivity && document.querySelectorAll('.wallpaper').length>1;");
- await evaluate('wallpaper', "document.querySelectorAll('.wallpaper')[1].click();return true;");
- result.checks.wallpaperPicker = await wait('settings', "return __ticketPicker.readyState==='done' && __ticketPicker.result?.blob instanceof Blob && __ticketPicker.result.blob.size>0;");
- await evaluate('settings', "window.__ticketPicker=new MozActivity({name:'pick',data:{type:'alerttone'}});return true;");
- await wait('ringtones', "return document.body.dataset.ready==='true' && document.querySelectorAll('gaia-radio').length>3;");
- await evaluate('ringtones', "document.querySelectorAll('gaia-radio')[2].click();return true;");
- await wait('ringtones', "return !document.getElementById('set').disabled;");
- await evaluate('ringtones', "document.getElementById('set').click();return true;");
- result.checks.tonePicker = await wait('settings', "return __ticketPicker.readyState==='done' && __ticketPicker.result?.blob instanceof Blob && __ticketPicker.result.blob.size>0;");
- result.passed=Object.values(result.checks).every(Boolean);
- } catch(error) {result.error=String(error);}
- finally {
- await evaluate('settings', "if(window.__ticketLanguage)await VulpesCompat.call('settings.set',{values:{'language.current':__ticketLanguage}});return true;").catch(()=>{});
- win.document.getElementById('home').click();
- }
- await IOUtils.writeJSON(`${root}/logs/tickets-20261005.json`,result);
- }
- const bluetoothMarker = `${root}/logs/test-bluetooth`;
- if (await IOUtils.exists(bluetoothMarker)) {
- await IOUtils.remove(bluetoothMarker);
- const result={bootId:report.bootId,passed:false,checks:{}};
- await IOUtils.writeJSON(`${root}/logs/bluetooth.json`,result);
- let before;
- try {
- await evaluate('system', "ScreenManager.turnScreenOn(true);if(Service.query('locked'))await Service.request('unlock',{forcibly:true});return true;");
- await evaluate('system', "await VulpesCompat.call('apps.launch',{manifestURL:'http://settings.localhost:8765/manifest.webapp'});return true;");
- await wait('settings', "return !!navigator.mozBluetooth?.defaultAdapter;");
- before=await evaluate('settings', "return navigator.mozBluetooth.defaultAdapter.state==='enabled';");
- await evaluate('settings', "require(['modules/settings_service'],s=>s.navigate('bluetooth'));return true;");
- result.checks.originalPanel=await wait('settings', "return !!document.querySelector('#bluetooth.current .bluetooth-status');");
- await evaluate('settings', "require(['modules/bluetooth/bluetooth_context'],c=>{window.__btContext=c;c.setEnabled(true);});return true;");
- result.checks.enabled=await wait('settings', "return navigator.mozBluetooth.defaultAdapter.state==='enabled';");
- await evaluate('settings', "window.__btFound=new Set();window.__btScanPromise=navigator.mozBluetooth.defaultAdapter.startDiscovery().then(h=>h.ondevicefound=e=>__btFound.add(e.device.address));return true;");
- result.checks.discovering=await wait('settings', "return navigator.mozBluetooth.defaultAdapter.discovering;");
- await sleep(7000);
- result.deviceCount=await evaluate('settings', "return __btFound.size;");
- await evaluate('settings', "await navigator.mozBluetooth.defaultAdapter.stopDiscovery();return true;");
- result.checks.stopped=await wait('settings', "return !navigator.mozBluetooth.defaultAdapter.discovering;");
- result.passed=Object.values(result.checks).every(Boolean);
- } catch(error) {result.error=String(error);}
- finally {
- if(before!==undefined)await evaluate('settings', `await navigator.mozBluetooth.defaultAdapter.${before?'enable':'disable'}();return true;`).catch(()=>{});
- win.document.getElementById('home').click();
- }
- await IOUtils.writeJSON(`${root}/logs/bluetooth.json`,result);
- }
- const cameraMarker = `${root}/logs/test-camera`;
- if (await IOUtils.exists(cameraMarker)) {
- await IOUtils.remove(cameraMarker);
- const cameraReport = {bootId:report.bootId, imagesRetained:false, passed:false};
- await IOUtils.writeJSON(`${root}/logs/camera.json`,cameraReport);
- try {
- await evaluate('system', "ScreenManager.turnScreenOn(true);if(Service.query('locked'))await Service.request('unlock',{forcibly:true});return true;");
- await wait('system', "return !Service.query('locked');");
- await evaluate('system', "await VulpesCompat.call('apps.launch',{manifestURL:'http://camera.localhost:8765/manifest.webapp'});return true;");
- await wait('camera', "return !!window.app?.camera?.mozCamera && app.camera.mozCamera.vulpesPreviewCanvas?.isConnected;");
- // Gaia remembers the selected lens; establish a known starting state.
- await evaluate('camera', "if(app.settings.cameras.selected('key')!=='back')app.settings.cameras.next();return true;");
- await wait('camera', "return app.settings.cameras.selected('key')==='back' && !!app.camera.mozCamera && !app.camera.isBusy && app.camera.mozCamera.vulpesPreviewCanvas?.isConnected;");
- cameraReport.gaia = await evaluate('camera', `
- const video=app.camera.mozCamera.vulpesPreviewCanvas;
- const camera=app.camera.mozCamera;
- window.__cameraBeforeSwitch=camera;
- const capabilities=camera.capabilities;
- let focus=null, focusError=null;
- if(capabilities.maxFocusAreas>0) {
- try {
- camera.setFocusAreas([{top:-150,left:-150,bottom:150,right:150}]);
- await camera.autoFocus();focus=true;
- } catch(error) { focus=false;focusError=String(error); }
- }
- if(capabilities.zoomRatios.length>1) {
- camera.zoom=2;
- await new Promise(r=>setTimeout(r,700));
- }
- const testedZoom=camera.zoom;
- camera.zoom=1;
- const photo=await camera.takePicture();
- const bitmap=await createImageBitmap(photo);
- const picture={width:bitmap.width,height:bitmap.height};bitmap.close();
- return {width:video.width,height:video.height,native:camera.vulpesNative===true,
- picture,focus,focusError,zoom:testedZoom,focusAreas:capabilities.maxFocusAreas,
- jpegBytes:photo.size,jpegType:photo.type,errors:window.__vulpesDiagnostics};
- `);
- cameraReport.performance = await evaluate('camera', `
- const stream=app.camera.mozCamera, before=stream.vulpesTiming?.frames||0;
- const video=app.camera.mozCamera.vulpesPreviewCanvas;
- let decoded=0,frameCallback;const onVideoFrame=()=>{decoded++;frameCallback=requestAnimationFrame(onVideoFrame);};
- frameCallback=requestAnimationFrame(onVideoFrame);
- const start=performance.now();await new Promise(r=>setTimeout(r,4000));
- const elapsed=performance.now()-start;
- if(frameCallback!==undefined)cancelAnimationFrame(frameCallback);
- const hud=document.querySelector('.hud');
- const button=document.querySelector('.js-camera');
- return {fps:(stream.vulpesTiming.frames-before)*1000/elapsed,
- conversionMs:stream.vulpesTiming.conversionMs/stream.vulpesTiming.frames,
- animationFps:decoded*1000/elapsed,
- transferMs:stream.vulpesTiming.transferMs/stream.vulpesTiming.frames,
- switchVisible:getComputedStyle(button).opacity!=='0' && hud.getAttribute('camera-enabled')==='true',
- flashVisible:getComputedStyle(document.querySelector('.js-flash')).visibility==='visible'};
- `);
- await IOUtils.writeJSON(`${root}/logs/camera.json`,cameraReport);
- await evaluate('camera', "document.querySelector('.js-camera').click();return true;");
- await wait('camera', "return !!app.camera.mozCamera && app.camera.mozCamera!==window.__cameraBeforeSwitch && !app.camera.isBusy && app.camera.mozCamera.vulpesPreviewCanvas?.isConnected;");
- cameraReport.switched = await evaluate('camera', `
- const photo=await app.camera.mozCamera.takePicture();
- window.__cameraBeforeHome=app.camera.mozCamera;
- return {camera:app.settings.cameras.selected('key'),jpegBytes:photo.size,
- previousStopped:window.__cameraBeforeSwitch.vulpesClosed===true};
- `);
- cameraReport.roundTrips = [];
- for (let i=0; i<8; i++) {
- await evaluate('camera', "window.__switchFrom=app.camera.mozCamera;return true;");
- await tap('camera', '.js-camera');
- await wait('camera', "return !!app.camera.mozCamera && app.camera.mozCamera!==window.__switchFrom && !app.camera.isBusy && app.camera.mozCamera.vulpesPreviewCanvas?.isConnected;");
- cameraReport.roundTrips.push(await evaluate('camera', `
- const camera=app.camera.mozCamera, before=camera.vulpesTiming.frames;
- const video=app.camera.mozCamera.vulpesPreviewCanvas;
- let decoded=0,frameCallback;const onVideoFrame=()=>{decoded++;frameCallback=requestAnimationFrame(onVideoFrame);};
- frameCallback=requestAnimationFrame(onVideoFrame);
- await new Promise(r=>setTimeout(r,1500));
- if(frameCallback!==undefined)cancelAnimationFrame(frameCallback);
- const canvas=document.createElement('canvas');canvas.width=16;canvas.height=16;
- const ctx=canvas.getContext('2d');ctx.drawImage(video,0,0,16,16);
- const pixels=ctx.getImageData(0,0,16,16).data;
- let light=0;for(let n=0;nf.name));return true;");
- await tap('camera','.js-capture');
- await wait('camera', "const files=await VulpesCompat.call('media.request',{type:'pictures',operation:'enumerate'});window.__cameraSavedPhoto=files.find(f=>!__cameraFilesBefore.has(f.name));return !!__cameraSavedPhoto;");
- cameraReport.savedPhoto=await evaluate('camera', 'return {name:__cameraSavedPhoto.name,size:__cameraSavedPhoto.size,type:__cameraSavedPhoto.type};');
- await evaluate('system', "await VulpesCompat.call('apps.launch',{manifestURL:'http://gallery.localhost:8765/manifest.webapp'});return true;");
- await wait('gallery', `return typeof files!=='undefined' && files.some(f=>f.name===${JSON.stringify(cameraReport.savedPhoto.name)} && f.metadata.width>0 && f.metadata.thumbnail);`);
- await evaluate('gallery', `document.querySelector('[data-filename="'+${JSON.stringify(cameraReport.savedPhoto.name)}+'"]')?.click();return true;`);
- cameraReport.galleryOpened=await wait('gallery', "return document.body.classList.contains('fullscreenView');");
- await evaluate('system', "await VulpesCompat.call('apps.launch',{manifestURL:'http://camera.localhost:8765/manifest.webapp'});return true;");
- await wait('camera', "return !!app.camera.mozCamera && !app.camera.isBusy && !document.hidden;");
- await evaluate('camera', `await navigator.getDeviceStorage('pictures').delete(${JSON.stringify(cameraReport.savedPhoto.name)});window.__cameraBeforeHome=app.camera.mozCamera;return true;`);
- cameraReport.captureStored=cameraReport.savedPhoto.size>1000;
- delete cameraReport.savedPhoto.name;
- cameraReport.beforeHome = await evaluate('system', "return {active:Service.query('getTopMostWindow')?.origin, top:Service.query('getTopMostUI')?.name, locked:Service.query('locked'), screen:ScreenManager.screenEnabled};");
- await evaluate('system', "dispatchEvent(new CustomEvent('home'));return true;");
- cameraReport.afterHome = await evaluate('system', "await new Promise(r=>setTimeout(r,1000));return {active:Service.query('getTopMostWindow')?.origin, top:Service.query('getTopMostUI')?.name, locked:Service.query('locked'), screen:ScreenManager.screenEnabled};");
- cameraReport.hidden = await wait('camera', 'return document.hidden;');
- cameraReport.stopped = await wait('camera', "return window.__cameraBeforeHome.vulpesClosed===true;");
- cameraReport.passed=cameraReport.captureStored && cameraReport.galleryOpened && cameraReport.gaia.native && cameraReport.gaia.picture.width*cameraReport.gaia.picture.height>10000000 && cameraReport.gaia.focus && cameraReport.gaia.zoom>1 && cameraReport.gaia.jpegBytes>1000 && cameraReport.gaia.jpegType==='image/jpeg' && cameraReport.switched.jpegBytes>1000 && cameraReport.switched.previousStopped && cameraReport.stopped && cameraReport.roundTrips.length===8 && cameraReport.roundTrips.every(r=>r.frames>0 && r.animationFrames>0 && r.width>0 && r.visible && r.previousStopped);
- } catch(error) { cameraReport.error=String(error); }
- finally { win.document.getElementById('home')?.click(); }
- await IOUtils.writeJSON(`${root}/logs/camera.json`, cameraReport);
- }
- const contactsMarker=`${root}/logs/test-contacts`;
- if(await IOUtils.exists(contactsMarker)) {
- await IOUtils.remove(contactsMarker);
- const result={bootId:report.bootId,passed:false}, name='Vulpes QA '+Date.now();
- let id;
- const lookup=`const r=navigator.mozContacts.find({filterBy:['givenName'],filterOp:'equals',filterValue:${JSON.stringify(name)}});
- return await new Promise((ok,no)=>{r.onsuccess=()=>ok(r.result[0]||null);r.onerror=()=>no(r.error);});`;
- const field=async(selector,value)=>evaluate('contacts',`const e=document.querySelector(${JSON.stringify(selector)});
- e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('input',{bubbles:true}));return true;`);
- try {
- await evaluate('system', "ScreenManager.turnScreenOn(true);if(Service.query('locked'))await Service.request('unlock',{forcibly:true});await VulpesCompat.call('apps.launch',{manifestURL:'http://communications.localhost:8765/manifest.webapp',entryPoint:'contacts'});return true;");
- await wait('contacts', "return typeof Loader!=='undefined' && window.MainNavigation?.currentView()==='view-contacts-list';");
- await tap('contacts','#add-contact-button');
- await wait('contacts', "return MainNavigation.currentView()==='view-contact-form' && !!document.querySelector('#number_0');");
- await field('#givenName',name);await field('#familyName','Élodie Test');
- await field('#number_0','5550199');await field('#email_0','qa@example.invalid');
- await tap('contacts','#save-button');
- const saved=await wait('contacts',lookup);id=saved.id;
- result.created=saved.tel[0].value==='5550199';
- const selector='[data-uuid="'+id+'"]';
- await tap('contacts',selector);await tap('contacts','#edit-contact-button');
- await wait('contacts', "return MainNavigation.currentView()==='view-contact-form';");
- await field('#familyName','Élodie Modifiée');await tap('contacts','#save-button');
- result.edited=await wait('contacts', "return document.querySelector('#contact-name-title')?.textContent.includes('Élodie Modifiée');");
- await evaluate('contacts', "document.querySelector('#send-sms-button-0').scrollIntoView({block:'center'});return true;");
- await tap('contacts','#send-sms-button-0');
- result.sms=await wait('sms', "return !document.hidden && window.ConversationView?.recipients?.numbers.includes('5550199');");
- await tap('sms','#messages-contact-pick-button');
- await wait('contacts', 'return !document.hidden && ActivityHandler.currentlyHandling;');
- await tap('contacts',selector);await tap('contacts','[data-l10n-id=pick_destination]');
- await wait('contacts','return !ActivityHandler.currentlyHandling;');
- result.picker=await wait('sms', "return !document.hidden && ConversationView.recipients.numbers.includes('5550199');");
- await evaluate('sms', "new MozActivity({name:'dial',data:{type:'webtelephony/number',number:'5550199'}});return true;");
- result.dialer=await wait('dialer', "return !document.hidden && document.querySelector('#phone-number-view')?.value==='5550199';");
- result.passed=result.created&&result.edited&&result.sms&&result.picker&&result.dialer;
- } catch(error) {result.error=String(error);}
- finally {
- try {
- if(!id) id=(await evaluate('contacts',lookup))?.id;
- if(id) {
- await evaluate('contacts', `const r=navigator.mozContacts.remove(${JSON.stringify(id)});await new Promise((ok,no)=>{r.onsuccess=ok;r.onerror=()=>no(r.error);});return true;`);
- result.temporaryContactRemoved=!(await evaluate('contacts',lookup));
- }
- } catch(error) {result.cleanupError=String(error);}
- win.document.getElementById('home')?.click();
- }
- await IOUtils.writeJSON(`${root}/logs/contacts.json`,result);
- }
- const smsMarker = `${root}/logs/test-sms-display`;
- if (await IOUtils.exists(smsMarker)) {
- await IOUtils.remove(smsMarker);
- const smsReport = {bootId:report.bootId, passed:false, sent:false};
- try {
- await evaluate('system', "ScreenManager.turnScreenOn(true);if(Service.query('locked'))await Service.request('unlock',{forcibly:true});await VulpesCompat.call('apps.launch',{manifestURL:'http://sms.localhost:8765/manifest.webapp'});return true;");
- await wait('sms', "return typeof window.Navigation?.toPanel === 'function' && !!window.MessageManager;");
- smsReport.threads = await evaluate('sms', `
- const threads=await new Promise((resolve,reject)=>{const rows=[];const c=navigator.mozMobileMessage.getThreads();c.onsuccess=()=>{if(c.done)resolve(rows);else{rows.push(c.result);c.continue();}};c.onerror=()=>reject(c.error);});
- window.__smsReviewThreads=threads.map(t=>t.id);
- return {count:threads.length,validDates:threads.every(t=>Number.isFinite(+t.timestamp))};
- `);
- smsReport.conversations=[];
- for(let i=0;iNumber.isFinite(+e.querySelector('time')?.dataset.time)),
- nonempty:nodes.every(e=>!!e.querySelector('.message-content-body')?.textContent),
- errorCount:window.__vulpesDiagnostics?.length||0};
- `));
- }
- smsReport.passed=smsReport.threads.validDates && smsReport.conversations.length>0 && smsReport.conversations.every(c=>c.bubbles>0 && c.validDates && c.nonempty);
- } catch(error) {smsReport.error=String(error);}
- finally {win.document.getElementById('home')?.click();}
- await IOUtils.writeJSON(`${root}/logs/sms-display.json`,smsReport);
- }
- const inputMarker = `${root}/logs/test-keyboard`;
- if (await IOUtils.exists(inputMarker)) {
- await IOUtils.remove(inputMarker);
- const inputReport = {bootId:report.bootId,recordedAt:Date.now(),passed:false,sent:false};
- try {
- await evaluate('system', 'ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});return true;');
- await sleep(1500);
- await evaluate('system', `await VulpesCompat.call('apps.launch',{manifestURL:'http://sms.localhost:8765/manifest.webapp'});return true;`);
- await wait('sms', 'return document.querySelector("#threads-composer-link") && innerWidth>0;');
- await evaluate('sms', 'await Navigation.toPanel("thread-list");await Navigation.toPanel("composer");return true;');
- await wait('sms','return !!document.querySelector(".recipient[contenteditable]");');
- await tap('sms','.recipient[contenteditable]');
- await wait('keyboard', 'return navigator.mozInputMethod?.inputcontext?.inputType==="tel" && !!document.querySelector("button[aria-label=\\"2\\"]");');
- await tap('keyboard','button[aria-label="2"]');
- await tap('keyboard','button[aria-label="3"]');
- inputReport.recipientTyped = await evaluate('sms','return document.querySelector(".recipient[contenteditable]").textContent.endsWith("23");');
- const recipientBefore=await evaluate('sms','return document.querySelector(".recipient[contenteditable]").textContent;');
- await tap('sms','#messages-input');
- await wait('keyboard', 'return navigator.mozInputMethod?.inputcontext?.inputType==="text" && !!document.querySelector("button[aria-label=\\"a\\"]");');
- inputReport.keyboard = await evaluate('keyboard', `const e=document.querySelector('button[aria-label="a"]');const r=e.getBoundingClientRect();
- window.__keyTrial=[];for(const type of ['touchstart','touchend','pointerdown','click']) e.addEventListener(type,()=>__keyTrial.push(type),{once:true});
- return {width:innerWidth,height:innerHeight,key:r.toJSON(),font:getComputedStyle(document.documentElement).fontSize,portrait:app.viewManager.screenInPortraitMode(),engine:app.inputMethodManager.currentIMEngine?.constructor?.name};`);
- await tap('keyboard','button[aria-label="a"]');
- await sleep(2000);
- inputReport.context = await evaluate('keyboard', 'const c=navigator.mozInputMethod.inputcontext;return c&&{id:c.id,type:c.type,inputType:c.inputType,textLength:c.text.length};');
- inputReport.message = await evaluate('sms', 'const text=document.querySelector("#messages-input").textContent;return {length:text.length,lowercase:text.includes("a"),uppercase:text.includes("A")};');
- inputReport.messageTyped = inputReport.message.lowercase || inputReport.message.uppercase;
- inputReport.events = await evaluate('keyboard','return window.__keyTrial;');
- inputReport.recipientUnchanged=await evaluate('sms',`return document.querySelector(".recipient[contenteditable]").textContent===${JSON.stringify(recipientBefore)};`);
- inputReport.passed=inputReport.recipientTyped && inputReport.messageTyped && inputReport.recipientUnchanged && inputReport.keyboard.portrait && inputReport.keyboard.key.height>=44;
- if(!inputReport.messageTyped) {
- try {
- await evaluate('keyboard','await navigator.mozInputMethod.inputcontext.sendKey(0,97);return true;');
- inputReport.directKey = await evaluate('sms','return document.querySelector("#messages-input").textContent.length>0;');
- } catch(error) {inputReport.directKeyError=String(error);}
- }
- inputReport.fonts=await evaluate('sms',null,'RenderedFonts');
- for (const face of inputReport.fonts) delete face.samples;
- } catch(error) {inputReport.error=String(error);}
- finally {win.document.getElementById('home').click();}
- await IOUtils.writeJSON(`${root}/logs/keyboard.json`,inputReport);
- }
- const sendMarker=`${root}/logs/test-send-sms.json`;
- if(await IOUtils.exists(sendMarker)) {
- const request=await IOUtils.readJSON(sendMarker);
- await IOUtils.remove(sendMarker);
- const result={bootId:report.bootId,recordedAt:Date.now(),submitted:false,deliveryConfirmed:false};
- try {
- if(typeof request.recipient!=='string' || !/^\+[0-9]{8,15}$/.test(request.recipient)) throw Error('INVALID_TEST_RECIPIENT');
- const {tundraRadio}=ChromeUtils.importESModule('resource://vulpes/host/Tundra.sys.mjs');
- const state=await tundraRadio('status');
- if(!['registered','roaming'].includes(state.registration?.[0]?.Status?.data)) throw Error('RADIO_NOT_REGISTERED');
- await evaluate('system', 'ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});return true;');
- await sleep(1000);
- await evaluate('system', `await VulpesCompat.call('apps.launch',{manifestURL:'http://sms.localhost:8765/manifest.webapp'});return true;`);
- await wait('sms',"return typeof window.Navigation?.toPanel === 'function';");
- await evaluate('sms','await Navigation.toPanel("thread-list");await Navigation.toPanel("composer");return true;');
- await evaluate('sms','if(ConversationView.recipients.length || document.querySelector(".recipient[contenteditable]").textContent.trim() || Compose.getText())throw Error("TEST_DRAFT_PRESENT");return true;');
- await tap('sms','.recipient[contenteditable]');
- await wait('keyboard','return navigator.mozInputMethod?.inputcontext?.inputType==="tel";');
- await evaluate('keyboard',`await navigator.mozInputMethod.inputcontext.replaceSurroundingText(${JSON.stringify(request.recipient)},0,0);return true;`);
- await tap('sms','#messages-input');
- await wait('keyboard','return navigator.mozInputMethod?.inputcontext?.inputType==="text";');
- const isMms=request.type==='mms';
- const body='Vulpes OS - test '+(isMms?'MMS':'SMS')+' '+new Date().toISOString();
- const filename='vulpes-test-'+Date.now()+'.png';
- await evaluate('keyboard',`await navigator.mozInputMethod.inputcontext.replaceSurroundingText(${JSON.stringify(body)},0,0);return true;`);
- if(isMms) {
- await evaluate('sms',`const c=document.createElement('canvas');c.width=c.height=64;
- const ctx=c.getContext('2d');ctx.fillStyle='#197dd2';ctx.fillRect(0,0,64,64);
- const blob=await new Promise(resolve=>c.toBlob(resolve,'image/png'));
- Compose.append(new Attachment(blob,{name:${JSON.stringify(filename)},isDraft:true}));return true;`);
- await wait('sms',"return Compose.type==='mms';");
- }
- await wait('sms','return !document.querySelector("#messages-send-button").disabled;');
- result.preSend=await evaluate('sms',`
- // Gaia also accepts a number still being edited when Send is pressed.
- const numbers=ConversationView.recipients.numbers.slice();
- const pending=document.querySelector('.recipient[contenteditable]')?.textContent.trim();
- const normalize=n=>navigator.mozPhoneNumberService.normalize(n);
- if(pending && !numbers.some(n=>normalize(n)===normalize(pending)))numbers.push(pending);
- return {composer:Navigation.isCurrentPanel('composer'),
- recipientMatch:JSON.stringify(numbers.map(n=>navigator.mozPhoneNumberService.normalize(n)))===JSON.stringify([${JSON.stringify(request.recipient)}]),
- recipientCount:numbers.length,
- bodyMatch:Compose.getText()===${JSON.stringify(body)},bodyLength:Compose.getText().length,
- expectedLength:${body.length}};`);
- if(!result.preSend.composer || !result.preSend.recipientMatch || !result.preSend.bodyMatch)
- throw Error('TEST_MESSAGE_MISMATCH');
- await evaluate('sms',"document.querySelector('#messages-send-button').click();return true;");
- result.submitted=await wait('sms',`return await new Promise((resolve,reject)=>{
- const cursor=navigator.mozMobileMessage.getMessages(null,true);
- cursor.onsuccess=()=>{const m=cursor.result;if(!m){resolve(false);return;}
- const match=${isMms} ? m.type==='mms' && m.attachments.some(a=>a.location===${JSON.stringify(filename)}) : m.body===${JSON.stringify(body)};
- if(match){window.__sentTestId=m.id;if(m.delivery==='error'){reject(Error('MODEM_TRANSMISSION_FAILED'));return;}resolve(m.delivery==='sent');return;}cursor.continue();};
- cursor.onerror=()=>reject(cursor.error);
- });`);
- if(isMms) {
- result.operatorAccepted=result.submitted;
- result.conversationVisible=await wait('sms',"return !!document.querySelector('#message-'+window.__sentTestId+' .attachment-container');");
- }
- } catch(error) {result.error=String(error);}
- finally {win.document.getElementById('home').click();}
- await IOUtils.writeJSON(`${root}/logs/send-sms.json`,result);
- }
- const notificationMarker = `${root}/logs/test-notifications`;
- if (await IOUtils.exists(notificationMarker)) {
- await IOUtils.remove(notificationMarker);
- const result = {bootId:report.bootId,passed:false,silent:true};
- let id;
- try {
- await evaluate('system', `ScreenManager.turnScreenOn(true);await VulpesCompat.call('apps.launch',{manifestURL:'http://sms.localhost:8765/manifest.webapp'});return true;`);
- await wait('sms', 'return typeof Notification.get === "function";');
- await evaluate('system', 'if(!Service.query("locked"))await Service.request("lock");return true;');
- result.lockState=await evaluate('system','return {service:Service.query("locked"),direct:lockScreen.locked,preview:NotificationScreen.lockscreenPreview};');
- await wait('system', 'return Service.query("locked") && NotificationScreen.lockscreenPreview;');
- id = await evaluate('sms', `return await new Promise((resolve,reject)=>{
- const n=new Notification('Vulpes — test local',{body:'Notification de qualification',tag:'vulpes-qualification',silent:true});
- n.onshow=()=>resolve(n.id);n.onerror=()=>reject(n.error);
- });`);
- const selector = `[data-notification-id="${id}"]`;
- result.tray = await wait('system', `return !!NotificationScreen.container.querySelector(${JSON.stringify(selector)});`);
- result.lockscreen = await wait('system', `return !!NotificationScreen.getLockScreenContainer()?.querySelector(${JSON.stringify(selector)});`);
- await evaluate('sms', `const rows=await Notification.get({tag:'vulpes-qualification'});rows.forEach(n=>n.close());return true;`);
- result.removed = await wait('system', `return !NotificationScreen.container.querySelector(${JSON.stringify(selector)}) && !NotificationScreen.getLockScreenContainer()?.querySelector(${JSON.stringify(selector)});`);
- result.passed = result.tray && result.lockscreen && result.removed;
- } catch(error) { result.error=String(error); }
- finally {
- if(id) await evaluate('sms', `for(const n of await Notification.get({tag:'vulpes-qualification'})) n.close();return true;`).catch(()=>{});
- win.document.getElementById('home').click();
- }
- await IOUtils.writeJSON(`${root}/logs/notifications.json`,result);
- }
- }
- await requestedUiChecks();
- let uiCheckRunning=false;
- const uiCheckTimer=setInterval(async()=>{
- if(uiCheckRunning) return;
- uiCheckRunning=true;
- try {
- const reloadMarker=`${root}/logs/test-reload-gaia`;
- if(await IOUtils.exists(reloadMarker)) {
- await IOUtils.remove(reloadMarker);
- win.document.querySelector('browser').reloadWithFlags(Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE);
- await wait('system', "return document.body?.getAttribute('ready-state')==='fullyLoaded';");
- await sleep(1500);
- }
- await requestedUiChecks();
- } catch(error) {dump('TUNDRA_UI_CHECK_ERROR '+error+'\n');}
- finally {uiCheckRunning=false;}
- },3000);
- win.addEventListener('unload',()=>clearInterval(uiCheckTimer),{once:true});
report.layout=await evaluate('homescreen',"return {width:innerWidth,height:innerHeight,dpr:devicePixelRatio,rootFont:getComputedStyle(document.documentElement).fontSize,bodyFont:getComputedStyle(document.body).fontFamily,zoom:visualViewport.scale,rootWidth:document.documentElement.scrollWidth};");
report.textFonts = await evaluate('homescreen', `
await document.fonts.ready;
diff --git a/tundra/droidian/session.py b/tundra/droidian/session.py
index 438651d3..19408f45 100644
--- a/tundra/droidian/session.py
+++ b/tundra/droidian/session.py
@@ -132,7 +132,6 @@ Services.obs.addObserver({observe(win) {
profile.mkdir(parents=True, exist_ok=True)
prefs = {'browser.shell.checkDefaultBrowser':False,
'font.default.x-western':'sans-serif',
- 'widget.wayland.vsync.enabled':True, 'layout.frame_rate':-1,
'dom.w3c_touch_events.legacy_apis.enabled':True,
'font.name.sans-serif.x-western':'Fira Sans',
'browser.dom.window.dump.enabled':True,
diff --git a/tundra/manifests/android-hal.json b/tundra/manifests/android-hal.json
deleted file mode 100644
index d7cb499b..00000000
--- a/tundra/manifests/android-hal.json
+++ /dev/null
@@ -1,70 +0,0 @@
-{
- "schema": 1,
- "device": "sargo",
- "role": "Android HAL userspace reference; vendor/firmware remain device-supplied",
- "image": {
- "sha256": "132d0e18c6a6a111293447e940267f35aa2b14fa00a872ef160b20dc2014d886",
- "bytes": 281219072
- },
- "installedPackage": {
- "name": "android-system-google-sargo",
- "version": "9.0.0+r1+ubports20230620+git20230322023537.774b882.bookworm.production",
- "source": "android-system-google-sargo-bin"
- },
- "packaging": {
- "repository": "https://github.com/droidian-devices/android-system-google-sargo-bin",
- "commit": "774b88272f151a0d58d5cea493de10274168cf5e",
- "downloadPinned": false,
- "downloadBehavior": "The packaging script selects the latest UBports CI artifact at build time."
- },
- "candidateOrigin": {
- "url": "https://ci.ubports.com/job/UBportsCommunityPortsJenkinsCI/job/ubports%252Fporting%252Fcommunity-ports%252Fjenkins-ci%252Fsargo/job/main/359/",
- "inferredFrom": [
- "ro.build.version.incremental=359",
- "ro.build.date.utc=1687228550",
- "package ubports20230620"
- ],
- "artifactChecksumMatchVerified": false,
- "artifactsCurrentlyListed": 0,
- "recipes": [
- {
- "url": "https://gitlab.com/ubports/porting/community-ports/jenkins-ci/halium-build-tools.git",
- "commit": "681a03bfc1f8b243be1a12624380fdedcbf7f1a9",
- "fetched": true
- },
- {
- "url": "https://gitlab.com/ubports/porting/community-ports/jenkins-ci/sargo.git",
- "commit": "14e070112338860d14ace77320424939bb5f519b",
- "fetched": true
- }
- ],
- "sourceManifestArtifacts": [
- {
- "artifact": "used-repos.txt",
- "url": "https://ci.ubports.com/job/UBportsCommunityPortsJenkinsCI/job/ubports%252Fporting%252Fcommunity-ports%252Fjenkins-ci%252Fsargo/job/main/359/artifact/used-repos.txt",
- "error": "HTTP Error 404: Not Found"
- },
- {
- "artifact": "last-used-repos.txt",
- "url": "https://ci.ubports.com/job/UBportsCommunityPortsJenkinsCI/job/ubports%252Fporting%252Fcommunity-ports%252Fjenkins-ci%252Fsargo/job/main/359/artifact/last-used-repos.txt",
- "error": "HTTP Error 404: Not Found"
- }
- ],
- "artifactRetentionCount": 30,
- "retentionRecipeSha256": "8715a60456a0706e79d9a06436df5b608aee95e03d65fa4fd5b8c1d0f05a751e"
- },
- "embeddedNotices": {
- "path": "/system/etc/NOTICE.xml.gz",
- "sha256": "95bfb3fb3cd81bcd0847990df121ec3aeedd4065177aabc22ac98e8a13dd8dee",
- "fileNameTags": 482,
- "licenseTextTags": 70,
- "strictXmlParsingPassed": false,
- "originalPreserved": true
- },
- "sourceReviewComplete": false,
- "remaining": [
- "Recover or reconstruct the resolved Android source manifest and exact external dependencies",
- "Establish a verifiable correspondence between the archived build and this binary image",
- "Complete component-level redistribution review; the packaging license alone is insufficient"
- ]
-}
diff --git a/tundra/manifests/sargo-hardware.json b/tundra/manifests/sargo-hardware.json
deleted file mode 100644
index 3e8748bd..00000000
--- a/tundra/manifests/sargo-hardware.json
+++ /dev/null
@@ -1,22 +0,0 @@
-{
- "schema": 1,
- "device": "sargo",
- "scope": "Pinned hardware image inputs for local standalone builds; redistribution not reviewed",
- "images": {
- "vendor.img": {
- "source": "vendor_a.img",
- "sha256": "d584429873c88db741b81144b55977bbd8126b3e877445901bee6a0bda7fe4d0",
- "bytes": 805306368
- },
- "modem.img": {
- "source": "modem_a.img",
- "sha256": "fd0624b37ac1d80c064a9763c7e2d2d6f94c3fd5e4617965b735ad411a473d0a",
- "bytes": 115343360
- }
- },
- "dtbo": {
- "sha256": "0e43a57d6423e8d24cd944fcad0939ed81160e847a6d4203e2f7c16a86d69c03",
- "bytes": 8417280
- },
- "vendorFingerprint": "google/sargo/sargo:9/PD2A.190115.029/5320088:user/release-keys"
-}
diff --git a/tundra/mms-codec/COPYING b/tundra/mms-codec/COPYING
deleted file mode 100644
index f288702d..00000000
--- a/tundra/mms-codec/COPYING
+++ /dev/null
@@ -1,674 +0,0 @@
- GNU GENERAL PUBLIC LICENSE
- Version 3, 29 June 2007
-
- Copyright (C) 2007 Free Software Foundation, Inc.
- Everyone is permitted to copy and distribute verbatim copies
- of this license document, but changing it is not allowed.
-
- Preamble
-
- The GNU General Public License is a free, copyleft license for
-software and other kinds of works.
-
- The licenses for most software and other practical works are designed
-to take away your freedom to share and change the works. By contrast,
-the GNU General Public License is intended to guarantee your freedom to
-share and change all versions of a program--to make sure it remains free
-software for all its users. We, the Free Software Foundation, use the
-GNU General Public License for most of our software; it applies also to
-any other work released this way by its authors. You can apply it to
-your programs, too.
-
- When we speak of free software, we are referring to freedom, not
-price. Our General Public Licenses are designed to make sure that you
-have the freedom to distribute copies of free software (and charge for
-them if you wish), that you receive source code or can get it if you
-want it, that you can change the software or use pieces of it in new
-free programs, and that you know you can do these things.
-
- To protect your rights, we need to prevent others from denying you
-these rights or asking you to surrender the rights. Therefore, you have
-certain responsibilities if you distribute copies of the software, or if
-you modify it: responsibilities to respect the freedom of others.
-
- For example, if you distribute copies of such a program, whether
-gratis or for a fee, you must pass on to the recipients the same
-freedoms that you received. You must make sure that they, too, receive
-or can get the source code. And you must show them these terms so they
-know their rights.
-
- Developers that use the GNU GPL protect your rights with two steps:
-(1) assert copyright on the software, and (2) offer you this License
-giving you legal permission to copy, distribute and/or modify it.
-
- For the developers' and authors' protection, the GPL clearly explains
-that there is no warranty for this free software. For both users' and
-authors' sake, the GPL requires that modified versions be marked as
-changed, so that their problems will not be attributed erroneously to
-authors of previous versions.
-
- Some devices are designed to deny users access to install or run
-modified versions of the software inside them, although the manufacturer
-can do so. This is fundamentally incompatible with the aim of
-protecting users' freedom to change the software. The systematic
-pattern of such abuse occurs in the area of products for individuals to
-use, which is precisely where it is most unacceptable. Therefore, we
-have designed this version of the GPL to prohibit the practice for those
-products. If such problems arise substantially in other domains, we
-stand ready to extend this provision to those domains in future versions
-of the GPL, as needed to protect the freedom of users.
-
- Finally, every program is threatened constantly by software patents.
-States should not allow patents to restrict development and use of
-software on general-purpose computers, but in those that do, we wish to
-avoid the special danger that patents applied to a free program could
-make it effectively proprietary. To prevent this, the GPL assures that
-patents cannot be used to render the program non-free.
-
- The precise terms and conditions for copying, distribution and
-modification follow.
-
- TERMS AND CONDITIONS
-
- 0. Definitions.
-
- "This License" refers to version 3 of the GNU General Public License.
-
- "Copyright" also means copyright-like laws that apply to other kinds of
-works, such as semiconductor masks.
-
- "The Program" refers to any copyrightable work licensed under this
-License. Each licensee is addressed as "you". "Licensees" and
-"recipients" may be individuals or organizations.
-
- To "modify" a work means to copy from or adapt all or part of the work
-in a fashion requiring copyright permission, other than the making of an
-exact copy. The resulting work is called a "modified version" of the
-earlier work or a work "based on" the earlier work.
-
- A "covered work" means either the unmodified Program or a work based
-on the Program.
-
- To "propagate" a work means to do anything with it that, without
-permission, would make you directly or secondarily liable for
-infringement under applicable copyright law, except executing it on a
-computer or modifying a private copy. Propagation includes copying,
-distribution (with or without modification), making available to the
-public, and in some countries other activities as well.
-
- To "convey" a work means any kind of propagation that enables other
-parties to make or receive copies. Mere interaction with a user through
-a computer network, with no transfer of a copy, is not conveying.
-
- An interactive user interface displays "Appropriate Legal Notices"
-to the extent that it includes a convenient and prominently visible
-feature that (1) displays an appropriate copyright notice, and (2)
-tells the user that there is no warranty for the work (except to the
-extent that warranties are provided), that licensees may convey the
-work under this License, and how to view a copy of this License. If
-the interface presents a list of user commands or options, such as a
-menu, a prominent item in the list meets this criterion.
-
- 1. Source Code.
-
- The "source code" for a work means the preferred form of the work
-for making modifications to it. "Object code" means any non-source
-form of a work.
-
- A "Standard Interface" means an interface that either is an official
-standard defined by a recognized standards body, or, in the case of
-interfaces specified for a particular programming language, one that
-is widely used among developers working in that language.
-
- The "System Libraries" of an executable work include anything, other
-than the work as a whole, that (a) is included in the normal form of
-packaging a Major Component, but which is not part of that Major
-Component, and (b) serves only to enable use of the work with that
-Major Component, or to implement a Standard Interface for which an
-implementation is available to the public in source code form. A
-"Major Component", in this context, means a major essential component
-(kernel, window system, and so on) of the specific operating system
-(if any) on which the executable work runs, or a compiler used to
-produce the work, or an object code interpreter used to run it.
-
- The "Corresponding Source" for a work in object code form means all
-the source code needed to generate, install, and (for an executable
-work) run the object code and to modify the work, including scripts to
-control those activities. However, it does not include the work's
-System Libraries, or general-purpose tools or generally available free
-programs which are used unmodified in performing those activities but
-which are not part of the work. For example, Corresponding Source
-includes interface definition files associated with source files for
-the work, and the source code for shared libraries and dynamically
-linked subprograms that the work is specifically designed to require,
-such as by intimate data communication or control flow between those
-subprograms and other parts of the work.
-
- The Corresponding Source need not include anything that users
-can regenerate automatically from other parts of the Corresponding
-Source.
-
- The Corresponding Source for a work in source code form is that
-same work.
-
- 2. Basic Permissions.
-
- All rights granted under this License are granted for the term of
-copyright on the Program, and are irrevocable provided the stated
-conditions are met. This License explicitly affirms your unlimited
-permission to run the unmodified Program. The output from running a
-covered work is covered by this License only if the output, given its
-content, constitutes a covered work. This License acknowledges your
-rights of fair use or other equivalent, as provided by copyright law.
-
- You may make, run and propagate covered works that you do not
-convey, without conditions so long as your license otherwise remains
-in force. You may convey covered works to others for the sole purpose
-of having them make modifications exclusively for you, or provide you
-with facilities for running those works, provided that you comply with
-the terms of this License in conveying all material for which you do
-not control copyright. Those thus making or running the covered works
-for you must do so exclusively on your behalf, under your direction
-and control, on terms that prohibit them from making any copies of
-your copyrighted material outside their relationship with you.
-
- Conveying under any other circumstances is permitted solely under
-the conditions stated below. Sublicensing is not allowed; section 10
-makes it unnecessary.
-
- 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
-
- No covered work shall be deemed part of an effective technological
-measure under any applicable law fulfilling obligations under article
-11 of the WIPO copyright treaty adopted on 20 December 1996, or
-similar laws prohibiting or restricting circumvention of such
-measures.
-
- When you convey a covered work, you waive any legal power to forbid
-circumvention of technological measures to the extent such circumvention
-is effected by exercising rights under this License with respect to
-the covered work, and you disclaim any intention to limit operation or
-modification of the work as a means of enforcing, against the work's
-users, your or third parties' legal rights to forbid circumvention of
-technological measures.
-
- 4. Conveying Verbatim Copies.
-
- You may convey verbatim copies of the Program's source code as you
-receive it, in any medium, provided that you conspicuously and
-appropriately publish on each copy an appropriate copyright notice;
-keep intact all notices stating that this License and any
-non-permissive terms added in accord with section 7 apply to the code;
-keep intact all notices of the absence of any warranty; and give all
-recipients a copy of this License along with the Program.
-
- You may charge any price or no price for each copy that you convey,
-and you may offer support or warranty protection for a fee.
-
- 5. Conveying Modified Source Versions.
-
- You may convey a work based on the Program, or the modifications to
-produce it from the Program, in the form of source code under the
-terms of section 4, provided that you also meet all of these conditions:
-
- a) The work must carry prominent notices stating that you modified
- it, and giving a relevant date.
-
- b) The work must carry prominent notices stating that it is
- released under this License and any conditions added under section
- 7. This requirement modifies the requirement in section 4 to
- "keep intact all notices".
-
- c) You must license the entire work, as a whole, under this
- License to anyone who comes into possession of a copy. This
- License will therefore apply, along with any applicable section 7
- additional terms, to the whole of the work, and all its parts,
- regardless of how they are packaged. This License gives no
- permission to license the work in any other way, but it does not
- invalidate such permission if you have separately received it.
-
- d) If the work has interactive user interfaces, each must display
- Appropriate Legal Notices; however, if the Program has interactive
- interfaces that do not display Appropriate Legal Notices, your
- work need not make them do so.
-
- A compilation of a covered work with other separate and independent
-works, which are not by their nature extensions of the covered work,
-and which are not combined with it such as to form a larger program,
-in or on a volume of a storage or distribution medium, is called an
-"aggregate" if the compilation and its resulting copyright are not
-used to limit the access or legal rights of the compilation's users
-beyond what the individual works permit. Inclusion of a covered work
-in an aggregate does not cause this License to apply to the other
-parts of the aggregate.
-
- 6. Conveying Non-Source Forms.
-
- You may convey a covered work in object code form under the terms
-of sections 4 and 5, provided that you also convey the
-machine-readable Corresponding Source under the terms of this License,
-in one of these ways:
-
- a) Convey the object code in, or embodied in, a physical product
- (including a physical distribution medium), accompanied by the
- Corresponding Source fixed on a durable physical medium
- customarily used for software interchange.
-
- b) Convey the object code in, or embodied in, a physical product
- (including a physical distribution medium), accompanied by a
- written offer, valid for at least three years and valid for as
- long as you offer spare parts or customer support for that product
- model, to give anyone who possesses the object code either (1) a
- copy of the Corresponding Source for all the software in the
- product that is covered by this License, on a durable physical
- medium customarily used for software interchange, for a price no
- more than your reasonable cost of physically performing this
- conveying of source, or (2) access to copy the
- Corresponding Source from a network server at no charge.
-
- c) Convey individual copies of the object code with a copy of the
- written offer to provide the Corresponding Source. This
- alternative is allowed only occasionally and noncommercially, and
- only if you received the object code with such an offer, in accord
- with subsection 6b.
-
- d) Convey the object code by offering access from a designated
- place (gratis or for a charge), and offer equivalent access to the
- Corresponding Source in the same way through the same place at no
- further charge. You need not require recipients to copy the
- Corresponding Source along with the object code. If the place to
- copy the object code is a network server, the Corresponding Source
- may be on a different server (operated by you or a third party)
- that supports equivalent copying facilities, provided you maintain
- clear directions next to the object code saying where to find the
- Corresponding Source. Regardless of what server hosts the
- Corresponding Source, you remain obligated to ensure that it is
- available for as long as needed to satisfy these requirements.
-
- e) Convey the object code using peer-to-peer transmission, provided
- you inform other peers where the object code and Corresponding
- Source of the work are being offered to the general public at no
- charge under subsection 6d.
-
- A separable portion of the object code, whose source code is excluded
-from the Corresponding Source as a System Library, need not be
-included in conveying the object code work.
-
- A "User Product" is either (1) a "consumer product", which means any
-tangible personal property which is normally used for personal, family,
-or household purposes, or (2) anything designed or sold for incorporation
-into a dwelling. In determining whether a product is a consumer product,
-doubtful cases shall be resolved in favor of coverage. For a particular
-product received by a particular user, "normally used" refers to a
-typical or common use of that class of product, regardless of the status
-of the particular user or of the way in which the particular user
-actually uses, or expects or is expected to use, the product. A product
-is a consumer product regardless of whether the product has substantial
-commercial, industrial or non-consumer uses, unless such uses represent
-the only significant mode of use of the product.
-
- "Installation Information" for a User Product means any methods,
-procedures, authorization keys, or other information required to install
-and execute modified versions of a covered work in that User Product from
-a modified version of its Corresponding Source. The information must
-suffice to ensure that the continued functioning of the modified object
-code is in no case prevented or interfered with solely because
-modification has been made.
-
- If you convey an object code work under this section in, or with, or
-specifically for use in, a User Product, and the conveying occurs as
-part of a transaction in which the right of possession and use of the
-User Product is transferred to the recipient in perpetuity or for a
-fixed term (regardless of how the transaction is characterized), the
-Corresponding Source conveyed under this section must be accompanied
-by the Installation Information. But this requirement does not apply
-if neither you nor any third party retains the ability to install
-modified object code on the User Product (for example, the work has
-been installed in ROM).
-
- The requirement to provide Installation Information does not include a
-requirement to continue to provide support service, warranty, or updates
-for a work that has been modified or installed by the recipient, or for
-the User Product in which it has been modified or installed. Access to a
-network may be denied when the modification itself materially and
-adversely affects the operation of the network or violates the rules and
-protocols for communication across the network.
-
- Corresponding Source conveyed, and Installation Information provided,
-in accord with this section must be in a format that is publicly
-documented (and with an implementation available to the public in
-source code form), and must require no special password or key for
-unpacking, reading or copying.
-
- 7. Additional Terms.
-
- "Additional permissions" are terms that supplement the terms of this
-License by making exceptions from one or more of its conditions.
-Additional permissions that are applicable to the entire Program shall
-be treated as though they were included in this License, to the extent
-that they are valid under applicable law. If additional permissions
-apply only to part of the Program, that part may be used separately
-under those permissions, but the entire Program remains governed by
-this License without regard to the additional permissions.
-
- When you convey a copy of a covered work, you may at your option
-remove any additional permissions from that copy, or from any part of
-it. (Additional permissions may be written to require their own
-removal in certain cases when you modify the work.) You may place
-additional permissions on material, added by you to a covered work,
-for which you have or can give appropriate copyright permission.
-
- Notwithstanding any other provision of this License, for material you
-add to a covered work, you may (if authorized by the copyright holders of
-that material) supplement the terms of this License with terms:
-
- a) Disclaiming warranty or limiting liability differently from the
- terms of sections 15 and 16 of this License; or
-
- b) Requiring preservation of specified reasonable legal notices or
- author attributions in that material or in the Appropriate Legal
- Notices displayed by works containing it; or
-
- c) Prohibiting misrepresentation of the origin of that material, or
- requiring that modified versions of such material be marked in
- reasonable ways as different from the original version; or
-
- d) Limiting the use for publicity purposes of names of licensors or
- authors of the material; or
-
- e) Declining to grant rights under trademark law for use of some
- trade names, trademarks, or service marks; or
-
- f) Requiring indemnification of licensors and authors of that
- material by anyone who conveys the material (or modified versions of
- it) with contractual assumptions of liability to the recipient, for
- any liability that these contractual assumptions directly impose on
- those licensors and authors.
-
- All other non-permissive additional terms are considered "further
-restrictions" within the meaning of section 10. If the Program as you
-received it, or any part of it, contains a notice stating that it is
-governed by this License along with a term that is a further
-restriction, you may remove that term. If a license document contains
-a further restriction but permits relicensing or conveying under this
-License, you may add to a covered work material governed by the terms
-of that license document, provided that the further restriction does
-not survive such relicensing or conveying.
-
- If you add terms to a covered work in accord with this section, you
-must place, in the relevant source files, a statement of the
-additional terms that apply to those files, or a notice indicating
-where to find the applicable terms.
-
- Additional terms, permissive or non-permissive, may be stated in the
-form of a separately written license, or stated as exceptions;
-the above requirements apply either way.
-
- 8. Termination.
-
- You may not propagate or modify a covered work except as expressly
-provided under this License. Any attempt otherwise to propagate or
-modify it is void, and will automatically terminate your rights under
-this License (including any patent licenses granted under the third
-paragraph of section 11).
-
- However, if you cease all violation of this License, then your
-license from a particular copyright holder is reinstated (a)
-provisionally, unless and until the copyright holder explicitly and
-finally terminates your license, and (b) permanently, if the copyright
-holder fails to notify you of the violation by some reasonable means
-prior to 60 days after the cessation.
-
- Moreover, your license from a particular copyright holder is
-reinstated permanently if the copyright holder notifies you of the
-violation by some reasonable means, this is the first time you have
-received notice of violation of this License (for any work) from that
-copyright holder, and you cure the violation prior to 30 days after
-your receipt of the notice.
-
- Termination of your rights under this section does not terminate the
-licenses of parties who have received copies or rights from you under
-this License. If your rights have been terminated and not permanently
-reinstated, you do not qualify to receive new licenses for the same
-material under section 10.
-
- 9. Acceptance Not Required for Having Copies.
-
- You are not required to accept this License in order to receive or
-run a copy of the Program. Ancillary propagation of a covered work
-occurring solely as a consequence of using peer-to-peer transmission
-to receive a copy likewise does not require acceptance. However,
-nothing other than this License grants you permission to propagate or
-modify any covered work. These actions infringe copyright if you do
-not accept this License. Therefore, by modifying or propagating a
-covered work, you indicate your acceptance of this License to do so.
-
- 10. Automatic Licensing of Downstream Recipients.
-
- Each time you convey a covered work, the recipient automatically
-receives a license from the original licensors, to run, modify and
-propagate that work, subject to this License. You are not responsible
-for enforcing compliance by third parties with this License.
-
- An "entity transaction" is a transaction transferring control of an
-organization, or substantially all assets of one, or subdividing an
-organization, or merging organizations. If propagation of a covered
-work results from an entity transaction, each party to that
-transaction who receives a copy of the work also receives whatever
-licenses to the work the party's predecessor in interest had or could
-give under the previous paragraph, plus a right to possession of the
-Corresponding Source of the work from the predecessor in interest, if
-the predecessor has it or can get it with reasonable efforts.
-
- You may not impose any further restrictions on the exercise of the
-rights granted or affirmed under this License. For example, you may
-not impose a license fee, royalty, or other charge for exercise of
-rights granted under this License, and you may not initiate litigation
-(including a cross-claim or counterclaim in a lawsuit) alleging that
-any patent claim is infringed by making, using, selling, offering for
-sale, or importing the Program or any portion of it.
-
- 11. Patents.
-
- A "contributor" is a copyright holder who authorizes use under this
-License of the Program or a work on which the Program is based. The
-work thus licensed is called the contributor's "contributor version".
-
- A contributor's "essential patent claims" are all patent claims
-owned or controlled by the contributor, whether already acquired or
-hereafter acquired, that would be infringed by some manner, permitted
-by this License, of making, using, or selling its contributor version,
-but do not include claims that would be infringed only as a
-consequence of further modification of the contributor version. For
-purposes of this definition, "control" includes the right to grant
-patent sublicenses in a manner consistent with the requirements of
-this License.
-
- Each contributor grants you a non-exclusive, worldwide, royalty-free
-patent license under the contributor's essential patent claims, to
-make, use, sell, offer for sale, import and otherwise run, modify and
-propagate the contents of its contributor version.
-
- In the following three paragraphs, a "patent license" is any express
-agreement or commitment, however denominated, not to enforce a patent
-(such as an express permission to practice a patent or covenant not to
-sue for patent infringement). To "grant" such a patent license to a
-party means to make such an agreement or commitment not to enforce a
-patent against the party.
-
- If you convey a covered work, knowingly relying on a patent license,
-and the Corresponding Source of the work is not available for anyone
-to copy, free of charge and under the terms of this License, through a
-publicly available network server or other readily accessible means,
-then you must either (1) cause the Corresponding Source to be so
-available, or (2) arrange to deprive yourself of the benefit of the
-patent license for this particular work, or (3) arrange, in a manner
-consistent with the requirements of this License, to extend the patent
-license to downstream recipients. "Knowingly relying" means you have
-actual knowledge that, but for the patent license, your conveying the
-covered work in a country, or your recipient's use of the covered work
-in a country, would infringe one or more identifiable patents in that
-country that you have reason to believe are valid.
-
- If, pursuant to or in connection with a single transaction or
-arrangement, you convey, or propagate by procuring conveyance of, a
-covered work, and grant a patent license to some of the parties
-receiving the covered work authorizing them to use, propagate, modify
-or convey a specific copy of the covered work, then the patent license
-you grant is automatically extended to all recipients of the covered
-work and works based on it.
-
- A patent license is "discriminatory" if it does not include within
-the scope of its coverage, prohibits the exercise of, or is
-conditioned on the non-exercise of one or more of the rights that are
-specifically granted under this License. You may not convey a covered
-work if you are a party to an arrangement with a third party that is
-in the business of distributing software, under which you make payment
-to the third party based on the extent of your activity of conveying
-the work, and under which the third party grants, to any of the
-parties who would receive the covered work from you, a discriminatory
-patent license (a) in connection with copies of the covered work
-conveyed by you (or copies made from those copies), or (b) primarily
-for and in connection with specific products or compilations that
-contain the covered work, unless you entered into that arrangement,
-or that patent license was granted, prior to 28 March 2007.
-
- Nothing in this License shall be construed as excluding or limiting
-any implied license or other defenses to infringement that may
-otherwise be available to you under applicable patent law.
-
- 12. No Surrender of Others' Freedom.
-
- If conditions are imposed on you (whether by court order, agreement or
-otherwise) that contradict the conditions of this License, they do not
-excuse you from the conditions of this License. If you cannot convey a
-covered work so as to satisfy simultaneously your obligations under this
-License and any other pertinent obligations, then as a consequence you may
-not convey it at all. For example, if you agree to terms that obligate you
-to collect a royalty for further conveying from those to whom you convey
-the Program, the only way you could satisfy both those terms and this
-License would be to refrain entirely from conveying the Program.
-
- 13. Use with the GNU Affero General Public License.
-
- Notwithstanding any other provision of this License, you have
-permission to link or combine any covered work with a work licensed
-under version 3 of the GNU Affero General Public License into a single
-combined work, and to convey the resulting work. The terms of this
-License will continue to apply to the part which is the covered work,
-but the special requirements of the GNU Affero General Public License,
-section 13, concerning interaction through a network will apply to the
-combination as such.
-
- 14. Revised Versions of this License.
-
- The Free Software Foundation may publish revised and/or new versions of
-the GNU General Public License from time to time. Such new versions will
-be similar in spirit to the present version, but may differ in detail to
-address new problems or concerns.
-
- Each version is given a distinguishing version number. If the
-Program specifies that a certain numbered version of the GNU General
-Public License "or any later version" applies to it, you have the
-option of following the terms and conditions either of that numbered
-version or of any later version published by the Free Software
-Foundation. If the Program does not specify a version number of the
-GNU General Public License, you may choose any version ever published
-by the Free Software Foundation.
-
- If the Program specifies that a proxy can decide which future
-versions of the GNU General Public License can be used, that proxy's
-public statement of acceptance of a version permanently authorizes you
-to choose that version for the Program.
-
- Later license versions may give you additional or different
-permissions. However, no additional obligations are imposed on any
-author or copyright holder as a result of your choosing to follow a
-later version.
-
- 15. Disclaimer of Warranty.
-
- THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
-APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
-HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
-OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
-THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
-PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
-IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
-ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
-
- 16. Limitation of Liability.
-
- IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
-WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
-THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
-GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
-USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
-DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
-PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
-EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
-SUCH DAMAGES.
-
- 17. Interpretation of Sections 15 and 16.
-
- If the disclaimer of warranty and limitation of liability provided
-above cannot be given local legal effect according to their terms,
-reviewing courts shall apply local law that most closely approximates
-an absolute waiver of all civil liability in connection with the
-Program, unless a warranty or assumption of liability accompanies a
-copy of the Program in return for a fee.
-
- END OF TERMS AND CONDITIONS
-
- How to Apply These Terms to Your New Programs
-
- If you develop a new program, and you want it to be of the greatest
-possible use to the public, the best way to achieve this is to make it
-free software which everyone can redistribute and change under these terms.
-
- To do so, attach the following notices to the program. It is safest
-to attach them to the start of each source file to most effectively
-state the exclusion of warranty; and each file should have at least
-the "copyright" line and a pointer to where the full notice is found.
-
-
- Copyright (C)
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License
- along with this program. If not, see .
-
-Also add information on how to contact you by electronic and paper mail.
-
- If the program does terminal interaction, make it output a short
-notice like this when it starts in an interactive mode:
-
- Copyright (C)
- This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
- This is free software, and you are welcome to redistribute it
- under certain conditions; type `show c' for details.
-
-The hypothetical commands `show w' and `show c' should show the appropriate
-parts of the General Public License. Of course, your program's commands
-might be different; for a GUI interface, you would use an "about box".
-
- You should also get your employer (if you work as a programmer) or school,
-if any, to sign a "copyright disclaimer" for the program, if necessary.
-For more information on this, and how to apply and follow the GNU GPL, see
- .
-
- The GNU General Public License does not permit incorporating your program
-into proprietary programs. If your program is a subroutine library, you
-may consider it more useful to permit linking proprietary applications with
-the library. If this is what you want to do, use the GNU Lesser General
-Public License instead of this License. But first, please read
-.
diff --git a/tundra/mms-codec/go.mod b/tundra/mms-codec/go.mod
deleted file mode 100644
index c608575e..00000000
--- a/tundra/mms-codec/go.mod
+++ /dev/null
@@ -1,10 +0,0 @@
-module vulpes.local/mms-codec
-
-go 1.22
-
-require gitlab.com/ubports/development/core/nuntium v0.0.0-20260827194050-d051757417a4
-
-require (
- github.com/godbus/dbus/v5 v5.1.0 // indirect
- gitlab.com/ubports/development/core/go-ldm v0.0.0-20240829085643-4b8905597dac // indirect
-)
diff --git a/tundra/mms-codec/go.sum b/tundra/mms-codec/go.sum
deleted file mode 100644
index 538295c0..00000000
--- a/tundra/mms-codec/go.sum
+++ /dev/null
@@ -1,12 +0,0 @@
-github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
-github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
-github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
-github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
-github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
-github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
-gitlab.com/ubports/development/core/go-ldm v0.0.0-20240829085643-4b8905597dac h1:qwJa6TLO/c3SnjyXyxVRMEpHY2AKAcoSOgzuHmXp3y0=
-gitlab.com/ubports/development/core/go-ldm v0.0.0-20240829085643-4b8905597dac/go.mod h1:pucSy3AIvCddSTBSWMi0ksVk1f14RLUoTTo/6ir9WEg=
-gitlab.com/ubports/development/core/nuntium v0.0.0-20260827194050-d051757417a4 h1:pObcbcl2WrOp8Mdw1/vs72xFZX/WAmHYk2GIToeLbBo=
-gitlab.com/ubports/development/core/nuntium v0.0.0-20260827194050-d051757417a4/go.mod h1:FccedFuGf6fPathxJjT5POSHItV2A2imvERNIdoBGdA=
-gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
-gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
diff --git a/tundra/mms-codec/main.go b/tundra/mms-codec/main.go
deleted file mode 100644
index eaf8dfd9..00000000
--- a/tundra/mms-codec/main.go
+++ /dev/null
@@ -1,145 +0,0 @@
-// SPDX-License-Identifier: GPL-3.0-only
-// This separate helper uses UBports Nuntium's MMS/WAP codecs over JSON stdin.
-package main
-
-import (
- "bytes"
- "encoding/json"
- "errors"
- "io"
- "log"
- "os"
- "strings"
- "time"
-
- "gitlab.com/ubports/development/core/nuntium/mms"
- "gitlab.com/ubports/development/core/nuntium/ofono"
-)
-
-const limit = 600 * 1024
-
-type Part struct {
- ID string `json:"id"`
- Type string `json:"type"`
- Data []byte `json:"data"`
-}
-type Request struct {
- Operation string `json:"operation"`
- Data []byte `json:"data"`
- Recipients []string `json:"recipients"`
- Subject string `json:"subject"`
- Transaction string `json:"transaction"`
- Parts []Part `json:"parts"`
-}
-
-func run(r Request) (any, error) {
- if len(r.Data) > limit {
- return nil, errors.New("MMS_TOO_LARGE")
- }
- switch r.Operation {
- case "encode":
- if len(r.Recipients) < 1 || len(r.Recipients) > 10 || len(r.Parts) > 20 {
- return nil, errors.New("INVALID_MMS")
- }
- parts := []*mms.Attachment{}
- total := 0
- for _, p := range r.Parts {
- total += len(p.Data)
- if total > limit || len(p.ID) > 100 || len(p.Type) > 100 {
- return nil, errors.New("MMS_TOO_LARGE")
- }
- parts = append(parts, &mms.Attachment{ContentId: p.ID, ContentLocation: p.ID, Name: p.ID, MediaType: p.Type, Data: p.Data})
- }
- message := mms.NewMSendReq(r.Recipients, parts, false)
- message.Subject = r.Subject
- var b bytes.Buffer
- if err := mms.NewEncoder(&b).Encode(message); err != nil {
- return nil, err
- }
- if b.Len() > limit {
- return nil, errors.New("MMS_TOO_LARGE")
- }
- return map[string]any{"data": b.Bytes(), "transaction": message.TransactionId}, nil
- case "response":
- message := mms.NewMSendConf()
- if err := mms.NewDecoder(r.Data).Decode(message); err != nil {
- return nil, err
- }
- if message.Type != mms.TYPE_SEND_CONF || message.TransactionId != r.Transaction {
- return nil, errors.New("MMS_INVALID_RESPONSE")
- }
- return map[string]any{"accepted": message.ResponseStatus == mms.ResponseStatusOk, "status": message.ResponseStatus, "messageId": message.MessageId}, nil
- case "notification":
- if len(r.Data) < 4 {
- return nil, errors.New("MMS_INVALID_PUSH")
- }
- push := &ofono.PushPDU{}
- if err := ofono.NewDecoder(r.Data).Decode(push); err != nil {
- return nil, err
- }
- if push.ApplicationId != mms.PUSH_APPLICATION_ID || push.ContentType != mms.VND_WAP_MMS_MESSAGE {
- return nil, errors.New("MMS_INVALID_PUSH")
- }
- message := mms.NewMNotificationInd(time.Now())
- if err := mms.NewDecoder(push.Data).Decode(message); err != nil {
- return nil, err
- }
- if message.Type != mms.TYPE_NOTIFICATION_IND {
- return nil, errors.New("MMS_INVALID_PUSH")
- }
- return map[string]any{"transaction": message.TransactionId, "url": message.ContentLocation, "sender": strings.Split(message.From, "/TYPE=")[0], "subject": message.Subject, "expires": message.Expiry.Unix(), "size": message.Size}, nil
- case "retrieve":
- message := mms.NewMRetrieveConf("")
- if err := mms.NewDecoder(r.Data).Decode(message); err != nil {
- return nil, err
- }
- if message.Type != mms.TYPE_RETRIEVE_CONF || (message.RetrieveStatus != 0 && message.RetrieveStatus != 128) {
- return nil, errors.New("MMS_INVALID_CONTENT")
- }
- parts := []Part{}
- for _, p := range message.Attachments {
- id := p.ContentLocation
- if id == "" {
- id = strings.Trim(p.ContentId, "<>")
- }
- parts = append(parts, Part{id, p.MediaType, p.Data})
- }
- receivers := []string{}
- for _, n := range message.To {
- receivers = append(receivers, strings.Split(n, "/TYPE=")[0])
- }
- return map[string]any{"sender": strings.Split(message.From, "/TYPE=")[0], "receivers": receivers, "subject": message.Subject, "parts": parts, "timestamp": message.Date * 1000}, nil
- case "ack":
- if r.Transaction == "" || len(r.Transaction) > 200 {
- return nil, errors.New("MMS_INVALID_TRANSACTION")
- }
- message := mms.NewMNotifyRespInd()
- message.TransactionId = r.Transaction
- message.Version = mms.MMS_MESSAGE_VERSION_1_1
- message.Status = mms.STATUS_RETRIEVED
- var b bytes.Buffer
- if err := mms.NewEncoder(&b).Encode(message); err != nil {
- return nil, err
- }
- return map[string]any{"data": b.Bytes()}, nil
- }
- return nil, errors.New("MMS_INVALID_OPERATION")
-}
-func main() {
- log.SetOutput(io.Discard)
- defer func() {
- if recover() != nil {
- json.NewEncoder(os.Stdout).Encode(map[string]string{"error": "MMS_INVALID_PDU"})
- }
- }()
- var r Request
- if err := json.NewDecoder(io.LimitReader(os.Stdin, 2*limit)).Decode(&r); err != nil {
- json.NewEncoder(os.Stdout).Encode(map[string]string{"error": "MMS_INVALID_REQUEST"})
- return
- }
- value, err := run(r)
- if err != nil {
- value = map[string]string{"error": "MMS_CODEC_ERROR"}
- }
- json.NewEncoder(os.Stdout).Encode(value)
-}
diff --git a/tundra/mms-codec/main_test.go b/tundra/mms-codec/main_test.go
deleted file mode 100644
index 1ff511b7..00000000
--- a/tundra/mms-codec/main_test.go
+++ /dev/null
@@ -1,40 +0,0 @@
-// SPDX-License-Identifier: GPL-3.0-only
-package main
-
-import (
- "encoding/base64"
- "gitlab.com/ubports/development/core/nuntium/mms"
- "testing"
-)
-
-func TestEncodePhotoAndText(t *testing.T) {
- result, err := run(Request{Operation: "encode", Recipients: []string{"+15550199"}, Parts: []Part{{"text.txt", "text/plain", []byte("MMS test é")}, {"photo.jpg", "image/jpeg", []byte{255, 216, 255, 217}}}})
- if err != nil {
- t.Fatal(err)
- }
- data := result.(map[string]any)["data"].([]byte)
- message := mms.NewMRetrieveConf("")
- message.Type = mms.TYPE_SEND_REQ
- if err = mms.NewDecoder(data).Decode(message); err != nil {
- t.Fatal(err)
- }
- if len(message.Attachments) != 2 || string(message.Attachments[0].Data) != "MMS test é" {
- t.Fatalf("lost parts: %d", len(message.Attachments))
- }
-}
-func TestSendRequiresMatchingConfirmation(t *testing.T) {
- // M-Send.conf: transaction 't', MMS 1.1, status OK.
- data, _ := base64.StdEncoding.DecodeString("jIGYdACNkZKA")
- value, err := run(Request{Operation: "response", Data: data, Transaction: "t"})
- if err != nil || !value.(map[string]any)["accepted"].(bool) {
- t.Fatal(value, err)
- }
- if _, err = run(Request{Operation: "response", Data: data, Transaction: "other"}); err == nil {
- t.Fatal("accepted unrelated response")
- }
-}
-func TestLimits(t *testing.T) {
- if _, err := run(Request{Operation: "retrieve", Data: make([]byte, limit+1)}); err == nil {
- t.Fatal("oversize accepted")
- }
-}
diff --git a/tundra/runtime/audio-devices.py b/tundra/runtime/audio-devices.py
deleted file mode 100644
index 6c55f6a9..00000000
--- a/tundra/runtime/audio-devices.py
+++ /dev/null
@@ -1,29 +0,0 @@
-#!/usr/bin/env python3
-# SPDX-License-Identifier: MPL-2.0
-"""Give the audio group access to Sargo's calibration and amplifier devices."""
-import os
-from pathlib import Path
-import stat
-import time
-
-DEVICES = ('msm_audio_cal', 'msm_rtac', 'msm_cirrus_playback')
-
-
-def prepare(root=Path('/dev')):
- for name in DEVICES:
- path = root / name
- if not stat.S_ISCHR(path.lstat().st_mode):
- raise RuntimeError('Not an audio character device: ' + name)
- os.chown(path, 0, 1005, follow_symlinks=False)
- path.chmod(0o660)
-
-
-if __name__ == '__main__':
- for attempt in range(60):
- try:
- prepare()
- break
- except FileNotFoundError:
- if attempt == 59:
- raise
- time.sleep(1)
diff --git a/tundra/runtime/bluetooth-control.py b/tundra/runtime/bluetooth-control.py
deleted file mode 100644
index 190e071f..00000000
--- a/tundra/runtime/bluetooth-control.py
+++ /dev/null
@@ -1,323 +0,0 @@
-#!/usr/bin/env python3
-# SPDX-License-Identifier: MPL-2.0
-"""BlueZ broker. The persistent bus owns discovery and the pairing agent."""
-import json
-import os
-from pathlib import Path
-import re
-import socket
-import sys
-import threading
-import time
-import uuid
-
-SOCKET = Path('/run/tundra-bluetooth/control.sock')
-LIMIT = 65536
-
-
-def validate(value):
- if not isinstance(value, dict) or value.get('version') != 1:
- raise ValueError('BLUETOOTH_INVALID_REQUEST')
- op = value.get('operation')
- if op not in ('status', 'power', 'discoverable', 'rename', 'scan', 'stopScan',
- 'pair', 'unpair', 'connect', 'disconnect', 'reply', 'cancelPair'):
- raise ValueError('BLUETOOTH_INVALID_REQUEST')
- if op in ('pair', 'unpair', 'connect', 'disconnect', 'cancelPair') and not re.fullmatch(
- r'(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}', str(value.get('address', ''))):
- raise ValueError('BLUETOOTH_INVALID_ADDRESS')
- if op in ('power', 'discoverable') and not isinstance(value.get('enabled'), bool):
- raise ValueError('BLUETOOTH_INVALID_REQUEST')
- if op == 'rename' and (not isinstance(value.get('name'), str) or
- not 1 <= len(value['name'].encode()) <= 248 or
- any(ord(c) < 32 for c in value['name'])):
- raise ValueError('BLUETOOTH_INVALID_NAME')
- if op == 'reply' and (not isinstance(value.get('accept'), bool) or
- not isinstance(value.get('id'), str) or len(value['id']) > 40):
- raise ValueError('BLUETOOTH_INVALID_REQUEST')
- return op
-
-
-def device_record(props):
- cod = int(props.get('Class', 0))
- return dict(address=str(props['Address']), name=str(props.get('Alias', props['Address'])),
- paired=bool(props.get('Paired')), connected=bool(props.get('Connected')),
- uuids=[str(u) for u in props.get('UUIDs', [])],
- cod=dict(majorDeviceClass=(cod >> 8) & 31, minorDeviceClass=(cod >> 2) & 63,
- majorServiceClass=(cod >> 13) & 2047))
-
-
-def serve():
- import dbus
- import dbus.service
- from dbus.mainloop.glib import DBusGMainLoop
- from gi.repository import GLib
- DBusGMainLoop(set_as_default=True)
- bus = dbus.SystemBus(private=True)
- pending = {}
- discovery = {'owned': False, 'expires': 0}
-
- def interface(path, name):
- return dbus.Interface(bus.get_object('org.bluez', path), name)
-
- def objects():
- return interface('/', 'org.freedesktop.DBus.ObjectManager').GetManagedObjects()
-
- def adapter():
- for path, interfaces in objects().items():
- if 'org.bluez.Adapter1' in interfaces:
- return str(path), interfaces['org.bluez.Adapter1']
- raise ValueError('BLUETOOTH_UNAVAILABLE')
-
- def snapshot():
- path, props = adapter()
- return dict(available=True, address=str(props['Address']), name=str(props.get('Alias', 'Vulpes OS')),
- enabled=bool(props['Powered']), discoverable=bool(props['Discoverable']),
- discovering=bool(props['Discovering']),
- devices=[device_record(i['org.bluez.Device1']) for p, i in objects().items()
- if 'org.bluez.Device1' in i and str(i['org.bluez.Device1']['Adapter']) == path],
- pairing=[p['info'] for p in pending.values()])
-
- def remote(address):
- path, _ = adapter()
- for p, interfaces in objects().items():
- props = interfaces.get('org.bluez.Device1', {})
- if str(props.get('Address', '')).upper() == address.upper() and str(props.get('Adapter')) == path:
- return str(p), props
- raise ValueError('BLUETOOTH_DEVICE_GONE')
-
- class Rejected(dbus.DBusException):
- _dbus_error_name = 'org.bluez.Error.Rejected'
-
- def drop(identity, error=True):
- row = pending.pop(identity, None)
- if row and error and row['fail']:
- row['fail'](Rejected('Pairing rejected or expired'))
- return False
-
- def prompt(path, method, ok=None, fail=None, passkey=''):
- # One original Gaia pairing panel at a time, never auto-accept a peer.
- for identity in list(pending):
- drop(identity)
- props = interface(path, 'org.freedesktop.DBus.Properties').GetAll('org.bluez.Device1')
- identity = str(uuid.uuid4())
- pending[identity] = dict(ok=ok, fail=fail, path=str(path),
- info=dict(id=identity, method=method, address=str(props['Address']),
- deviceName=str(props.get('Alias', props['Address'])), passkey=str(passkey)))
- GLib.timeout_add_seconds(90, drop, identity)
-
- def check_sender(sender):
- if sender != bus.get_name_owner('org.bluez'):
- raise Rejected('Only BlueZ may request pairing')
-
- def clear_pending():
- for identity in list(pending):drop(identity)
-
- class Agent(dbus.service.Object):
- @dbus.service.method('org.bluez.Agent1', in_signature='ou', out_signature='', sender_keyword='sender', async_callbacks=('ok', 'fail'))
- def RequestConfirmation(self, device, passkey, ok, fail, sender=None):
- check_sender(sender)
- prompt(device, 'confirmation', ok, fail, f'{int(passkey):06d}')
-
- @dbus.service.method('org.bluez.Agent1', in_signature='o', out_signature='s', sender_keyword='sender', async_callbacks=('ok', 'fail'))
- def RequestPinCode(self, device, ok, fail, sender=None):
- check_sender(sender)
- prompt(device, 'enterpincode', ok, fail)
-
- @dbus.service.method('org.bluez.Agent1', in_signature='o', out_signature='u', sender_keyword='sender', async_callbacks=('ok', 'fail'))
- def RequestPasskey(self, device, ok, fail, sender=None):
- check_sender(sender)
- prompt(device, 'enterpasskey', ok, fail)
-
- @dbus.service.method('org.bluez.Agent1', in_signature='o', out_signature='', sender_keyword='sender', async_callbacks=('ok', 'fail'))
- def RequestAuthorization(self, device, ok, fail, sender=None):
- check_sender(sender)
- prompt(device, 'consent', ok, fail)
-
- @dbus.service.method('org.bluez.Agent1', in_signature='os', out_signature='', sender_keyword='sender', async_callbacks=('ok', 'fail'))
- def AuthorizeService(self, device, service, ok, fail, sender=None):
- check_sender(sender)
- props = interface(device, 'org.freedesktop.DBus.Properties').GetAll('org.bluez.Device1')
- if props.get('Trusted') and props.get('Paired'):
- ok()
- else:
- prompt(device, 'consent', ok, fail)
-
- @dbus.service.method('org.bluez.Agent1', in_signature='ouq', out_signature='', sender_keyword='sender')
- def DisplayPasskey(self, device, passkey, entered, sender=None):
- check_sender(sender)
- prompt(device, 'displaypasskey', passkey=f'{int(passkey):06d}')
-
- @dbus.service.method('org.bluez.Agent1', in_signature='os', out_signature='', sender_keyword='sender')
- def DisplayPinCode(self, device, pincode, sender=None):
- check_sender(sender)
- prompt(device, 'displaypasskey', passkey=pincode)
-
- @dbus.service.method('org.bluez.Agent1', in_signature='', out_signature='', sender_keyword='sender')
- def Cancel(self, sender=None):
- check_sender(sender)
- for identity in list(pending):
- drop(identity)
-
- @dbus.service.method('org.bluez.Agent1', in_signature='', out_signature='', sender_keyword='sender')
- def Release(self, sender=None):
- check_sender(sender)
- clear_pending()
-
- agent = Agent(bus, '/org/vulpes/BluetoothAgent')
- manager = interface('/org/bluez', 'org.bluez.AgentManager1')
- manager.RegisterAgent('/org/vulpes/BluetoothAgent', 'KeyboardDisplay')
- manager.RequestDefaultAgent('/org/vulpes/BluetoothAgent')
-
- def stop_scan():
- if discovery['owned']:
- try:
- path, _ = adapter()
- interface(path, 'org.bluez.Adapter1').StopDiscovery()
- finally:
- discovery['owned'] = False
-
- def expire_scan():
- if discovery['owned'] and time.monotonic() >= discovery['expires']:
- try:
- stop_scan()
- except Exception:
- pass
- return True
- GLib.timeout_add_seconds(5, expire_scan)
-
- def dispatch(value, finish):
- try:
- op = validate(value)
- path, props = adapter()
- prop = interface(path, 'org.freedesktop.DBus.Properties')
- if op == 'power':
- if not value['enabled']:
- stop_scan()
- prop.Set('org.bluez.Adapter1', 'Powered', dbus.Boolean(value['enabled'], variant_level=1))
- if not value['enabled']:
- clear_pending()
- elif op == 'discoverable':
- prop.Set('org.bluez.Adapter1', 'Pairable', dbus.Boolean(value['enabled'], variant_level=1))
- prop.Set('org.bluez.Adapter1', 'Discoverable', dbus.Boolean(value['enabled'], variant_level=1))
- elif op == 'rename':
- prop.Set('org.bluez.Adapter1', 'Alias', dbus.String(value['name'], variant_level=1))
- elif op == 'scan':
- if not discovery['owned']:
- interface(path, 'org.bluez.Adapter1').StartDiscovery()
- discovery['owned'] = True
- discovery['expires'] = time.monotonic() + 60
- elif op == 'stopScan':
- stop_scan()
- elif op == 'reply':
- row = pending.get(value['id'])
- if row is None:
- raise ValueError('BLUETOOTH_PAIRING_EXPIRED')
- method = row['info']['method']
- code = value.get('code', '')
- if value['accept'] and method in ('enterpincode', 'enterpasskey'):
- valid = isinstance(code, str) and 1 <= len(code) <= 16 and code.isascii() and code.isprintable()
- if method == 'enterpasskey':
- valid = isinstance(code, str) and re.fullmatch(r'[0-9]{1,6}', code)
- if not valid:
- raise ValueError('BLUETOOTH_INVALID_PIN')
- pending.pop(value['id'])
- if not value['accept'] and row['fail']:
- row['fail'](Rejected('Rejected by user'))
- elif row['ok']:
- if method == 'enterpincode': row['ok'](dbus.String(code))
- elif method == 'enterpasskey': row['ok'](dbus.UInt32(int(code)))
- else: row['ok']()
- elif op in ('pair', 'unpair', 'connect', 'disconnect', 'cancelPair'):
- device, dp = remote(value['address'])
- obj = interface(device, 'org.bluez.Device1')
- if op == 'unpair':
- interface(path, 'org.bluez.Adapter1').RemoveDevice(dbus.ObjectPath(device))
- elif op == 'cancelPair':
- obj.CancelPairing()
- elif op in ('connect', 'disconnect'):
- if not dp.get('Paired'):
- raise ValueError('BLUETOOTH_NOT_PAIRED')
- getattr(obj, 'Connect' if op == 'connect' else 'Disconnect')(
- reply_handler=lambda: finish(True), error_handler=lambda e: finish(error(e)), timeout=60)
- return False
- else:
- stop_scan()
- def paired():
- # Trust follows an explicit successful Pair initiated from Gaia.
- try:
- interface(device, 'org.freedesktop.DBus.Properties').Set(
- 'org.bluez.Device1', 'Trusted', dbus.Boolean(True, variant_level=1))
- for identity, row in list(pending.items()):
- if row['path']==device:drop(identity,False)
- finish(True)
- except Exception as exc:finish(error(exc))
- obj.Pair(reply_handler=paired, error_handler=lambda e: finish(error(e)), timeout=95)
- return False
- finish(snapshot())
- except Exception as e:
- finish(error(e))
- return False
-
- def error(exc):
- name = str(exc).split(':', 1)[0]
- if hasattr(exc, 'get_dbus_name'):
- name = exc.get_dbus_name().rsplit('.', 1)[-1]
- return {'error': name if re.fullmatch(r'[A-Za-z_]+', name) else 'BLUETOOTH_FAILED'}
-
- slots = threading.BoundedSemaphore(8)
- def connection(sock):
- try:
- with sock:
- sock.settimeout(110)
- data = b''
- while not data.endswith(b'\n') and len(data) <= 4096:
- chunk = sock.recv(4097-len(data))
- if not chunk: break
- data += chunk
- if len(data) > 4096: raise ValueError('BLUETOOTH_INVALID_REQUEST')
- value = json.loads(data)
- validate(value)
- done = threading.Event(); reply = []
- def finish(result):
- reply.append(result); done.set()
- GLib.idle_add(dispatch, value, finish)
- if not done.wait(100): raise ValueError('BLUETOOTH_TIMEOUT')
- result = reply[0]
- sock.sendall(json.dumps(result if isinstance(result, dict) and 'error' in result else {'result':result}).encode()+b'\n')
- except (OSError, ValueError):
- pass
- finally:
- slots.release()
-
- def listen():
- import pwd
- SOCKET.parent.mkdir(parents=True, exist_ok=True)
- SOCKET.unlink(missing_ok=True)
- with socket.socket(socket.AF_UNIX) as server:
- server.bind(str(SOCKET)); os.chmod(SOCKET, 0o660)
- os.chown(SOCKET, 0, pwd.getpwnam('droidian').pw_gid)
- server.listen(8)
- while True:
- sock, _ = server.accept()
- if not slots.acquire(blocking=False): sock.close(); continue
- threading.Thread(target=connection, args=(sock,), daemon=True).start()
- threading.Thread(target=listen, daemon=True).start()
- GLib.MainLoop().run()
-
-
-def client():
- data = sys.stdin.read(4097)
- if len(data) > 4096: raise ValueError('BLUETOOTH_INVALID_REQUEST')
- validate(json.loads(data))
- with socket.socket(socket.AF_UNIX) as sock:
- sock.settimeout(110); sock.connect(str(SOCKET)); sock.sendall(data.encode()+b'\n')
- result = b''
- while not result.endswith(b'\n') and len(result) <= LIMIT:
- chunk = sock.recv(65536)
- if not chunk: break
- result += chunk
- if len(result) > LIMIT: raise ValueError('BLUETOOTH_INVALID_REPLY')
- print(result.decode())
-
-if __name__ == '__main__':
- client() if '--client' in sys.argv else serve()
diff --git a/tundra/runtime/camera-preview.py b/tundra/runtime/camera-preview.py
index 458cedf5..848961f4 100644
--- a/tundra/runtime/camera-preview.py
+++ b/tundra/runtime/camera-preview.py
@@ -6,7 +6,6 @@ The parent owns stdin. EOF or five seconds without a request closes the sensor.
A pbuffer and external texture are required even for the software NV21 callback.
"""
import argparse
-import fcntl
import struct
import ctypes as C
import json
@@ -215,12 +214,6 @@ def main():
parser.add_argument('--probe', action='store_true', help='Count frames for five seconds, retain no images')
args = parser.parse_args()
output = os.fdopen(os.dup(1), 'wb', buffering=0)
- try:
- # A preview packet is 450 KiB. A larger pipe avoids waking the parent
- # for each 64 KiB fragment; unsupported kernels retain their default.
- fcntl.fcntl(output.fileno(), fcntl.F_SETPIPE_SZ, 1024 * 1024)
- except OSError:
- pass
os.dup2(2, 1) # HAL callbacks can print; keep stdout protocol uncontaminated.
camera = None
try:
@@ -243,12 +236,8 @@ def main():
last_request = time.monotonic()
if command == 'frame\n':
data = camera.frame or b''
- packet = memoryview(struct.pack(' LIMIT: raise ValueError('MMS_TOO_LARGE')
- subject=value.get('subject','')
- if not isinstance(subject,str) or len(subject)>200: raise ValueError('INVALID_MMS')
- return {'recipients':recipients,'parts':parts,'subject':subject}
-
-
-def request(value):
- if not isinstance(value,dict) or value.get('version')!=1: raise ValueError('INVALID_REQUEST')
- op=value.get('operation')
- with LOCK:
- rows=records()
- if op=='send':
- message=validate_send(value)
- # Encode before accepting the queue entry so malformed content isn't queued.
- encoded=codec('encode',**message)
- identity=str(uuid.uuid4())
- rows.append({'id':identity,'direction':'outgoing','state':'pending','encoded':encoded,'created':time.time()})
- save(ROOT/'queue.json',rows)
- return {'path':identity,'state':'pending'}
- if op=='state':
- row=next((r for r in rows if r['id']==value.get('path')),None)
- return {k:row[k] for k in ['state','error'] if k in row} if row else {'state':'unknown'}
- if op=='incoming':
- return [r['message'] for r in rows if r['direction']=='incoming' and r['state']=='received'][:1]
- if op=='ack':
- ids=value.get('ids')
- if not isinstance(ids,list) or len(ids)>100: raise ValueError('INVALID_REQUEST')
- for row in rows:
- if row['direction']=='incoming' and row['id'] in ids and row['state']=='received':
- row['state']='stored';row.pop('message',None)
- save(ROOT/'queue.json',rows);return True
- raise ValueError('NOT_SUPPORTED')
-
-
-@contextlib.contextmanager
-def bearer():
- import dbus
- bus=dbus.SystemBus(private=True)
- # This short-lived connection is closed after each transfer. With GLib,
- # the bus default would terminate the whole daemon on that disconnect.
- bus.set_exit_on_disconnect(False)
- try:
- conn=dbus.Interface(bus.get_object('org.ofono',MODEM),'org.ofono.ConnectionManager')
- contexts=[(str(p),v) for p,v in conn.GetContexts() if str(v.get('Type'))=='mms']
- if not contexts: raise ValueError('MMS_APN_MISSING')
- path,props=contexts[0]
- # Only provision an incomplete default context, never replace user APNs.
- if not props.get('MessageCenter'):
- sim=dbus.Interface(bus.get_object('org.ofono',MODEM),'org.ofono.SimManager').GetProperties()
- imsi=str(sim.get('SubscriberIdentity',''))
- if imsi.startswith('20801') and str(props.get('AccessPointName')) in ('','mms'):
- cfg={'AccessPointName':'orange.acte','Username':'orange','Password':'orange',
- 'AuthenticationMethod':'pap','Protocol':'ip','MessageCenter':'http://mms.orange.fr',
- 'MessageProxy':'192.168.10.200:8080'}
- ctx=dbus.Interface(bus.get_object('org.ofono',path),'org.ofono.ConnectionContext')
- for key,val in cfg.items():ctx.SetProperty(key,dbus.String(val,variant_level=1))
- props=ctx.GetProperties()
- else: raise ValueError('MMS_APN_MISSING')
- ctx=dbus.Interface(bus.get_object('org.ofono',path),'org.ofono.ConnectionContext')
- activated=False;manager=None;previous=None;cleanup=[]
- try:
- try:
- manager=dbus.Interface(bus.get_object('org.ofono','/'),'org.nemomobile.ofono.ModemManager')
- previous=manager.GetMmsSim()
- sim=dbus.Interface(bus.get_object('org.ofono',MODEM),'org.ofono.SimManager').GetProperties()
- manager.SetMmsSim(sim['SubscriberIdentity'])
- except dbus.DBusException:manager=None
- if not props.get('Active'):
- try:ctx.SetProperty('Active',dbus.Boolean(True,variant_level=1),timeout=45)
- except dbus.DBusException:raise ValueError('MMS_DATA_UNAVAILABLE')
- activated=True
- props=ctx.GetProperties();settings=props.get('Settings',{})
- interface=str(settings.get('Interface',''));address=str(settings.get('Address',''))
- if not re.fullmatch(r'[\w.-]{1,15}',interface) or not address:raise ValueError('MMS_DATA_UNAVAILABLE')
- ip=ipaddress.ip_address(address)
- if ip.version!=4:raise ValueError('MMS_DATA_UNAVAILABLE')
- proxy=str(props.get('MessageProxy',''));url=str(props.get('MessageCenter',''))
- parsed=urlsplit(url)
- if parsed.scheme!='http' or not parsed.hostname or parsed.username:raise ValueError('MMS_APN_MISSING')
- proxyurl=urlsplit(proxy if '://' in proxy else '//'+proxy)
- host=proxyurl.hostname or parsed.hostname;port=proxyurl.port or (8080 if proxy else parsed.port or 80)
- # Operator MMS profiles normally expose an IPv4 proxy. Avoid DNS via Wi-Fi.
- try: destination=str(ipaddress.IPv4Address(host))
- except ValueError:raise ValueError('MMS_PROXY_REQUIRED')
- subprocess.run(['ip','link','set',interface,'up'],check=True,stdout=subprocess.DEVNULL)
- prefix=ipaddress.IPv4Network('0.0.0.0/'+str(settings.get('Netmask','255.255.255.255'))).prefixlen
- current=json.loads(subprocess.check_output(['ip','-j','addr','show','dev',interface]))
- if not any(a.get('local')==address for link in current for a in link.get('addr_info',[])):
- address_args=[address+'/'+str(prefix),'dev',interface]
- subprocess.run(['ip','addr','add',*address_args],check=True,stdout=subprocess.DEVNULL)
- cleanup.append(['ip','addr','del',*address_args])
- routes=json.loads(subprocess.check_output(['ip','-j','route','show',destination+'/32']))
- if routes and not any(r.get('dev')==interface for r in routes):raise ValueError('MMS_ROUTE_CONFLICT')
- if not routes:
- route_args=[destination+'/32','dev',interface,'src',address]
- subprocess.run(['ip','route','add',*route_args],check=True,stdout=subprocess.DEVNULL)
- cleanup.append(['ip','route','del',*route_args])
- yield {'interface':interface,'address':address,'host':destination,'port':port,'url':url,'proxy':bool(proxy)}
- finally:
- for command in reversed(cleanup):
- subprocess.run(command,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
- if activated:
- try:ctx.SetProperty('Active',dbus.Boolean(False,variant_level=1),timeout=20)
- except dbus.DBusException:pass
- if manager is not None and previous is not None:
- try:manager.SetMmsSim(previous)
- except dbus.DBusException:pass
-
- finally:
- bus.close()
-
-
-def transfer(connection, url, body=None):
- parsed=urlsplit(url)
- if parsed.scheme!='http' or not parsed.hostname or parsed.username or parsed.fragment:raise ValueError('MMS_INVALID_URL')
- if not connection['proxy'] and parsed.hostname!=urlsplit(connection['url']).hostname:raise ValueError('MMS_INVALID_URL')
- client=http.client.HTTPConnection(connection['host'],connection['port'],timeout=35)
- sock=socket.socket(socket.AF_INET,socket.SOCK_STREAM);client.sock=sock
- target=url if connection['proxy'] else (parsed.path or '/')+('?' + parsed.query if parsed.query else '')
- try:
- sock.settimeout(35)
- sock.setsockopt(socket.SOL_SOCKET,socket.SO_BINDTODEVICE,connection['interface'].encode()+b'\0')
- sock.bind((connection['address'],0));sock.connect((connection['host'],connection['port']))
- client.request('POST' if body is not None else 'GET',target,body,
- {'Content-Type':'application/vnd.wap.mms-message','Accept':'application/vnd.wap.mms-message',
- 'User-Agent':'VulpesOS/2.7 MMS/1.1','Host':parsed.netloc})
- response=client.getresponse();data=response.read(LIMIT+1)
- if response.status!=200:raise ValueError('MMS_HTTP_ERROR')
- if len(data)>LIMIT:raise ValueError('MMS_TOO_LARGE')
- return data
- finally:client.close()
-
-
-def process(row):
- with bearer() as connection:
- if row['direction']=='outgoing':
- encoded=row['encoded'];data=transfer(connection,connection['url'],base64.b64decode(encoded['data']))
- response=codec('response',data=base64.b64encode(data).decode(),transaction=encoded['transaction'])
- if not response['accepted']:raise ValueError('MMS_REJECTED')
- return {'state':'sent','encoded':None,'acceptedByMMSC':True}
- notification=row['notification']
- if row.get('ackPending'):
- ack=codec('ack',transaction=notification['transaction'])
- transfer(connection,connection['url'],base64.b64decode(ack['data']))
- return {'ackPending':False}
- if notification['expires'] and notification['expires']WIRE_LIMIT:raise ValueError('MMS_TOO_LARGE')
- result={'ok':True,'result':request(json.loads(data))}
- except Exception as e:
- code=str(e) if re.fullmatch(r'[A-Z_]+',str(e)) else 'INVALID_REQUEST'
- result={'ok':False,'error':code}
- try:conn.sendall(json.dumps(result).encode()+b'\n')
- except OSError:pass
-
-
-def agent():
- import dbus,dbus.service
- from dbus.mainloop.glib import DBusGMainLoop
- from gi.repository import GLib
- DBusGMainLoop(set_as_default=True);bus=dbus.SystemBus();path='/org/vulpes/MmsPush'
- class Push(dbus.service.Object):
- @dbus.service.method('org.ofono.PushNotificationAgent',in_signature='aya{sv}',out_signature='',sender_keyword='sender')
- def ReceiveNotification(self,data,info,sender=None):
- if sender!=bus.get_name_owner('org.ofono'):raise dbus.DBusException('Denied')
- try:notification=codec('notification',data=base64.b64encode(bytes(data)).decode())
- except Exception:return
- identity=hashlib.sha256((notification['transaction']+'\n'+notification['url']).encode()).hexdigest()
- with LOCK:
- rows=records()
- if not any(r['id']==identity for r in rows):
- rows.append({'id':identity,'direction':'incoming','state':'pending','notification':notification});save(ROOT/'queue.json',rows)
- @dbus.service.method('org.ofono.PushNotificationAgent',in_signature='',out_signature='',sender_keyword='sender')
- def Release(self,sender=None):
- if sender==bus.get_name_owner('org.ofono'):GLib.timeout_add_seconds(3,register)
- push=Push(bus,path)
- def register():
- try:dbus.Interface(bus.get_object('org.ofono',MODEM),'org.ofono.PushNotification').RegisterAgent(dbus.ObjectPath(path));return False
- except dbus.DBusException:return True
- if register():GLib.timeout_add_seconds(10,register)
- GLib.MainLoop().run()
-
-
-def client():
- raw=sys.stdin.read(WIRE_LIMIT+1)
- if len(raw)>WIRE_LIMIT:raise ValueError('MMS_TOO_LARGE')
- with socket.socket(socket.AF_UNIX) as sock:
- sock.settimeout(20);sock.connect(str(SOCKET));sock.sendall(raw.encode()+b'\n');data=b''
- while not data.endswith(b'\n') and len(data)<=WIRE_LIMIT:
- chunk=sock.recv(65536)
- if not chunk:break
- data+=chunk
- if len(data)>WIRE_LIMIT:raise ValueError('MMS_TOO_LARGE')
- print(data.decode())
-
-if __name__=='__main__':
- if '--client' in sys.argv:client()
- else:
- os.umask(0o077);ROOT.mkdir(parents=True,exist_ok=True)
- rows=records()
- for row in rows:
- if row['state']=='submitting':row.update({'state':'failed','error':'MMS_DELIVERY_UNKNOWN'})
- save(ROOT/'queue.json',rows)
- threading.Thread(target=worker,daemon=True).start()
- threading.Thread(target=serve,daemon=True).start()
- agent()
diff --git a/tundra/runtime/mobile-settings.py b/tundra/runtime/mobile-settings.py
deleted file mode 100644
index 790e9de6..00000000
--- a/tundra/runtime/mobile-settings.py
+++ /dev/null
@@ -1,316 +0,0 @@
-# SPDX-License-Identifier: MPL-2.0
-"""Settings-only oFono queries. Never return IMSI, PIN retries or device serials."""
-import subprocess
-import json
-import re
-import time
-from pathlib import Path
-
-CLOCK_CONFIG = Path("/var/lib/tundra/clock/settings.json")
-
-def clock_automatic():
- try:
- return json.loads(CLOCK_CONFIG.read_text())["automatic"] is True
- except FileNotFoundError:
- return True
-
-def save_clock_automatic(enabled):
- CLOCK_CONFIG.parent.mkdir(parents=True, exist_ok=True)
- temporary = CLOCK_CONFIG.with_suffix(".tmp")
- temporary.write_text(json.dumps({"automatic": enabled}) + "\n")
- temporary.chmod(0o600)
- temporary.replace(CLOCK_CONFIG)
-
-def network_time():
- result = subprocess.run(["/usr/bin/busctl", "--system", "--timeout=5", "--json=short", "call",
- "org.ofono", "/ril_0", "org.ofono.NetworkTime", "GetNetworkTime"], capture_output=True, text=True, timeout=7)
- if result.returncode:
- raise RuntimeError("NETWORK_TIME_UNAVAILABLE")
- values = {key: value.get("data") for key, value in json.loads(result.stdout)["data"][0].items()}
- utc, received = values.get("UTC"), values.get("Received")
- now = time.monotonic()
- if type(utc) is not int or type(received) is not int or not 0 <= received <= now:
- raise RuntimeError("NETWORK_TIME_UNAVAILABLE")
- seconds = utc + now - received
- if not 946684800 <= seconds <= 4102444800:
- raise RuntimeError("NETWORK_TIME_UNAVAILABLE")
- return seconds
-
-def sync_network_clock():
- if not clock_automatic():
- return
- seconds = network_time()
- if abs(time.time() - seconds) > 2:
- # oFono timestamps use CLOCK_MONOTONIC, not wall time or boot time.
- time.clock_settime(time.CLOCK_REALTIME, seconds)
-
-
-
-def properties(interface, timeout=5):
- result = subprocess.run(['/usr/bin/busctl', '--system', '--timeout='+str(timeout), '--json=short',
- 'call', 'org.ofono', '/ril_0', 'org.ofono.' + interface,
- 'GetProperties'], capture_output=True, text=True, timeout=timeout+2)
- if result.returncode:
- raise RuntimeError('RADIO_NOT_READY')
- return {key: value.get('data') for key, value in json.loads(result.stdout)['data'][0].items()}
-
-
-def request(value):
- if value.get('action') in ('calls','setCallWaiting','setCallerId','forwarding','setForwarding'):
- return supplementary_request(value)
- if value.get('action') in ('radioMode', 'setRadioMode', 'apns', 'editApn'):
- return connection_request(value)
- if set(value) - {'version', 'operation', 'action', 'enabled'}:
- raise ValueError('INVALID_REQUEST')
- action = value.get('action')
- if action not in ('deliveryReports','dataEnabled','roaming') and 'enabled' in value:
- raise ValueError('INVALID_REQUEST')
- if action in ('network', 'dataEnabled', 'roaming'):
- if action != 'network':
- if type(value.get('enabled')) is not bool:
- raise ValueError('INVALID_REQUEST')
- name = 'Powered' if action == 'dataEnabled' else 'RoamingAllowed'
- set_property('ConnectionManager', name, 'b', value['enabled'])
- data = properties('ConnectionManager')
- if action != 'network' and data.get(name) is not value['enabled']:
- raise RuntimeError('RADIO_REQUEST_FAILED')
- network = properties('NetworkRegistration')
- return {'enabled':data.get('Powered'), 'roaming':data.get('RoamingAllowed'),
- 'attached':data.get('Attached'), 'status':network.get('Status'),
- 'operator':network.get('Name'), 'technology':network.get('Technology'), 'mode':network.get('Mode')}
- if action == 'sim':
- sim = properties('SimManager')
- modem = properties('Modem')
- # The slot key stays private to Gaia Settings; subscriber identity is not needed.
- return {'radioEnabled':modem.get('Powered') is True and modem.get('Online') is True,
- 'present': sim.get('Present') is True, 'id': sim.get('CardIdentifier'),
- 'operator': sim.get('ServiceProviderName', ''), 'pin': sim.get('PinRequired'),
- 'fixedDialing': sim.get('FixedDialing'), 'pinEnabled':'pin' in sim.get('LockedPins', []), 'numbers': sim.get('SubscriberNumbers', [])}
- if action in ('smsc', 'messages'):
- props = properties('MessageManager')
- return {'address': props.get('ServiceCenterAddress'), 'deliveryReports': props.get('UseDeliveryReports')}
- if action == 'deliveryReports':
- if type(value.get('enabled')) is not bool:
- raise ValueError('INVALID_REQUEST')
- result = subprocess.run(['/usr/bin/busctl', '--system', '--timeout=5', 'call',
- 'org.ofono', '/ril_0', 'org.ofono.MessageManager', 'SetProperty',
- 'sv', 'UseDeliveryReports', 'b', str(value['enabled']).lower()],
- capture_output=True, text=True, timeout=7)
- if result.returncode:
- raise RuntimeError('RADIO_REQUEST_FAILED')
- props = properties('MessageManager')
- if props.get('UseDeliveryReports') is not value['enabled']:
- raise RuntimeError('RADIO_REQUEST_FAILED')
- return {'deliveryReports': props['UseDeliveryReports']}
- raise ValueError('NOT_SUPPORTED')
-
-
-def set_property(interface, name, signature, value, timeout=5):
- result = subprocess.run(['/usr/bin/busctl', '--system', '--timeout='+str(timeout), 'call',
- 'org.ofono', '/ril_0', 'org.ofono.' + interface,
- 'SetProperty', 'sv', name, signature, str(value).lower() if signature == 'b' else value],
- capture_output=True, text=True, timeout=timeout+2)
- if result.returncode:
- raise RuntimeError('RADIO_REQUEST_FAILED')
-
-
-def device_request(value):
- """Clock service only; callers cannot supply commands, paths or D-Bus members."""
- import math
- from pathlib import Path
- allowed = {'version', 'operation', 'action', 'timezone', 'milliseconds', 'enabled'}
- if set(value) - allowed:
- raise ValueError('INVALID_REQUEST')
- action = value.get('action')
- extras = {'setTimezone': {'timezone'}, 'setTime': {'milliseconds'}, 'clock': set(), 'setAutomatic': {'enabled'}}
- if action in extras and set(value) - {'version', 'operation', 'action'} - extras[action]:
- raise ValueError('INVALID_REQUEST')
- if action == 'setTimezone':
- zone = value.get('timezone')
- if not isinstance(zone, str) or zone.startswith('/') or '..' in zone.split('/') or len(zone) > 80:
- raise ValueError('INVALID_REQUEST')
- root = Path('/usr/share/zoneinfo').resolve()
- target = (root / zone).resolve()
- if not target.is_relative_to(root) or not target.is_file() or target.read_bytes()[:4] != b'TZif':
- raise ValueError('INVALID_REQUEST')
- args = ['set-timezone', zone]
- elif action == 'setAutomatic':
- if type(value.get('enabled')) is not bool:
- raise ValueError('INVALID_REQUEST')
- save_clock_automatic(value['enabled'])
- if value['enabled']:
- try:
- sync_network_clock()
- except RuntimeError:
- pass # Keep the requested mode while waiting for the network.
- args = None
- elif action == 'setTime':
- ms = value.get('milliseconds')
- if type(ms) not in (int, float) or not math.isfinite(ms) or not 946684800000 <= ms <= 4102444800000:
- raise ValueError('INVALID_REQUEST')
- if clock_automatic():
- raise RuntimeError('AUTOMATIC_TIME_ENABLED')
- args = ['set-time', '@' + str(int(ms / 1000))]
- elif action == 'clock':
- args = None
- else:
- raise ValueError('NOT_SUPPORTED')
- if args:
- result = subprocess.run(['/usr/bin/timedatectl', *args], capture_output=True, text=True, timeout=10)
- if result.returncode:
- raise RuntimeError('CLOCK_REQUEST_FAILED')
- result = subprocess.run(['/usr/bin/timedatectl', 'show', '-p', 'Timezone', '-p', 'NTP', '-p', 'CanNTP'],
- capture_output=True, text=True, timeout=5)
- if result.returncode:
- raise RuntimeError('CLOCK_UNAVAILABLE')
- state = dict(line.split('=', 1) for line in result.stdout.splitlines() if '=' in line)
- if action == 'setTimezone' and state.get('Timezone') != value['timezone']:
- raise RuntimeError('CLOCK_REQUEST_FAILED')
- return {'timezone':state.get('Timezone'), 'automatic':clock_automatic(), 'automaticAvailable':True}
-
-
-# Only existing user-facing Internet/MMS contexts are editable. IMS is owned by
-# the telephony stack and must never be rewritten by the Settings form.
-APN_FIELDS = {'apn':'AccessPointName', 'user':'Username', 'password':'Password',
- 'protocol':'Protocol', 'authtype':'AuthenticationMethod',
- 'mmsc':'MessageCenter', 'mmsproxy':'MessageProxy'}
-
-
-def contexts():
- result = subprocess.run(['/usr/bin/busctl','--system','--timeout=5','--json=short',
- 'call','org.ofono','/ril_0','org.ofono.ConnectionManager','GetContexts'],
- capture_output=True,text=True,timeout=7)
- if result.returncode:
- raise RuntimeError('RADIO_NOT_READY')
- return {path: {key:item['data'] for key,item in props.items()}
- for path,props in json.loads(result.stdout)['data'][0]
- if re.fullmatch(r'/ril_0/context[0-9]+',path)
- and props.get('Type',{}).get('data') in ('internet','mms')}
-
-
-def apn_view(path, props):
- fields={key:str(props.get(name,'')) for key,name in APN_FIELDS.items() if key!='password'}
- return {'id':path,'type':'default' if props['Type']=='internet' else 'mms',
- 'active':props.get('Active') is True, 'hasPassword':bool(props.get('Password')),
- 'fields':fields}
-
-
-def set_context(path, name, value):
- result = subprocess.run(['/usr/bin/busctl','--system','--timeout=3','call','org.ofono',path,
- 'org.ofono.ConnectionContext','SetProperty','sv',name,'s',value],
- capture_output=True,text=True,timeout=4)
- if result.returncode:
- raise RuntimeError('APN_WRITE_FAILED')
-
-
-def connection_request(value):
- action=value.get('action')
- extras={'radioMode':set(),'setRadioMode':{'mode'},'apns':set(),'editApn':{'id','fields'}}
- if action not in extras or set(value)-{'version','operation','action'}-extras[action]:
- raise ValueError('INVALID_REQUEST')
- if action in ('radioMode','setRadioMode'):
- before=properties('RadioSettings')
- available=[mode for mode in before.get('AvailableTechnologies',[]) if mode in ('gsm','umts','lte')]
- if action=='setRadioMode':
- mode=value.get('mode')
- if mode not in ['any',*available]:
- raise ValueError('INVALID_REQUEST')
- if mode != before.get('TechnologyPreference'):
- set_property('RadioSettings','TechnologyPreference','s',mode)
- before=properties('RadioSettings')
- if before.get('TechnologyPreference')!=mode:
- raise RuntimeError('RADIO_REQUEST_FAILED')
- return {'mode':before.get('TechnologyPreference'),'available':['any',*available]}
- if action=='apns':
- return [apn_view(path,props) for path,props in contexts().items()]
- path=value.get('id'); fields=value.get('fields')
- if not isinstance(path,str) or not re.fullmatch(r'/ril_0/context[0-9]+',path):
- raise ValueError('INVALID_REQUEST')
- if not isinstance(fields,dict) or not fields or set(fields)-set(APN_FIELDS):
- raise ValueError('INVALID_REQUEST')
- for key,field in fields.items():
- if not isinstance(field,str) or len(field.encode())>512 or any(ord(c)<32 for c in field):
- raise ValueError('INVALID_REQUEST')
- if key=='apn' and not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9.-]{0,99}',field):
- raise ValueError('INVALID_REQUEST')
- if key=='protocol' and field not in ('ip','ipv6','dual'):
- raise ValueError('INVALID_REQUEST')
- if key=='authtype' and field not in ('none','pap','chap','any'):
- raise ValueError('INVALID_REQUEST')
- if key=='mmsc' and field:
- from urllib.parse import urlsplit
- url=urlsplit(field)
- if url.scheme not in ('http','https') or not url.hostname or url.username or url.password:
- raise ValueError('INVALID_REQUEST')
- if key=='mmsproxy' and field and not re.fullmatch(r'[A-Za-z0-9.\[\]:-]{1,255}',field):
- raise ValueError('INVALID_REQUEST')
- before=contexts().get(path)
- if before is None:
- raise ValueError('APN_NOT_FOUND')
- if before['Type']!='mms' and any(k in fields for k in ('mmsc','mmsproxy')):
- raise ValueError('INVALID_REQUEST')
- changes={APN_FIELDS[key]:field for key,field in fields.items() if before.get(APN_FIELDS[key],'')!=field}
- if not changes:
- return apn_view(path,before)
- if before.get('Active'):
- raise RuntimeError('APN_IN_USE')
- changed=[]
- try:
- for name,field in changes.items():
- # Include the attempted property: a D-Bus timeout may arrive after
- # the modem has already applied it.
- changed.append(name)
- set_context(path,name,field)
- after=contexts().get(path)
- if after is None or any(after.get(name,'')!=field for name,field in changes.items()):
- raise RuntimeError('APN_WRITE_FAILED')
- return apn_view(path,after)
- except (RuntimeError,OSError,subprocess.TimeoutExpired):
- failed=False
- for name in reversed(changed):
- try:set_context(path,name,str(before.get(name,'')))
- except (RuntimeError,OSError,subprocess.TimeoutExpired):failed=True
- try:restored=contexts().get(path)
- except (RuntimeError,OSError,subprocess.TimeoutExpired):restored=None
- if failed or restored is None or any(restored.get(name,'')!=before.get(name,'') for name in changed):
- raise RuntimeError('APN_ROLLBACK_FAILED') from None
- raise RuntimeError('APN_WRITE_FAILED') from None
-
-
-FORWARDING = {'unconditional':'VoiceUnconditional', 'busy':'VoiceBusy',
- 'noReply':'VoiceNoReply', 'notReachable':'VoiceNotReachable'}
-
-
-def supplementary_request(value):
- action=value.get('action')
- extras={'calls':set(), 'setCallWaiting':{'enabled'}, 'setCallerId':{'mode'},
- 'forwarding':set(), 'setForwarding':{'reason','enabled','number'}}
- if action not in extras or set(value)-{'version','operation','action'}-extras[action]:
- raise ValueError('INVALID_REQUEST')
- if action in ('calls','setCallWaiting','setCallerId'):
- name=None
- if action=='setCallWaiting':
- if type(value.get('enabled')) is not bool:raise ValueError('INVALID_REQUEST')
- name='VoiceCallWaiting'; desired='enabled' if value['enabled'] else 'disabled'
- elif action=='setCallerId':
- desired=value.get('mode')
- if desired not in ('default','enabled','disabled'):raise ValueError('INVALID_REQUEST')
- name='HideCallerId'
- if name:set_property('CallSettings',name,'s',desired,timeout=20)
- # Network supplementary-service queries take several seconds on this RIL.
- props=properties('CallSettings',timeout=20)
- if name and props.get(name)!=desired:raise RuntimeError('RADIO_READBACK_FAILED')
- return {key:props.get(key) for key in ('HideCallerId','VoiceCallWaiting')}
- if action=='setForwarding':
- name=FORWARDING.get(value.get('reason'))
- number=value.get('number','');enabled=value.get('enabled')
- if name is None or type(enabled) is not bool or not isinstance(number,str):raise ValueError('INVALID_REQUEST')
- if enabled and not re.fullmatch(r'\+?[0-9]{1,20}',number):raise ValueError('INVALID_REQUEST')
- if not enabled and number:raise ValueError('INVALID_REQUEST')
- desired=number if enabled else ''
- before=properties('CallForwarding',timeout=20)
- if name not in before:raise RuntimeError('RADIO_NOT_READY')
- if before[name]!=desired:set_property('CallForwarding',name,'s',desired,timeout=20)
- props=properties('CallForwarding',timeout=20)
- if action=='setForwarding' and props.get(name)!=desired:raise RuntimeError('RADIO_READBACK_FAILED')
- return {key:props.get(member) for key,member in FORWARDING.items()}
diff --git a/tundra/runtime/modem-recovery.py b/tundra/runtime/modem-recovery.py
deleted file mode 100644
index 14e80b1b..00000000
--- a/tundra/runtime/modem-recovery.py
+++ /dev/null
@@ -1,35 +0,0 @@
-#!/usr/bin/env python3
-# SPDX-License-Identifier: MPL-2.0
-"""Keep a Sargo modem fault from immediately restarting the whole phone."""
-from pathlib import Path
-import time
-
-
-def configure(devices=Path('/sys/bus/msm_subsys/devices')):
- matches = [p.parent for p in devices.glob('*/name')
- if p.read_text().strip() == 'modem']
- if len(matches) != 1:
- raise RuntimeError('Expected one Sargo modem subsystem')
- level = matches[0]/'restart_level'
- previous = level.read_text().strip()
- if previous not in ('SYSTEM', 'RELATED'):
- raise RuntimeError('Unsupported modem restart policy: '+previous)
- # This kernel supports coupled subsystem recovery. Firmware failures and
- # unsuccessful recovery remain visible in the kernel log; do not suppress
- # its watchdog or crash accounting.
- if previous != 'RELATED':
- level.write_text('RELATED\n')
- if level.read_text().strip() != 'RELATED':
- raise RuntimeError('Modem restart policy was not applied')
- print('Sargo modem recovery: '+previous+' -> RELATED', flush=True)
-
-
-if __name__ == '__main__':
- for attempt in range(30):
- try:
- configure()
- break
- except (FileNotFoundError, RuntimeError):
- if attempt == 29:
- raise
- time.sleep(1)
diff --git a/tundra/runtime/prepare-bluetooth.py b/tundra/runtime/prepare-bluetooth.py
deleted file mode 100644
index cfc149a0..00000000
--- a/tundra/runtime/prepare-bluetooth.py
+++ /dev/null
@@ -1,59 +0,0 @@
-# SPDX-License-Identifier: MPL-2.0
-"""Connect the Sargo Bluetooth HAL to BlueZ; pairing stays user initiated."""
-
-def prepare(root, write):
- import shutil
- from pathlib import Path
- shutil.copy2(Path(__file__).with_name('bluetooth-control.py'),root/'etc/tundra/bluetooth-control.py')
- write('etc/systemd/system/tundra-bluetooth.service', """[Unit]
-Description=Tundra Bluetooth pairing and discovery broker
-Requires=bluetooth.service
-After=bluetooth.service
-PartOf=bluetooth.service
-[Service]
-ExecStart=/usr/bin/python3 /etc/tundra/bluetooth-control.py
-RuntimeDirectory=tundra-bluetooth
-RuntimeDirectoryMode=0755
-UMask=0077
-Restart=on-failure
-RestartSec=3
-NoNewPrivileges=yes
-ProtectSystem=strict
-ProtectHome=yes
-PrivateTmp=yes
-""")
-
- write('etc/dbus-1/system.d/tundra-bluetooth.conf', '''
-
-
-
-
-''')
- write('etc/bluetooth/main.conf', '[General]\nName=Vulpes OS\nDiscoverableTimeout=120\n[Policy]\nAutoEnable=false\n')
- write('etc/systemd/system/tundra-bluebinder.service', '''[Unit]
-Description=Sargo Bluetooth HAL bridge
-Requires=tundra-hal.service
-After=tundra-hal.service
-Before=bluetooth.service
-[Service]
-Type=notify
-ExecStartPre=/usr/bin/binder-wait android.hardware.bluetooth@1.0::IBluetoothHci/default
-ExecStart=/usr/sbin/bluebinder
-Restart=on-failure
-RestartSec=5
-TimeoutStartSec=60
-NoNewPrivileges=yes
-ProtectHome=yes
-ProtectSystem=strict
-PrivateTmp=yes
-''')
- write('etc/systemd/system/bluetooth.service.d/tundra.conf', '''[Unit]
-Requires=tundra-bluebinder.service dbus.service
-After=tundra-bluebinder.service dbus.service
-''')
- wants = root/'etc/systemd/system/tundra.target.wants'
- wants.mkdir(parents=True, exist_ok=True)
- (wants/'tundra-bluetooth.service').symlink_to('/etc/systemd/system/tundra-bluetooth.service')
- (wants/'bluetooth.service').symlink_to('/usr/lib/systemd/system/bluetooth.service')
diff --git a/tundra/runtime/prepare-hardware.py b/tundra/runtime/prepare-hardware.py
index c490711d..d4157104 100644
--- a/tundra/runtime/prepare-hardware.py
+++ b/tundra/runtime/prepare-hardware.py
@@ -2,23 +2,10 @@
# SPDX-License-Identifier: MPL-2.0
"""Configure isolated oFono/PulseAudio diagnostics; no call or SMS on startup."""
from pathlib import Path
-import shutil
-import runpy
def prepare(root, write):
write('etc/tundra/hardware-enabled', 'sargo\n')
- runpy.run_path(str(Path(__file__).with_name('runtime-packages.py')))['stage'](root)
- runpy.run_path(str(Path(__file__).with_name('prepare-bluetooth.py')))['prepare'](root, write)
- shutil.copy2(Path(__file__).with_name('audio-devices.py'), root/'etc/tundra/audio-devices.py')
- write('etc/udev/rules.d/99-tundra-audio.rules', '\n'.join(
- 'KERNEL=="'+name+'", GROUP="1005", MODE="0660"'
- for name in ('msm_audio_cal', 'msm_rtac', 'msm_cirrus_playback'))+'\n')
- shutil.copy2(Path(__file__).with_name('modem-recovery.py'),
- root/'etc/tundra/modem-recovery.py')
- write('etc/systemd/system/tundra-hal.service.d/modem-recovery.conf', '''[Service]
-ExecStartPre=/usr/bin/python3 /etc/tundra/modem-recovery.py
-''')
with (root/'etc/group').open('a') as groups:
groups.write('audio:x:1005:droidian\ncamera:x:1006:droidian\nradio:x:1001:\nmedia:x:1013:droidian\n')
write('etc/ofono/binder.conf', '[Settings]\nExpectSlots = slot1\n\n[slot1]\npath = /ril_0\nslot = 0\n')
@@ -39,8 +26,6 @@ After=tundra-hal.service dbus.service
[Service]
Type=dbus
BusName=org.ofono
-StateDirectory=ofono
-StateDirectoryMode=0700
ExecStartPre=/usr/bin/binder-wait android.hardware.radio@1.0::IRadio/slot1
ExecStart=/usr/sbin/ofonod --nodetach
Restart=on-failure
@@ -64,39 +49,14 @@ StateDirectory=tundra/sms
StateDirectoryMode=0700
ReadWritePaths=/run/tundra-radio /var/lib/tundra/sms
''')
- write('etc/systemd/system/tundra-radio.service.d/clock.conf', Path(__file__).with_name('clock-service.conf').read_text())
- runpy.run_path(str(Path(__file__).with_name('mms-codec.py')))['stage'](root)
- shutil.copy2(Path(__file__).with_name('mms-control.py'),root/'etc/tundra/mms-control.py')
- write('etc/systemd/system/tundra-mms.service', """[Unit]
-Description=Tundra MMS queue and oFono push agent
-Requires=ofono.service
-After=ofono.service
-[Service]
-ExecStart=/usr/bin/python3 /etc/tundra/mms-control.py
-UMask=0077
-RuntimeDirectory=tundra-mms
-RuntimeDirectoryMode=0755
-StateDirectory=tundra/mms
-StateDirectoryMode=0700
-ProtectSystem=strict
-ProtectHome=yes
-PrivateTmp=yes
-ReadWritePaths=/var/lib/tundra/mms /run/tundra-mms
-MemoryMax=256M
-Restart=on-failure
-RestartSec=3
-""")
- write('etc/pulse/client.conf' , 'autospawn = no\n')
+ write('etc/pulse/client.conf', 'autospawn = no\n')
write('etc/pulse/daemon.conf', 'exit-idle-time = -1\ndefault-sample-rate = 48000\n')
write('etc/pulse/default.pa', '''.fail
load-module module-native-protocol-unix
-load-module module-dbus-protocol
load-module module-droid-card rate=48000 voice_virtual_stream=true
load-module module-droid-hidl
load-module module-default-device-restore
load-module module-suspend-on-idle
-load-module module-bluetooth-policy auto_switch=0
-load-module module-bluetooth-discover headset=native
''')
# Prefer AAL for the next camera integration tests. The GStreamer probe
# requests libstagefright_ccodec.so, absent from this Android 9 image.
@@ -106,12 +66,11 @@ load-module module-bluetooth-discover headset=native
write('etc/systemd/system/tundra-audio.service', '''[Unit]
Description=Tundra Sargo PulseAudio HAL bridge
Requires=tundra-hal.service systemd-logind.service
-After=tundra-hal.service systemd-logind.service bluetooth.service
+After=tundra-hal.service systemd-logind.service
[Service]
User=droidian
PAMName=tundra
Environment=XDG_RUNTIME_DIR=/run/user/32011
-ExecStartPre=+/usr/bin/python3 /etc/tundra/audio-devices.py
ExecStart=/usr/bin/pulseaudio -n --daemonize=no --exit-idle-time=-1 --log-target=journal --file=/etc/pulse/default.pa
Restart=on-failure
RestartSec=5
@@ -125,10 +84,8 @@ ExecStart=/usr/bin/python3 /etc/tundra/call-audio.py
RuntimeDirectory=tundra-call-audio
Restart=on-failure
RestartSec=5
-ProtectHome=tmpfs
+ProtectHome=yes
ProtectSystem=strict
-# The audio socket lives under /run/user, which ProtectHome also hides.
-BindReadOnlyPaths=/run/user/32011
ReadWritePaths=/run/tundra-call-audio
''')
(root/'var/lib/tundra/sms').mkdir(parents=True,exist_ok=True,mode=0o700)
@@ -140,5 +97,5 @@ ReadWritePaths=/run/tundra-call-audio
'StateDirectory=tundra/sms','StateDirectoryMode=0700',
'ReadWritePaths=/var/lib/tundra/sms','Restart=on-failure','RestartSec=3','']))
wants = root/'etc/systemd/system/tundra.target.wants'
- for name in ('ofono', 'tundra-audio', 'ModemManager', 'tundra-radio', 'tundra-call-audio', 'tundra-sms', 'tundra-mms'):
+ for name in ('ofono', 'tundra-audio', 'ModemManager', 'tundra-radio', 'tundra-call-audio', 'tundra-sms'):
(wants/(name+'.service')).symlink_to('../'+name+'.service')
diff --git a/tundra/runtime/prepare-native.py b/tundra/runtime/prepare-native.py
index 39bea004..e8b3467e 100644
--- a/tundra/runtime/prepare-native.py
+++ b/tundra/runtime/prepare-native.py
@@ -32,9 +32,8 @@ def prepare(root, network=None, hardware=None, diagnostics=False):
FiraSans Fira Sans
''')
- # fc-cache inherits HOME; running it as root leaves a root-owned user cache.
write('etc/systemd/system/tundra-display.service.d/fonts.conf', '''[Service]
-ExecStartPre=/usr/bin/fc-cache -f /usr/share/fonts/opentype/vulpes
+ExecStartPre=+/usr/bin/fc-cache -f /usr/share/fonts/opentype/vulpes
''')
write('etc/os-release','NAME="Tundra"\nID=tundra\nPRETTY_NAME="Tundra Sargo development trial"\n')
write('etc/hostname','vulpes-sargo\n')
@@ -118,11 +117,9 @@ for _ in range(120):
time.sleep(.5)
else:raise RuntimeError('Display HAL did not start')
''')
- # Cellular HAL and oFono must see the same rmnet interfaces. Display-only
- # diagnostics retain a separate network namespace. No Android network manager runs.
- write('etc/tundra/lxc.conf',f'''lxc.rootfs.path = /android
+ write('etc/tundra/lxc.conf','''lxc.rootfs.path = /android
lxc.uts.name = tundra-hal
-lxc.net.0.type = {'none' if hardware else 'empty'}
+lxc.net.0.type = empty
lxc.arch = aarch64
lxc.init.cmd = /init
lxc.apparmor.profile = unconfined
@@ -139,7 +136,7 @@ lxc.mount.entry = sys sys sysfs nodev,noexec,nosuid 0 0
lxc.mount.entry = /android/data data none bind 0 0
lxc.mount.entry = /mnt mnt none rbind 0 0
''')
- for name in ('hal_policy.py','setup-hal.py','lxc-guard.py','hardware-status.py','prepare-network.py','wifi-control.py','radio-control.py','mobile-settings.py','sms-inbox.py','call-audio.py','camera-preview.py','buttons.py'):
+ for name in ('hal_policy.py','setup-hal.py','lxc-guard.py','hardware-status.py','prepare-network.py','wifi-control.py','radio-control.py','sms-inbox.py','call-audio.py','camera-preview.py','buttons.py'):
shutil.copy2(Path(__file__).parent/name,root/'etc/tundra'/name)
write('etc/systemd/system/tundra-hardware.service', '''[Unit]
Description=Tundra read-only battery and network state
@@ -174,10 +171,6 @@ NoNewPrivileges=yes
(wants/'tundra-buttons.service').symlink_to('../tundra-buttons.service')
# Older immutable images may not contain the networking packages yet.
if network:
- write('etc/NetworkManager/conf.d/80-vulpes-hotspot.conf',Path(__file__).with_name('hotspot-firewall.conf').read_text())
- for name in ('iptables','ip6tables'):
- alias=root/'usr/sbin'/name
- if not alias.exists() and not alias.is_symlink():alias.symlink_to(name+'-legacy')
write('etc/tundra/network-enabled', 'sargo\n')
display = root/'etc/systemd/system/tundra-display.service'
display.write_text(display.read_text().replace('Environment=VULPES_TUNDRA=1',
diff --git a/tundra/runtime/radio-control.py b/tundra/runtime/radio-control.py
index 7c300127..d6ff7d87 100644
--- a/tundra/runtime/radio-control.py
+++ b/tundra/runtime/radio-control.py
@@ -16,8 +16,6 @@ import socket
import subprocess
import sys
import time
-import threading
-from concurrent.futures import ThreadPoolExecutor
SOCKET = Path('/run/tundra-radio/radio.sock')
MODEM = '/ril_0'
@@ -56,20 +54,12 @@ def request(value):
if not isinstance(value, dict) or value.get('version') != 1:
raise ValueError('INVALID_REQUEST')
operation = value.get('operation')
- if operation == 'deviceSettings':
- spec = importlib.util.spec_from_file_location('mobile_settings', Path(__file__).with_name('mobile-settings.py'))
- module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
- return module.device_request(value)
- if operation == 'mobileSettings':
- spec = importlib.util.spec_from_file_location('mobile_settings',Path(__file__).with_name('mobile-settings.py'))
- module = importlib.util.module_from_spec(spec);spec.loader.exec_module(module)
- return module.request(value)
if operation in ('incomingMessages','ackMessages','messageState'):
spec = importlib.util.spec_from_file_location('inbox',Path(__file__).with_name('sms-inbox.py'))
inbox = importlib.util.module_from_spec(spec);spec.loader.exec_module(inbox)
if operation == 'messageState':
path = value.get('path', '')
- if not isinstance(path,str) or not re.fullmatch(r'/ril_0/message[0-9a-fA-F_]+',path):
+ if not isinstance(path,str) or not re.fullmatch(r'/ril_0/message[0-9a-f_]+',path):
raise ValueError('INVALID_SMS_PATH')
state = inbox.outgoing_state(path)
if state is None:
@@ -129,100 +119,9 @@ def request(value):
raise ValueError('NOT_SUPPORTED')
-def send_response(conn, response):
- try:
- conn.sendall((json.dumps(response, separators=(',', ':')) + '\n').encode())
- except OSError:
- # Closing an app must not bring down the broker or retry an operation.
- pass
-
-
-def error_response(error):
- # TimeoutExpired includes command arguments, potentially an SMS body.
- reason = 'RADIO_TIMEOUT' if isinstance(error, subprocess.TimeoutExpired) else (
- 'RADIO_ERROR' if isinstance(error, OSError) else str(error) or 'RADIO_ERROR')
- return {'ok': False, 'error': reason}
-
-
-def execute_request(conn, value):
- try:
- response = {'ok': True, 'result': request(value)}
- except (ValueError, RuntimeError, OSError, subprocess.TimeoutExpired) as error:
- response = error_response(error)
- send_response(conn, response)
-
-
-class SettingsWorker:
- """Serialize Settings transactions without blocking the voice/SMS broker."""
- def __init__(self):
- self.pool = ThreadPoolExecutor(max_workers=1, thread_name_prefix='settings')
- self.slots = threading.BoundedSemaphore(8)
-
- def submit(self, conn, value):
- if not self.slots.acquire(blocking=False):
- return False
- try:
- self.pool.submit(self.run, conn, value, time.monotonic())
- except RuntimeError:
- self.slots.release()
- return False
- return True
-
- def run(self, conn, value, received):
- try:
- with conn:
- # Do not apply a stale preference after its queued client has
- # timed out. An already-started APN rollback still runs to completion.
- if time.monotonic() - received > 15:
- send_response(conn, {'ok':False, 'error':'RADIO_BUSY'})
- else:
- execute_request(conn, value)
- finally:
- self.slots.release()
-
- def close(self):
- self.pool.shutdown(wait=True)
-
-
-def handle_connection(conn, settings_worker=None):
- conn.settimeout(20)
- line = b''
- try:
- while not line.endswith(b'\n') and len(line) <= 8192:
- chunk = conn.recv(min(1024, 8193 - len(line)))
- if not chunk:
- return False
- line += chunk
- if len(line) > 8192:
- raise ValueError('INVALID_REQUEST')
- value = json.loads(line.decode())
- except (socket.timeout, ConnectionError):
- return False
- except (ValueError, OSError) as error:
- send_response(conn, error_response(error))
- return False
- if settings_worker and isinstance(value, dict) and value.get('operation') in ('mobileSettings','deviceSettings'):
- if settings_worker.submit(conn, value):
- return True # The worker now owns and closes this connection.
- send_response(conn, {'ok':False, 'error':'RADIO_BUSY'})
- else:
- execute_request(conn, value)
- return False
-
-
-def network_clock_loop(stop, worker):
- # Use the same executor as manual date/time writes: disabling automatic
- # time cannot race a delayed network update. Voice/SMS keep their own loop.
- spec = importlib.util.spec_from_file_location('clock_settings', Path(__file__).with_name('mobile-settings.py'))
- module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
- def tick():
- try:
- module.sync_network_clock()
- except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired):
- pass # No SIM or no NITZ yet; retry after registration.
- while not stop.is_set():
- worker.pool.submit(tick)
- stop.wait(60)
+def reply(stream, value):
+ stream.write((json.dumps(value, separators=(',', ':')) + '\n').encode())
+ stream.flush()
def serve():
@@ -240,28 +139,27 @@ def serve():
except (KeyError, OSError):
pass
server.listen(8)
- worker = SettingsWorker()
- clock_stop = threading.Event()
- clock_thread = threading.Thread(target=network_clock_loop, args=(clock_stop, worker), daemon=True)
- clock_thread.start()
- try:
- while True:
- conn, _ = server.accept()
- if not handle_connection(conn, worker):
- conn.close()
- finally:
- clock_stop.set()
- clock_thread.join()
- worker.close()
+ while True:
+ conn, _ = server.accept()
+ with conn:
+ conn.settimeout(20)
+ line = b''
+ while not line.endswith(b'\n') and len(line) < 8192:
+ chunk = conn.recv(1024)
+ if not chunk:
+ break
+ line += chunk
+ try:
+ result = request(json.loads(line.decode()))
+ reply(conn.makefile('wb'), {'ok': True, 'result': result})
+ except (ValueError, RuntimeError, OSError, subprocess.TimeoutExpired) as error:
+ reply(conn.makefile('wb'), {'ok': False, 'error': str(error) or 'RADIO_ERROR'})
def client(envelope=False):
raw = sys.stdin.read(8192)
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
- value = json.loads(raw)
- # Seven bounded writes plus their rollback/readback can exceed 20 s.
- slow_settings = isinstance(value, dict) and value.get('operation') == 'mobileSettings' and value.get('action') in ('editApn','calls','setCallWaiting','setCallerId','forwarding','setForwarding')
- sock.settimeout(100 if slow_settings else 20)
+ sock.settimeout(20)
sock.connect(str(SOCKET))
sock.sendall(raw.encode() + (b'' if raw.endswith('\n') else b'\n'))
data = b''
diff --git a/tundra/runtime/runtime-packages.py b/tundra/runtime/runtime-packages.py
deleted file mode 100644
index 738850cb..00000000
--- a/tundra/runtime/runtime-packages.py
+++ /dev/null
@@ -1,107 +0,0 @@
-#!/usr/bin/env python3
-# SPDX-License-Identifier: MPL-2.0
-"""Stage pinned package payloads missing from the original ARM64 reference."""
-import hashlib
-import io
-import json
-from pathlib import Path
-import shutil
-import subprocess
-import tarfile
-import tempfile
-import urllib.request
-
-PACKAGES = (
- ('pulseaudio-module-bluetooth_14.2-2+droidian4+git20250504201345.dc4fdb8.next.production_arm64.deb',
- 'https://production.repo.droidian.org/pool/main/p/pulseaudio',
- 'f0a6fa15835f1e22a5708dc46356bd30bb492d57655f769aaf4df78b6838f4f3'),
- ('libsbc1_2.1-1+b2_arm64.deb', 's/sbc',
- '62dd7210ff6500e3ee4976d865826d603127787f588c4c4a8f93556104c95634'),
- ('libdw1t64_0.196-1_arm64.deb', 'e/elfutils', 'a82634454de2b28d73374f693e4ab6c1c2634fa53b0139312c5f439c294dad2b'),
- ('libelf1t64_0.196-1_arm64.deb', 'e/elfutils', 'ead8a1244824d982ce51fb82be4904312d5dc44ddc157e589def58c257738476'),
- ('bluez_5.87-4_arm64.deb', 'b/bluez', 'fe733034dbdfa6146fc94112ad8ca0fce71d8e01d22a59ddb49ef2df3777d6b5'),
- ('ca-certificates_20260816_all.deb', 'c/ca-certificates', '1cb8b73c74c47677b329fad4095fb9370227d4b8f1969372411478a4586c46cf'),
- ('mobile-broadband-provider-info_20251101-1_all.deb', 'm/mobile-broadband-provider-info', 'c34e6bad952a320078fa91faf29a6c754d57963e7280998a01ccb058e60b87cc'),
- ('audiosystem-passthrough_1.3.0-1+droidian1+git20240519112654.f49813c.next.production_arm64.deb',
- 'https://production.repo.droidian.org/pool/main/a/audiosystem-passthrough',
- 'bb4dc41ee66b3b00e8fbd3e0bb8559ab0b98675836219efef7b02958c47b815d'),
-)
-
-
-# Existing libelf 0.194-4 files in the inventoried runtime; reject other replacements.
-REPLACES = {'usr/lib/aarch64-linux-gnu/libelf-0.194.so': 'ade23632ea253cb4ecfe95d5436bfeb3d40ae2003c5fe10bf1ac1c5b30bdf2e2', 'usr/share/doc/libelf1t64/changelog.Debian.gz': 'ffb0ea69fdbf6f32053059985048676931fd7d5c4458f8ca4bc54750cf4b1b31', 'usr/share/doc/libelf1t64/changelog.gz': '2e8e0af5c39ab744ac6934fe07419b5fd5504a50cd2f42110dd04315c548a708', 'usr/share/doc/libelf1t64/copyright': '2409860c86675ab33f8ad22480608faf849cbc2559d6478f708c23c508a1dbf2', 'usr/share/locale/de/LC_MESSAGES/elfutils.mo': 'f72328e130e63f231acb8e1a56ce5f4f3e91d0fc290db95d7ba4e4b2448d4d93', 'usr/share/locale/en@boldquot/LC_MESSAGES/elfutils.mo': '90158a24e3f85aaacc64085ef2644ba19075cb847c754341c9f245aaa69e94c6', 'usr/share/locale/en@quot/LC_MESSAGES/elfutils.mo': '33c6cda39c2145463dfdb2eb849922dbf6ccf51a9e4bfa8fd757744712d9abfa', 'usr/share/locale/es/LC_MESSAGES/elfutils.mo': '1ebff7e5583759b3f08eb5caaf18dd1a6ed6eb5ec71a02502ed8e21b134ad54f', 'usr/share/locale/ja/LC_MESSAGES/elfutils.mo': '764ed859f3aebd861da0b73c69da4d72d0f252952bb3be1c9c66360258a1661c', 'usr/share/locale/pl/LC_MESSAGES/elfutils.mo': '8abb000f43024994a9ec3032b52eb10858e004b81bc61c7ceea96f7467d13fdd', 'usr/share/locale/uk/LC_MESSAGES/elfutils.mo': 'ce0d747f478ee91a1cdef66bfd1e230a3ff1e787358f4c6e1ffb6513df713117'}
-
-def stage(root, cache=None):
- cache = cache or Path(__file__).resolve().parents[1]/'downloads/native-packages-20261008'
- cache.mkdir(parents=True, exist_ok=True)
- records = []
- for name, directory, sha in PACKAGES:
- base = directory if directory.startswith('https://') else 'https://deb.debian.org/debian/pool/main/'+directory
- url = base+'/'+name
- package = cache/name
- if not package.exists():
- with urllib.request.urlopen(url, timeout=60) as response:
- data = response.read()
- if hashlib.sha256(data).hexdigest() != sha:
- raise ValueError('Package checksum mismatch: '+name)
- package.write_bytes(data)
- if hashlib.sha256(package.read_bytes()).hexdigest() != sha:
- raise ValueError('Package checksum mismatch: '+name)
- members = subprocess.check_output(['ar', 't', str(package)], text=True).splitlines()
- member = next(m for m in members if m.startswith('data.tar'))
- payload = subprocess.check_output(['ar', 'p', str(package), member])
- with tempfile.TemporaryDirectory() as folder:
- source = Path(folder)
- with tarfile.open(fileobj=io.BytesIO(payload)) as archive:
- archive.extractall(source, filter='data')
- if name.startswith('ca-certificates'):
- certificates = sorted((source/'usr/share/ca-certificates/mozilla').glob('*.crt'))
- if len(certificates) < 50:
- raise ValueError('Incomplete CA bundle')
- target = root/'etc/ssl/certs/ca-certificates.crt'
- target.parent.mkdir(parents=True, exist_ok=True)
- target.write_bytes(b'\n'.join(p.read_bytes() for p in certificates)+b'\n')
- target.chmod(0o644)
- cert_link = root/'usr/lib/ssl/cert.pem'
- cert_link.parent.mkdir(parents=True, exist_ok=True)
- if not cert_link.exists() and not cert_link.is_symlink():
- cert_link.symlink_to('../../../etc/ssl/certs/ca-certificates.crt')
- selected = [source/'usr/share/doc/ca-certificates/copyright']
- else:
- selected = [p for p in (source/'usr').rglob('*') if p.is_file() or p.is_symlink()]
- # The packet-capture utility is not used by the runtime. Keep it in
- # the pinned package cache rather than enlarging the boot ramdisk.
- excluded = ['usr/bin/btmon'] if name.startswith('bluez_') else []
- selected = [p for p in selected if str(p.relative_to(source)) not in excluded]
- for path in selected:
- target = root/path.relative_to(source)
- target.parent.mkdir(parents=True, exist_ok=True)
- if path.is_symlink():
- if target.exists() or target.is_symlink():
- if not target.is_symlink():
- raise ValueError('Conflicting package link: '+str(target))
- # The reference runtime ships the compatibility link
- # for libelf 0.194. The pinned 0.196 package keeps the
- # same ABI name but points it at the newer SONAME.
- # Replace that one known compatibility link while
- # retaining the strict conflict checks for everything
- # else.
- if target.readlink() != path.readlink():
- if str(target.relative_to(root)) != 'usr/lib/aarch64-linux-gnu/libelf.so.1':
- raise ValueError('Conflicting package link: '+str(target))
- target.unlink()
- target.symlink_to(path.readlink())
- else:
- target.symlink_to(path.readlink())
- else:
- if target.exists() and target.read_bytes() != path.read_bytes():
- expected = REPLACES.get(str(path.relative_to(source)))
- if not expected or hashlib.sha256(target.read_bytes()).hexdigest() != expected:
- raise ValueError('Conflicting package file: '+str(target))
- shutil.copyfile(path, target)
- target.chmod(path.stat().st_mode & 0o777)
- records.append({'file': name, 'url': url, 'sha256': sha, 'excluded': excluded})
- record = root/'usr/share/vulpes/runtime-packages.json'
- record.parent.mkdir(parents=True, exist_ok=True)
- record.write_text(json.dumps(records, indent=2)+'\n')
- return records
diff --git a/tundra/runtime/setup-hal.py b/tundra/runtime/setup-hal.py
index cd930b79..7ee84bb2 100644
--- a/tundra/runtime/setup-hal.py
+++ b/tundra/runtime/setup-hal.py
@@ -53,19 +53,7 @@ def main():
with image.open('rb') as f:sha=hashlib.file_digest(f,'sha256').hexdigest()
if sha!='132d0e18c6a6a111293447e940267f35aa2b14fa00a872ef160b20dc2014d886':raise RuntimeError('Android reference changed')
run('mount','-t','ext4','-o','loop,ro,noload',str(image),'/run/tundra/android-original')
- hardware = Path('/etc/tundra/hardware-images.json')
- bundled = json.loads(hardware.read_text())['images'] if hardware.exists() else None
- if bundled:
- if set(bundled) != {'vendor.img', 'modem.img'}:
- raise RuntimeError('Expected both standalone hardware images')
- for name, record in bundled.items():
- path = Path('/reference')/name
- with path.open('rb') as source:
- if path.stat().st_size != record['bytes'] or hashlib.file_digest(source, 'sha256').hexdigest() != record['sha256']:
- raise RuntimeError('Standalone hardware image changed: '+name)
- run('mount','-t','ext4','-o','loop,ro,noload','/reference/vendor.img','/run/tundra/vendor-original')
- else:
- run('mount','-t','ext4','-o','ro,noload','/dev/mmcblk0p70','/run/tundra/vendor-original')
+ run('mount','-t','ext4','-o','ro,noload','/dev/mmcblk0p70','/run/tundra/vendor-original')
run('mount','-t','ext4','-o','ro,noload','/dev/mmcblk0p48','/mnt/vendor/persist')
overlay('/run/tundra/android-original','/android','android')
overlay('/run/tundra/vendor-original','/android/vendor','vendor')
@@ -77,19 +65,12 @@ def main():
if count != 1:raise RuntimeError('Expected exactly one Sargo WLAN IPA setting')
config.write_text(text) # vendor overlay upper is tmpfs, never the partition
directory('/android/vendor/firmware_mnt')
- if bundled:
- run('mount','-t','vfat','-o','loop,ro','/reference/modem.img','/android/vendor/firmware_mnt')
- else:
- run('mount','-t','vfat','-o','ro','/dev/mmcblk0p21','/android/vendor/firmware_mnt')
+ run('mount','-t','vfat','-o','ro','/dev/mmcblk0p21','/android/vendor/firmware_mnt')
report={}
for p in sorted(Path('/android').rglob('*.rc')):
if not p.is_file() or p.is_symlink() or not is_init_script(p):continue
text=p.read_text();filtered,record=filter_rc(text);record['sourceSha256']=hashlib.sha256(text.encode()).hexdigest()
p.write_text(filtered);report[str(p)]=record
- if Path('/etc/tundra/hardware-enabled').is_file():
- # The stock boot-completed trigger never runs without Android framework.
- with Path('/android/init.rc').open('a') as init:
- init.write('\non boot\n start ipastart_sh\n')
(Path('/run/tundra')/'hal-policy-report.json').write_text(json.dumps(report,indent=2))
# The container cannot open block devices. Its data and metadata live in RAM.
for p in ('/android/data','/android/metadata'):
diff --git a/tundra/runtime/wifi-control.py b/tundra/runtime/wifi-control.py
index e8d46ec2..8fdb19d0 100644
--- a/tundra/runtime/wifi-control.py
+++ b/tundra/runtime/wifi-control.py
@@ -1,8 +1,6 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Narrow native Wi-Fi bridge. Credentials travel over a private Unix socket."""
-from concurrent.futures import ThreadPoolExecutor
-import threading
import argparse
import configparser
import json
@@ -19,7 +17,6 @@ SOCKET = '/run/tundra-wifi/control.sock'
MAX_REQUEST = 16384
DEVICE = 'wlan0'
PROFILE_DIR = Path('/etc/NetworkManager/system-connections')
-MUTATION_LOCK = threading.Lock()
class WifiError(Exception):
@@ -85,29 +82,6 @@ def networks(rescan=False):
return sorted(found, key=lambda row: -row['signal'])[:128]
-def connection_info():
- info = {}
- address = nm('-g','IP4.ADDRESS','device','show',DEVICE,timeout=5)
- if address.returncode == 0:
- import ipaddress
- for line in address.stdout.splitlines():
- try:info['ipAddress'] = str(ipaddress.ip_interface(line).ip);break
- except ValueError:continue
- device = nm('-g','GENERAL.DBUS-PATH','device','show',DEVICE,timeout=5)
- path=device.stdout.strip() if device.returncode==0 else ''
- if re.fullmatch(r'/org/freedesktop/NetworkManager/Devices/[0-9]+',path):
- speed=subprocess.run(['/usr/bin/busctl','--system','--timeout=2','--json=short','get-property',
- 'org.freedesktop.NetworkManager',path,'org.freedesktop.NetworkManager.Device.Wireless','Bitrate'],
- capture_output=True,text=True,timeout=3)
- if speed.returncode==0:
- try:
- kbps=json.loads(speed.stdout)['data']
- if isinstance(kbps,list):kbps=kbps[0]
- if type(kbps) is int and kbps>0:info['linkSpeed']=kbps/1000
- except (ValueError,KeyError,TypeError,IndexError):pass
- return info
-
-
def saved_profiles(profile_dir):
result = []
for path in profile_dir.glob('vulpes-*.nmconnection'):
@@ -157,89 +131,14 @@ def profile_text(network, password, uid):
'cloned-mac-address=permanent\nssid=' +
''.join(str(b) + ';' for b in ssid.encode()) +
'\n[ipv4]\nmethod=auto\n[ipv6]\nmethod=auto\n')
- if network.get('hidden'): text = text.replace('[wifi]\n','[wifi]\nhidden=true\n')
if mode != 'open':
escaped = password.replace('\\', '\\\\').replace(' ', '\\s')
text += f'[wifi-security]\nkey-mgmt={mode}\npsk={escaped}\n'
return text
-HOTSPOT_UUID = '5e962c71-d3d0-4c39-b4de-460bb63fc045'
-HOTSPOT_RETURN = Path('/run/tundra-wifi/hotspot-return.json')
-
-
-def active_wifi_uuid():
- result=nm('-g','GENERAL.CON-UUID','device','show',DEVICE,timeout=5)
- value=result.stdout.strip() if result.returncode==0 else ''
- try:return str(uuid.UUID(value))
- except ValueError:return None
-
-
-def hotspot_state(profile_dir):
- path=profile_dir/'vulpes-hotspot.nmconnection'
- result={'enabled':active_wifi_uuid()==HOTSPOT_UUID,'configured':False,'ssid':'Vulpes OS','security':'wpa2-psk','password':''}
- if path.exists() and not path.is_symlink():
- config=configparser.ConfigParser(interpolation=None);config.read(path)
- result.update(configured=True,ssid=bytes(int(b) for b in config['wifi']['ssid'].split(';') if b).decode(),
- security='wpa2-psk' if config.has_section('wifi-security') else 'open',
- password=re.sub(r'\\([\\s])',lambda match:' ' if match[1]=='s' else '\\',config.get('wifi-security','psk',fallback='')))
- return result
-
-
-def hotspot(request, profile_dir):
- action=request.get('operation')
- allowed={'hotspotStatus':set(),'hotspotSave':{'ssid','security','password'},'hotspotPower':{'enabled'}}
- if action not in allowed or set(request)-{'operation'}-allowed[action]:raise WifiError('WIFI_INVALID_REQUEST')
- if action=='hotspotStatus':return hotspot_state(profile_dir)
- target=profile_dir/'vulpes-hotspot.nmconnection'
- if action=='hotspotSave':
- ssid=request.get('ssid');security=request.get('security');password=request.get('password')
- if not isinstance(ssid,str) or not 1<=len(ssid.encode())<=32 or any(ord(c)<32 for c in ssid):raise WifiError('WIFI_INVALID_REQUEST')
- if security not in ('open','wpa2-psk') or not isinstance(password,str):raise WifiError('WIFI_INVALID_REQUEST')
- if any(ord(c)<32 for c in password) or (security=='wpa2-psk' and not 8<=len(password.encode())<=63):raise WifiError('WIFI_INVALID_PASSWORD')
- if active_wifi_uuid()==HOTSPOT_UUID:raise WifiError('HOTSPOT_IN_USE')
- previous=target.read_text() if target.exists() and not target.is_symlink() else None
- value=profile_text({'ssid':ssid,'security':'wpa-psk' if security=='wpa2-psk' else 'open'},password,HOTSPOT_UUID)
- value=value.replace('id=Vulpes Wi-Fi','id=Vulpes hotspot',1).replace('autoconnect=true','autoconnect=false',1).replace('mode=infrastructure','mode=ap\nband=bg',1).replace('[ipv4]\nmethod=auto','[ipv4]\nmethod=shared',1).replace('[ipv6]\nmethod=auto','[ipv6]\nmethod=disabled',1)
- if security=='wpa2-psk':value=value.replace('[wifi-security]\n','[wifi-security]\nproto=rsn\n')
- profile_dir.mkdir(mode=0o700,parents=True,exist_ok=True);private_write(target,value)
- if nm('connection','load',str(target)).returncode:
- if previous is not None:private_write(target,previous);nm('connection','load',str(target))
- else:
- nm('connection','delete','uuid',HOTSPOT_UUID)
- target.unlink(missing_ok=True)
- raise WifiError('HOTSPOT_SAVE_FAILED')
- return hotspot_state(profile_dir)
- enabled=request.get('enabled')
- if type(enabled) is not bool:raise WifiError('WIFI_INVALID_REQUEST')
- current=active_wifi_uuid()
- if enabled:
- if not target.is_file() or target.is_symlink():raise WifiError('HOTSPOT_NOT_CONFIGURED')
- if current==HOTSPOT_UUID:return hotspot_state(profile_dir)
- HOTSPOT_RETURN.parent.mkdir(mode=0o700,parents=True,exist_ok=True)
- private_write(HOTSPOT_RETURN,json.dumps({'uuid':current}))
- if nm('connection','up','uuid',HOTSPOT_UUID).returncode or active_wifi_uuid()!=HOTSPOT_UUID:
- nm('connection','down','uuid',HOTSPOT_UUID)
- if current:nm('connection','up','uuid',current)
- HOTSPOT_RETURN.unlink(missing_ok=True)
- raise WifiError('HOTSPOT_START_FAILED')
- elif current==HOTSPOT_UUID:
- if nm('connection','down','uuid',HOTSPOT_UUID).returncode:raise WifiError('HOTSPOT_STOP_FAILED')
- previous=None
- try:previous=str(uuid.UUID(json.loads(HOTSPOT_RETURN.read_text()).get('uuid')))
- except (OSError,ValueError,TypeError,AttributeError):pass
- HOTSPOT_RETURN.unlink(missing_ok=True)
- if previous and previous!=HOTSPOT_UUID:
- if nm('connection','up','uuid',previous).returncode:raise WifiError('WIFI_RECONNECT_FAILED')
- result=hotspot_state(profile_dir)
- if result['enabled']!=enabled:raise WifiError('HOTSPOT_STATE_MISMATCH')
- return result
-
-
def dispatch(request, profile_dir=PROFILE_DIR):
- if isinstance(request,dict) and request.get('operation') in ('hotspotStatus','hotspotSave','hotspotPower'):
- return hotspot(request,profile_dir)
- if not isinstance(request, dict) or set(request) - {'operation', 'id', 'password', 'enabled', 'ssid', 'security'}:
+ if not isinstance(request, dict) or set(request) - {'operation', 'id', 'password', 'enabled'}:
raise WifiError('WIFI_INVALID_REQUEST')
operation = request.get('operation')
saved = saved_profiles(profile_dir)
@@ -269,25 +168,15 @@ def dispatch(request, profile_dir=PROFILE_DIR):
for network in found:
stored = next((row for row in saved if row['ssid'] == network['ssid'] and row['security'] == network['security']), None)
network.update(known=stored is not None, savedId=stored['id'] if stored else None)
- info={}
- if any(n['connected'] for n in found):
- try:info=connection_info()
- except (WifiError,OSError,subprocess.TimeoutExpired):pass
- return {'enabled': enabled, 'networks': found, 'connectionInformation':info}
+ return {'enabled': enabled, 'networks': found}
if operation == 'disconnect':
if nm('device', 'disconnect', DEVICE).returncode:
raise WifiError('WIFI_DISCONNECT_FAILED')
return {'disconnected': True}
- if operation not in ('connect','connectHidden'):
+ if operation != 'connect':
raise WifiError('WIFI_INVALID_REQUEST')
# Resolve an observed AP; clients cannot supply nmcli arguments or arbitrary SSIDs.
- if operation == 'connectHidden':
- ssid=request.get('ssid');security=request.get('security')
- if not isinstance(ssid,str) or not 1<=len(ssid.encode())<=32 or security not in ('open','wpa-psk','sae'):
- raise WifiError('WIFI_INVALID_REQUEST')
- network={'ssid':ssid,'security':security,'hidden':True}
- else:
- network = next((row for row in networks() if row['id'] == request.get('id')), None)
+ network = next((row for row in networks() if row['id'] == request.get('id')), None)
if network is None:
raise WifiError('WIFI_NETWORK_GONE')
stored = next((row for row in saved if row['ssid'] == network['ssid'] and row['security'] == network['security']), None)
@@ -295,8 +184,6 @@ def dispatch(request, profile_dir=PROFILE_DIR):
target = profile_dir / ('vulpes-' + uid + '.nmconnection')
previous = target.read_text() if stored else None
text = previous if stored and not request.get('password') else profile_text(network, request.get('password', ''), uid)
- if network.get('hidden') and 'hidden=true' not in text:
- text = text.replace('[wifi]\n', '[wifi]\nhidden=true\n')
profile_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
target = profile_dir / ('vulpes-' + uid + '.nmconnection')
connected = False
@@ -339,12 +226,7 @@ def handle(stream):
_, uid, _ = struct.unpack('3i', stream.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12))
if uid not in (0, 32011):
raise WifiError('PERMISSION_DENIED')
- request = receive(stream, MAX_REQUEST)
- if isinstance(request,dict) and request.get('operation') in ('connect','connectHidden','forget','disconnect','setEnabled','hotspotSave','hotspotPower'):
- with MUTATION_LOCK:
- result = {'result': dispatch(request)}
- else:
- result = {'result': dispatch(request)}
+ result = {'result': dispatch(receive(stream, MAX_REQUEST))}
except WifiError as error:
result = {'error': str(error)}
except Exception:
@@ -365,8 +247,7 @@ def main():
if len(data) > MAX_REQUEST:
raise WifiError('WIFI_INVALID_REQUEST')
with socket.socket(socket.AF_UNIX) as stream:
- value=json.loads(data)
- stream.settimeout(180 if isinstance(value,dict) and value.get('operation')=='hotspotPower' else 90)
+ stream.settimeout(90)
stream.connect(SOCKET)
stream.sendall(json.dumps(json.loads(data)).encode() + b'\n')
result = receive(stream, 65536)
@@ -384,18 +265,10 @@ def main():
os.chown(SOCKET, 0, 32011)
path.chmod(0o660)
server.listen(4)
- # Reading saved profiles must not wait behind a radio scan or connection.
- # Bound both workers and queued sockets; mutations remain serialized.
- slots = threading.BoundedSemaphore(8)
- def serve_client(stream):
- try:
- with stream: handle(stream)
- finally: slots.release()
- with ThreadPoolExecutor(max_workers=4) as workers:
- while True:
- stream, _ = server.accept()
- if slots.acquire(blocking=False): workers.submit(serve_client,stream)
- else: stream.close()
+ while True:
+ stream, _ = server.accept()
+ with stream:
+ handle(stream)
if __name__ == '__main__':
diff --git a/tundra/tests/test_bluetooth_control.py b/tundra/tests/test_bluetooth_control.py
deleted file mode 100644
index dc95c953..00000000
--- a/tundra/tests/test_bluetooth_control.py
+++ /dev/null
@@ -1,22 +0,0 @@
-import importlib.util
-from pathlib import Path
-import unittest
-
-spec=importlib.util.spec_from_file_location('bluetooth',Path(__file__).resolve().parents[1]/'runtime/bluetooth-control.py')
-bt=importlib.util.module_from_spec(spec);spec.loader.exec_module(bt)
-
-class Bluetooth(unittest.TestCase):
- def test_reject_arbitrary_path_command_and_non_boolean_power(self):
- for value in [dict(operation='exec',command='id'),dict(operation='pair',address='/org/bluez/hci0'),
- dict(operation='power',enabled='false'),dict(operation='rename',name='\nName')]:
- with self.assertRaises(ValueError):bt.validate(dict(version=1,**value))
- def test_valid_pair_and_name(self):
- self.assertEqual(bt.validate(dict(version=1,operation='pair',address='AA:BB:CC:DD:EE:FF')),'pair')
- self.assertEqual(bt.validate(dict(version=1,operation='rename',name='Vulpes OS')),'rename')
- def test_cod_for_gaia_and_no_manufactured_connection(self):
- value=bt.device_record(dict(Address='AA:BB:CC:DD:EE:FF',Class=0x240404,Paired=True))
- self.assertEqual(value['cod']['majorDeviceClass'],4)
- self.assertEqual(value['cod']['minorDeviceClass'],1)
- self.assertTrue(value['paired']);self.assertFalse(value['connected'])
-
-if __name__=='__main__':unittest.main()
diff --git a/tundra/tests/test_build_sources.py b/tundra/tests/test_build_sources.py
index aeb30df7..7bd889fb 100644
--- a/tundra/tests/test_build_sources.py
+++ b/tundra/tests/test_build_sources.py
@@ -1,11 +1,9 @@
# SPDX-License-Identifier: MPL-2.0
import importlib.util
-import json
from pathlib import Path
import sys
import tempfile
import unittest
-from unittest.mock import patch
ROOT=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(ROOT/'tools'))
@@ -34,13 +32,5 @@ class SourceSnapshotTests(unittest.TestCase):
self.build['sources']={'../../outside':'hash'}
with self.assertRaises(ValueError):rescue.verify_sources(self.folder,self.build)
- def test_workspace_required_before_any_device_command(self):
- (self.folder/'build.json').write_text(json.dumps({'image':'boot.img','deviceWorkspaceSha256':'a'*64}))
- with patch.object(sys,'argv',['test-rescue-boot','--build',str(self.folder),'--boot']), \
- patch.object(rescue.subprocess,'run') as run:
- with self.assertRaisesRegex(ValueError,'requires --device-workspace'):
- rescue.main()
- run.assert_not_called()
-
if __name__=='__main__':unittest.main()
diff --git a/tundra/tests/test_device_workspace.py b/tundra/tests/test_device_workspace.py
deleted file mode 100644
index 8be214c5..00000000
--- a/tundra/tests/test_device_workspace.py
+++ /dev/null
@@ -1,113 +0,0 @@
-# SPDX-License-Identifier: MPL-2.0
-import hashlib
-import importlib.util
-import json
-from pathlib import Path
-import struct
-import sys
-import tempfile
-import unittest
-from unittest.mock import patch
-
-TOOLS = Path(__file__).resolve().parents[1]/'tools'
-sys.path.insert(0, str(TOOLS))
-import boot_backup
-import sargo_workspace as workspace
-
-
-def load(name):
- spec = importlib.util.spec_from_file_location(name, TOOLS/(name+'.py'))
- value = importlib.util.module_from_spec(spec); spec.loader.exec_module(value)
- return value
-
-
-prepare = load('prepare-sargo-device')
-collect = load('collect-sargo-installation')
-
-
-class WorkspaceTests(unittest.TestCase):
- def setUp(self):
- temp = tempfile.TemporaryDirectory(); self.addCleanup(temp.cleanup)
- self.root = Path(temp.name)
- self.source = self.root/'inventory'; (self.source/'backup').mkdir(parents=True)
- sizes = {name: 16384 for name in boot_backup.SIZES}
- patcher = patch.object(boot_backup, 'SIZES', sizes); patcher.start(); self.addCleanup(patcher.stop)
- kernel = b'test kernel'
- patcher = patch.object(workspace, 'KERNEL_SHA256', hashlib.sha256(kernel).hexdigest())
- patcher.start(); self.addCleanup(patcher.stop)
- boot = bytearray(16384); boot[:8] = b'ANDROID!'
- struct.pack_into('<10I', boot, 8, len(kernel), 0x8000, 1, 0x1000000, 0, 0, 0x100, 4096, 0, 0)
- boot[4096:4096+len(kernel)] = kernel
- manifest = {'device': 'sargo', 'rawPartitions': True, 'serialSha256': 'a'*64,
- 'activeSlot': 'a', 'files': {}}
- for name, size in sizes.items():
- path = self.source/'backup'/name; path.write_bytes(boot if name=='boot_a.img' else bytes(size))
- manifest['files'][name] = {'bytes': size, 'sha256': workspace.digest(path)}
- (self.source/'backup/manifest.json').write_text(json.dumps(manifest))
- self.layout = {'schema': 2, 'device': '/dev/mmcblk0p72', 'partitionLabel': 'userdata',
- 'filesystem': 'ext4', 'backingDevice': '259:40',
- 'table': '0 104448000 linear 259:40 329728', 'size': 53648801280,
- 'firstMiBSha256': 'b'*64, 'serialSha256': 'a'*64, 'activeSlot': 'a'}
- self.save_layout()
-
- def save_layout(self):
- (self.source/'layout.json').write_text(json.dumps(self.layout))
-
- def test_private_workspace_is_portable_without_shared_inventory(self):
- output = prepare.prepare(self.source, self.root/'workspace')
- moved = self.root/'moved'; output.rename(moved)
- plan, layout = workspace.checked_workspace(moved)
- self.assertEqual(plan['backupDirectory'], str(moved/'backup'))
- self.assertEqual(plan['serialSha256'], 'a'*64)
- self.assertEqual(layout, self.layout)
-
- def test_different_phone_storage_is_rejected_before_output(self):
- self.layout['serialSha256'] = 'c'*64; self.save_layout()
- with self.assertRaisesRegex(ValueError, 'same Sargo'):
- prepare.prepare(self.source, self.root/'workspace')
- self.assertFalse((self.root/'workspace').exists())
-
- def test_other_filesystem_and_slot_are_rejected(self):
- for name, value in (('filesystem', 'f2fs'), ('activeSlot', 'b')):
- old = self.layout[name]; self.layout[name] = value; self.save_layout()
- with self.assertRaises(ValueError):
- prepare.prepare(self.source, self.root/'workspace')
- self.layout[name] = old
-
- def test_unknown_kernel_is_rejected(self):
- with patch.object(workspace, 'KERNEL_SHA256', '0'*64):
- with self.assertRaisesRegex(ValueError, 'not been qualified'):
- prepare.prepare(self.source, self.root/'workspace')
-
- def test_corruption_and_missing_workspace_record_rejected(self):
- output = prepare.prepare(self.source, self.root/'workspace')
- (output/'layout.json').write_text('{}')
- with self.assertRaisesRegex(ValueError, 'input changed'):
- workspace.checked_workspace(output)
-
- def test_existing_and_incomplete_directories_rejected(self):
- with self.assertRaisesRegex(ValueError, 'exists'):
- prepare.prepare(self.source, self.source)
- (self.source/'.incomplete').touch()
- with self.assertRaisesRegex(ValueError, 'did not finish'):
- prepare.prepare(self.source, self.root/'workspace')
-
- def test_collector_does_not_expose_serial_in_identity(self):
- proc = self.root/'proc'; (proc/'device-tree').mkdir(parents=True)
- (proc/'device-tree/compatible').write_bytes(b'google,b4s4-sdm670\0')
- (proc/'device-tree/model').write_text('Google Inc. MSM sdm670 S4 PVT v1.0\0')
- (proc/'cmdline').write_text('androidboot.serialno=PRIVATE androidboot.slot_suffix=_a')
- result = collect.identity(proc)
- self.assertEqual(result['serialSha256'], hashlib.sha256(b'PRIVATE').hexdigest())
- self.assertNotIn('PRIVATE', json.dumps(result))
- (proc/'cmdline').write_text('androidboot.serialno=PRIVATE androidboot.slot_suffix=_b')
- with self.assertRaises(ValueError):
- collect.identity(proc)
- (proc/'cmdline').write_text('androidboot.serialno=PRIVATE androidboot.slot_suffix=_a')
- (proc/'device-tree/model').write_text('Google Inc. MSM sdm670 B4 PVT v1.0\0')
- with self.assertRaisesRegex(ValueError, 'device tree'):
- collect.identity(proc)
-
-
-if __name__ == '__main__':
- unittest.main()
diff --git a/tundra/tests/test_distribution_package.py b/tundra/tests/test_distribution_package.py
deleted file mode 100644
index 7ea747a1..00000000
--- a/tundra/tests/test_distribution_package.py
+++ /dev/null
@@ -1,110 +0,0 @@
-# SPDX-License-Identifier: MPL-2.0
-import hashlib
-import importlib.util
-import json
-import lzma
-from pathlib import Path
-import sys
-import tempfile
-import unittest
-from unittest.mock import patch
-
-TOOLS = Path(__file__).resolve().parents[1]/'tools'
-sys.path.insert(0, str(TOOLS))
-spec = importlib.util.spec_from_file_location('distribution', TOOLS/'prepare-sargo-package.py')
-package = importlib.util.module_from_spec(spec)
-spec.loader.exec_module(package)
-
-
-class DistributionTests(unittest.TestCase):
- def setUp(self):
- temporary = tempfile.TemporaryDirectory()
- self.addCleanup(temporary.cleanup)
- self.root = Path(temporary.name)
- self.content = b'Tundra'*4096 + bytes(8192)
- self.archive = self.root/'system.ext4.xz'
- self.archive.write_bytes(lzma.compress(self.content))
- self.manifest = self.root/'package.json'
- self.value = {'device': 'sargo', 'version': 'test', 'systemImage': self.archive.name,
- 'uncompressedBytes': len(self.content),
- 'systemImageSha256': hashlib.sha256(self.content).hexdigest(),
- 'filesystemCheckPassed': True, 'compressionCheckPassed': True}
- self.save()
-
- def save(self):
- self.value['archiveBytes'] = self.archive.stat().st_size
- self.value['archiveSha256'] = package.digest(self.archive)
- self.manifest.write_text(json.dumps(self.value))
- self.expected = package.digest(self.manifest)
-
- def test_manifest_and_expanded_image_verified(self):
- value = package.checked_manifest(self.manifest, self.expected)
- archive = package.checked_archive(self.root, value)
- image = self.root/'expanded.ext4'
- package.unpack(archive, image, value)
- self.assertEqual(image.read_bytes(), self.content)
-
- def test_wrong_manifest_or_archive_hash_rejected(self):
- with self.assertRaisesRegex(ValueError, 'manifest checksum'):
- package.checked_manifest(self.manifest, '0'*64)
- self.archive.write_bytes(b'bad')
- with self.assertRaisesRegex(ValueError, 'checksum or size'):
- package.checked_archive(self.root, self.value)
-
- def test_paths_links_and_wrong_device_rejected(self):
- for name in ('../system.ext4.xz', '/tmp/system.ext4.xz', 'boot.img', 'sub/system.ext4.xz'):
- self.value['systemImage'] = name
- self.save()
- with self.assertRaises(ValueError):
- package.checked_manifest(self.manifest, self.expected)
- self.value['systemImage'] = self.archive.name
- self.value['device'] = 'bonito'
- self.save()
- with self.assertRaisesRegex(ValueError, 'Only Sargo'):
- package.checked_manifest(self.manifest, self.expected)
- original = self.archive.read_bytes()
- self.archive.unlink()
- other = self.root/'outside'; other.write_bytes(original)
- self.archive.symlink_to(other)
- with self.assertRaisesRegex(ValueError, 'regular archive'):
- package.checked_archive(self.root, self.value)
-
- def test_truncated_and_concatenated_streams_rejected(self):
- original = self.archive.read_bytes()
- for data in (original[:-4], original+b'extra', original+original):
- self.archive.write_bytes(data)
- self.save()
- target = self.root/'partial.ext4'
- with self.assertRaises((ValueError, lzma.LZMAError)):
- package.unpack(self.archive, target, self.value)
- self.assertFalse(target.exists())
-
- def test_expansion_limit_and_wrong_image_digest_leave_no_partial_image(self):
- for key, value in (('uncompressedBytes', 4096), ('systemImageSha256', '0'*64)):
- bad = dict(self.value, **{key: value})
- target = self.root/'partial.ext4'
- with self.assertRaises(ValueError):
- package.unpack(self.archive, target, bad)
- self.assertFalse(target.exists())
-
- def test_existing_image_and_data_are_never_overwritten(self):
- target = self.root/'existing.ext4'; target.write_bytes(b'existing user data')
- with self.assertRaises(FileExistsError):
- package.unpack(self.archive, target, self.value)
- self.assertEqual(target.read_bytes(), b'existing user data')
- with patch.object(package.subprocess, 'run') as run:
- with self.assertRaisesRegex(ValueError, 'already exists'):
- package.prepare(self.manifest, self.expected, self.root, 1024)
- run.assert_not_called()
-
- def test_verify_only_does_not_run_device_or_filesystem_commands(self):
- with patch.object(sys, 'argv', ['prepare', '--manifest', str(self.manifest),
- '--manifest-sha256', self.expected]), \
- patch.object(package.subprocess, 'run') as run, \
- patch.object(package.os, 'umask'):
- package.main()
- run.assert_not_called()
-
-
-if __name__ == '__main__':
- unittest.main()
diff --git a/tundra/tests/test_hardware_status.py b/tundra/tests/test_hardware_status.py
index 190bcebe..6a5745bc 100644
--- a/tundra/tests/test_hardware_status.py
+++ b/tundra/tests/test_hardware_status.py
@@ -50,11 +50,7 @@ class HardwareTests(unittest.TestCase):
self.assertIn('unmanaged-devices=*,except:interface-name:wlan0', config)
self.assertIn('auth-polkit=root-only', config)
self.assertIn('enabled=false', config)
- connections = root/'etc/NetworkManager/system-connections'
- self.assertTrue(connections.is_dir())
- self.assertEqual(list(connections.iterdir()), [])
- wifi_service = (root/'etc/systemd/system/tundra-wifi.service').read_text()
- self.assertIn('ReadWritePaths=/run /etc/NetworkManager/system-connections', wifi_service)
+ self.assertFalse((root/'etc/NetworkManager/system-connections').exists())
self.assertIn('VULPES_TUNDRA=1', (root/'etc/systemd/system/tundra-display.service').read_text())
diff --git a/tundra/tests/test_hotspot.py b/tundra/tests/test_hotspot.py
deleted file mode 100644
index 2139ca8a..00000000
--- a/tundra/tests/test_hotspot.py
+++ /dev/null
@@ -1,69 +0,0 @@
-# SPDX-License-Identifier: MPL-2.0
-import importlib.util
-from pathlib import Path
-import tempfile
-from types import SimpleNamespace
-import unittest
-from unittest.mock import patch
-spec=importlib.util.spec_from_file_location('wifi',Path(__file__).resolve().parents[1]/'runtime/wifi-control.py')
-wifi=importlib.util.module_from_spec(spec);spec.loader.exec_module(wifi)
-
-class HotspotTests(unittest.TestCase):
- def test_private_profile_roundtrip_and_validation(self):
- with tempfile.TemporaryDirectory() as directory, patch.object(wifi,'active_wifi_uuid',return_value=None), patch.object(wifi,'nm',return_value=SimpleNamespace(returncode=0)) as nm:
- root=Path(directory)
- request={'operation':'hotspotSave','ssid':'Vulpes é','security':'wpa2-psk','password':r' space\s tail '}
- value=wifi.dispatch(request,root)
- self.assertEqual(value['password'],request['password']);self.assertEqual(value['ssid'],request['ssid'])
- path=root/'vulpes-hotspot.nmconnection'
- self.assertEqual(path.stat().st_mode&0o777,0o600)
- self.assertIn('method=shared',path.read_text());self.assertIn('proto=rsn',path.read_text())
- self.assertEqual(wifi.saved_profiles(root),[])
- for invalid in ({'ssid':'x\ninject'},{'password':'short'},{'security':'wep'},{'ssid':'é'*17},{'extra':True}):
- before=path.read_bytes();count=nm.call_count
- with self.assertRaises(wifi.WifiError):wifi.dispatch(request|invalid,root)
- self.assertEqual(path.read_bytes(),before);self.assertEqual(nm.call_count,count)
- with patch.object(wifi,'active_wifi_uuid',return_value=wifi.HOTSPOT_UUID):
- with self.assertRaisesRegex(wifi.WifiError,'HOTSPOT_IN_USE'):wifi.dispatch(request,root)
-
- def test_password_is_not_rewritten_as_profile_configuration(self):
- with tempfile.TemporaryDirectory() as directory, patch.object(wifi,'active_wifi_uuid',return_value=None), patch.object(wifi,'nm',return_value=SimpleNamespace(returncode=0)):
- for password in ['autoconnect=true','mode=infrastructure','id=Vulpes Wi-Fi']:
- result=wifi.dispatch({'operation':'hotspotSave','ssid':'test','security':'wpa2-psk','password':password},Path(directory))
- self.assertEqual(result['password'],password)
-
- def test_save_failure_restores_previous(self):
- with tempfile.TemporaryDirectory() as directory, patch.object(wifi,'active_wifi_uuid',return_value=None):
- root=Path(directory);path=root/'vulpes-hotspot.nmconnection'
- request={'operation':'hotspotSave','ssid':'test','security':'wpa2-psk','password':'testpassword'}
- with patch.object(wifi,'nm',return_value=SimpleNamespace(returncode=1)) as nm:
- with self.assertRaisesRegex(wifi.WifiError,'HOTSPOT_SAVE_FAILED'):wifi.dispatch(request,root)
- self.assertFalse(path.exists());self.assertIn(('connection','delete','uuid',wifi.HOTSPOT_UUID),[c.args for c in nm.call_args_list])
- path.write_text('previous')
- with self.assertRaisesRegex(wifi.WifiError,'HOTSPOT_SAVE_FAILED'):wifi.dispatch(request,root)
- self.assertEqual(path.read_text(),'previous')
-
- def test_power_readback_and_reconnect_after_failure_or_stop(self):
- previous='8a16e3b3-c49b-44ca-9041-c3bac464f748'
- for fail in (False,True):
- active=[previous];commands=[]
- def nm(*args,**kwargs):
- commands.append(args)
- if args[:2]==('connection','up'):
- if fail and args[-1]==wifi.HOTSPOT_UUID:return SimpleNamespace(returncode=1)
- active[0]=args[-1]
- if args[:2]==('connection','down'):active[0]=None
- return SimpleNamespace(returncode=0)
- with tempfile.TemporaryDirectory() as directory, patch.object(wifi,'active_wifi_uuid',side_effect=lambda:active[0]),patch.object(wifi,'nm',side_effect=nm):
- root=Path(directory)
- with patch.object(wifi,'HOTSPOT_RETURN',root/'return.json'):
- wifi.dispatch({'operation':'hotspotSave','ssid':'test','security':'open','password':''},root)
- if fail:
- with self.assertRaisesRegex(wifi.WifiError,'HOTSPOT_START_FAILED'):wifi.dispatch({'operation':'hotspotPower','enabled':True},root)
- else:
- self.assertTrue(wifi.dispatch({'operation':'hotspotPower','enabled':True},root)['enabled'])
- self.assertFalse(wifi.dispatch({'operation':'hotspotPower','enabled':False},root)['enabled'])
- self.assertEqual(active[0],previous);self.assertFalse(wifi.HOTSPOT_RETURN.exists())
- self.assertIn(('connection','up','uuid',previous),commands)
-
-if __name__=='__main__':unittest.main()
diff --git a/tundra/tests/test_mms_control.py b/tundra/tests/test_mms_control.py
deleted file mode 100644
index a6c903fd..00000000
--- a/tundra/tests/test_mms_control.py
+++ /dev/null
@@ -1,69 +0,0 @@
-import base64,contextlib,importlib.util,json,tempfile,unittest,subprocess,sys,shutil
-from pathlib import Path
-from unittest.mock import patch
-ROOT=Path(__file__).resolve().parents[2]
-spec=importlib.util.spec_from_file_location('mms_control',ROOT/'tundra/runtime/mms-control.py');m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m)
-class MMS(unittest.TestCase):
- def setUp(self):
- self.tmp=tempfile.TemporaryDirectory();self.old=m.ROOT;m.ROOT=Path(self.tmp.name)
- def tearDown(self):m.ROOT=self.old;self.tmp.cleanup()
- def test_reject_paths_and_oversize(self):
- value={'recipients':['+15550199'],'parts':[{'id':'../secret','type':'text/plain','data':'AA=='}]}
- with self.assertRaises(ValueError):m.validate_send(value)
- value['parts'][0]['id']='text.txt';value['parts'][0]['data']=base64.b64encode(b'x'*(m.LIMIT+1)).decode()
- with self.assertRaises(ValueError):m.validate_send(value)
- def test_queue_acceptance_is_not_delivery(self):
- with patch.object(m,'codec',return_value={'data':'AA==','transaction':'t'}):
- r=m.request({'version':1,'operation':'send','recipients':['+15550199'],'parts':[{'id':'text.txt','type':'text/plain','data':'AA=='}]})
- self.assertEqual(m.request({'version':1,'operation':'state','path':r['path']})['state'],'pending')
- def test_operator_acceptance_required(self):
- row={'direction':'outgoing','encoded':{'data':'AA==','transaction':'t'}}
- with patch.object(m,'bearer',return_value=contextlib.nullcontext({'url':'http://mmsc.invalid'})),patch.object(m,'transfer',return_value=b'confirmation'),patch.object(m,'codec',return_value={'accepted':False}):
- with self.assertRaisesRegex(ValueError,'MMS_REJECTED'):m.process(row)
- def test_ack_does_not_delete_outgoing_or_unread_queue(self):
- m.save(m.ROOT/'queue.json',[{'id':'o','direction':'outgoing','state':'pending'},{'id':'i','direction':'incoming','state':'received','message':{'receipt':'i'}}])
- m.request({'version':1,'operation':'ack','ids':['o','i']});r=m.records()
- self.assertEqual(r[0]['state'],'pending');self.assertEqual(r[1]['state'],'stored');self.assertNotIn('message',r[1])
- def test_incoming_network_error_keeps_notification_retryable(self):
- m.save(m.ROOT/'queue.json',[{'id':'i','direction':'incoming','state':'pending','notification':{}}])
- with patch.object(m,'process',side_effect=ValueError('MMS_DATA_UNAVAILABLE')):self.assertTrue(m.process_once())
- row=m.records()[0];self.assertEqual(row['state'],'pending');self.assertGreater(row['retryAt'],0)
- self.assertFalse(m.process_once())
- def test_outgoing_transport_error_is_never_retried_automatically(self):
- m.save(m.ROOT/'queue.json',[{'id':'o','direction':'outgoing','state':'pending'}])
- with patch.object(m,'process',side_effect=ValueError('MMS_TRANSPORT_ERROR')):m.process_once()
- self.assertEqual(m.records()[0]['state'],'failed');self.assertFalse(m.process_once())
- def test_retrieval_ack_retry_survives_gaia_acknowledgement(self):
- m.save(m.ROOT/'queue.json',[{'id':'i','direction':'incoming','state':'received','ackPending':True,'notification':{},'message':{}}])
- m.request({'version':1,'operation':'ack','ids':['i']})
- with patch.object(m,'process',return_value={'ackPending':False}):m.process_once()
- row=m.records()[0];self.assertEqual(row['state'],'stored');self.assertFalse(row['ackPending'])
- def test_connection_failure_closes_http_socket(self):
- from unittest.mock import Mock
- sock=Mock();sock.connect.side_effect=OSError('network unavailable')
- with patch.object(m.socket,'socket',return_value=sock):
- with self.assertRaises(OSError):m.transfer({'proxy':True,'url':'http://mmsc.invalid','host':'192.0.2.1','port':8080,'interface':'test0','address':'192.0.2.2'},'http://mmsc.invalid',b'body')
- sock.close.assert_called_once()
- @unittest.skipUnless(shutil.which('dbus-run-session') and importlib.util.find_spec('dbus') and importlib.util.find_spec('gi'),'D-Bus/GLib integration dependencies unavailable')
- def test_private_bus_cleanup_does_not_exit_the_daemon(self):
- script='''
-import os,sys,importlib.util
-os.environ['DBUS_SYSTEM_BUS_ADDRESS']=os.environ['DBUS_SESSION_BUS_ADDRESS']
-from dbus.mainloop.glib import DBusGMainLoop
-from gi.repository import GLib
-DBusGMainLoop(set_as_default=True)
-s=importlib.util.spec_from_file_location('mms',sys.argv[1]);m=importlib.util.module_from_spec(s);s.loader.exec_module(m)
-loop=GLib.MainLoop()
-def close_bearer():
- try:
- with m.bearer():pass
- except Exception:pass
- GLib.timeout_add(100,alive)
- return False
-def alive():
- print('daemon-survived');loop.quit();return False
-GLib.idle_add(close_bearer);loop.run()
-'''
- r=subprocess.run(['dbus-run-session',sys.executable,'-c',script,str(ROOT/'tundra/runtime/mms-control.py')],capture_output=True,text=True,timeout=5)
- self.assertEqual(r.returncode,0,r.stderr);self.assertIn('daemon-survived',r.stdout)
-if __name__=='__main__':unittest.main()
diff --git a/tundra/tests/test_mobile_settings.py b/tundra/tests/test_mobile_settings.py
deleted file mode 100644
index 4cec3aa6..00000000
--- a/tundra/tests/test_mobile_settings.py
+++ /dev/null
@@ -1,115 +0,0 @@
-# SPDX-License-Identifier: MPL-2.0
-import importlib.util
-from pathlib import Path
-import unittest
-from unittest.mock import patch
-spec=importlib.util.spec_from_file_location('mobile',Path(__file__).resolve().parents[1]/'runtime/mobile-settings.py')
-mobile=importlib.util.module_from_spec(spec);spec.loader.exec_module(mobile)
-
-class MobileSettingsTests(unittest.TestCase):
- def test_sim_result_excludes_subscriber_identity_and_pin_counters(self):
- with patch.object(mobile,'properties',return_value={'Present':True,'CardIdentifier':'test-card','SubscriberIdentity':'secret','Retries':{'pin':3},'PinRequired':'none','ServiceProviderName':'Test','FixedDialing':False}):
- result=mobile.request({'version':1,'operation':'mobileSettings','action':'sim'})
- self.assertEqual(result['id'],'test-card')
- self.assertNotIn('secret',str(result));self.assertNotIn('Retries',result)
- def test_only_read_queries_allowed(self):
- for value in ({'action':'setPin'},{'action':'sim','command':'anything'},{'action':'calls','value':True}):
- with self.assertRaises(ValueError):mobile.request(value)
- def test_smsc_has_no_message_content(self):
- with patch.object(mobile,'properties',return_value={'ServiceCenterAddress':'test-center','Messages':['private']}):
- self.assertEqual(mobile.request({'action':'smsc'}),{'address':'test-center','deliveryReports':None})
- def test_call_query_cannot_report_unknown_as_disabled(self):
- with patch.object(mobile,'properties',return_value={}):
- self.assertIsNone(mobile.request({'action':'calls'})['VoiceCallWaiting'])
- def test_report_setting_validates_boolean_and_reads_back_modem(self):
- from types import SimpleNamespace
- with self.assertRaises(ValueError):mobile.request({'action':'deliveryReports','enabled':'true'})
- with patch.object(mobile.subprocess,'run',return_value=SimpleNamespace(returncode=0)) as run, patch.object(mobile,'properties',return_value={'UseDeliveryReports':True}):
- self.assertEqual(mobile.request({'action':'deliveryReports','enabled':True}),{'deliveryReports':True})
- self.assertEqual(run.call_args.args[0][-4:],['sv','UseDeliveryReports','b','true'])
- with patch.object(mobile.subprocess,'run',return_value=SimpleNamespace(returncode=0)), patch.object(mobile,'properties',return_value={'UseDeliveryReports':False}):
- with self.assertRaises(RuntimeError):mobile.request({'action':'deliveryReports','enabled':True})
- def test_data_write_requires_modem_readback(self):
- with patch.object(mobile,'set_property') as setter, patch.object(mobile,'properties',return_value={'Powered':False}):
- with self.assertRaisesRegex(RuntimeError,'RADIO_REQUEST_FAILED'):
- mobile.request({'action':'dataEnabled','enabled':True})
- setter.assert_called_once_with('ConnectionManager','Powered','b',True)
- def test_clock_rejects_path_and_type_injection_before_execution(self):
- with patch.object(mobile.subprocess,'run') as command:
- for value in ({'action':'setTimezone','timezone':'../../etc/passwd'}, {'action':'setTimezone','timezone':'/etc/passwd'},
- {'action':'setTime','milliseconds':True}, {'action':'setTime','milliseconds':float('nan')},
- {'action':'clock','timezone':'UTC'}, {'action':'setTime','milliseconds':0}):
- with self.assertRaises(ValueError):mobile.device_request(value)
- command.assert_not_called()
- def test_clock_returns_actual_service_state(self):
- from types import SimpleNamespace
- with patch.object(mobile.subprocess,'run',return_value=SimpleNamespace(returncode=0,stdout='Timezone=Europe/Paris\nNTP=no\nCanNTP=no\n')), patch.object(mobile,'clock_automatic',return_value=False):
- self.assertEqual(mobile.device_request({'action':'clock'}), {'timezone':'Europe/Paris','automatic':False,'automaticAvailable':True})
- def test_apn_read_never_returns_password_or_ims_context(self):
- import json
- from types import SimpleNamespace
- props=lambda kind:{'Type':{'data':kind},'Password':{'data':'private-secret'},'AccessPointName':{'data':'example'},'Active':{'data':False}}
- payload={'data':[[['/ril_0/context1',props('internet')],['/ril_0/context3',props('ims')]]]}
- with patch.object(mobile.subprocess,'run',return_value=SimpleNamespace(returncode=0,stdout=json.dumps(payload))):
- rows=mobile.request({'action':'apns'})
- self.assertEqual(len(rows),1);self.assertTrue(rows[0]['hasPassword'])
- self.assertNotIn('private-secret',str(rows))
- def test_apn_edit_reads_back_and_rolls_back_failed_write(self):
- path='/ril_0/context1';original={'Type':'internet','Active':False,'AccessPointName':'example','Username':'old'}
- state=dict(original)
- def write(path,name,value):state[name]=value
- with patch.object(mobile,'contexts',side_effect=lambda:{path:dict(state)}),patch.object(mobile,'set_context',side_effect=write):
- result=mobile.request({'action':'editApn','id':path,'fields':{'apn':'new.example','user':'new'}})
- self.assertEqual(result['fields']['apn'],'new.example');self.assertEqual(state['Username'],'new')
- state=dict(original)
- def fail(path,name,value):
- state[name]=value
- if name=='Username' and value=='new':raise RuntimeError('APN_WRITE_FAILED')
- with patch.object(mobile,'contexts',side_effect=lambda:{path:dict(state)}),patch.object(mobile,'set_context',side_effect=fail):
- with self.assertRaisesRegex(RuntimeError,'APN_WRITE_FAILED'):
- mobile.request({'action':'editApn','id':path,'fields':{'apn':'new.example','user':'new'}})
- self.assertEqual(state,original)
- def test_apn_failed_rollback_is_not_reported_as_restored(self):
- original={'/ril_0/context1':{'Type':'internet','Active':False,'AccessPointName':'example'}}
- with patch.object(mobile,'contexts',side_effect=[original,RuntimeError('RADIO_NOT_READY')]),patch.object(mobile,'set_context',side_effect=RuntimeError('APN_WRITE_FAILED')):
- with self.assertRaisesRegex(RuntimeError,'APN_ROLLBACK_FAILED'):
- mobile.request({'action':'editApn','id':'/ril_0/context1','fields':{'apn':'new.example'}})
- def test_apn_rejects_in_use_profiles_unknown_fields_and_injection(self):
- with patch.object(mobile,'contexts',return_value={'/ril_0/context1':{'Type':'internet','Active':True,'AccessPointName':'example'}}),patch.object(mobile,'set_context') as write:
- with self.assertRaisesRegex(RuntimeError,'APN_IN_USE'):mobile.request({'action':'editApn','id':'/ril_0/context1','fields':{'apn':'new.example'}})
- for fields in ({'command':'id'},{'apn':'bad\nname'},{'protocol':'evil'},{'mmsc':'file:///etc/passwd'},{'password':'x'*513}):
- with self.assertRaises(ValueError):mobile.request({'action':'editApn','id':'/ril_0/context1','fields':fields})
- with self.assertRaises(ValueError):mobile.request({'action':'editApn','id':'/ril_0/voicecall1','fields':{'apn':'new'}})
- write.assert_not_called()
- def test_radio_modes_match_real_modem_capabilities(self):
- state={'TechnologyPreference':'lte','AvailableTechnologies':['gsm','umts','lte']}
- def write(interface,name,signature,value):state[name]=value
- with patch.object(mobile,'properties',side_effect=lambda _:dict(state)),patch.object(mobile,'set_property',side_effect=write):
- self.assertEqual(mobile.request({'action':'setRadioMode','mode':'any'})['mode'],'any')
- with self.assertRaises(ValueError):mobile.request({'action':'setRadioMode','mode':'nr'})
- def test_supplementary_settings_validate_and_confirm_network_state(self):
- state={'HideCallerId':'default','VoiceCallWaiting':'enabled'}
- def write(interface,name,signature,value,**kwargs):state[name]=value
- with patch.object(mobile,'set_property',side_effect=write) as setter,patch.object(mobile,'properties',side_effect=lambda *a,**k:dict(state)):
- self.assertEqual(mobile.request({'action':'setCallWaiting','enabled':False})['VoiceCallWaiting'],'disabled')
- self.assertEqual(mobile.request({'action':'setCallerId','mode':'enabled'})['HideCallerId'],'enabled')
- setter.assert_called_with('CallSettings','HideCallerId','s','enabled',timeout=20)
- with patch.object(mobile,'set_property'),patch.object(mobile,'properties',return_value={'HideCallerId':'unknown'}):
- with self.assertRaisesRegex(RuntimeError,'RADIO_READBACK_FAILED'):mobile.request({'action':'setCallerId','mode':'default'})
- with patch.object(mobile,'set_property') as setter:
- for value in ({'action':'setCallWaiting','enabled':'true'},{'action':'setCallerId','mode':'secret'},{'action':'calls','number':'1'}):
- with self.assertRaises(ValueError):mobile.request(value)
- setter.assert_not_called()
- def test_forwarding_only_changes_requested_rule_and_checks_readback(self):
- state={'VoiceUnconditional':'','VoiceBusy':'123','VoiceNoReply':'','VoiceNotReachable':''}
- def write(interface,name,signature,value,**kwargs):state[name]=value
- with patch.object(mobile,'set_property',side_effect=write),patch.object(mobile,'properties',side_effect=lambda *a,**k:dict(state)):
- result=mobile.request({'action':'setForwarding','reason':'noReply','enabled':True,'number':'+33123456789'})
- self.assertEqual(result['noReply'],'+33123456789');self.assertEqual(result['busy'],'123')
- result=mobile.request({'action':'setForwarding','reason':'noReply','enabled':False,'number':''})
- self.assertEqual(result['noReply'],'');self.assertEqual(result['busy'],'123')
- with patch.object(mobile,'set_property') as setter:
- for value in ({'reason':'all','enabled':False,'number':''},{'reason':'busy','enabled':True,'number':'*123#'},{'reason':'busy','enabled':False,'number':'123'}):
- with self.assertRaises(ValueError):mobile.request({'action':'setForwarding',**value})
- setter.assert_not_called()
-if __name__=='__main__':unittest.main()
diff --git a/tundra/tests/test_modem_recovery.py b/tundra/tests/test_modem_recovery.py
deleted file mode 100644
index cd31d8ca..00000000
--- a/tundra/tests/test_modem_recovery.py
+++ /dev/null
@@ -1,42 +0,0 @@
-# SPDX-License-Identifier: MPL-2.0
-"""Check modem selection without modifying a host's subsystem policy."""
-import importlib.util
-from pathlib import Path
-import tempfile
-import unittest
-
-spec = importlib.util.spec_from_file_location(
- 'modem_recovery', Path(__file__).resolve().parents[1]/'runtime/modem-recovery.py')
-recovery = importlib.util.module_from_spec(spec)
-spec.loader.exec_module(recovery)
-
-
-class ModemRecoveryTest(unittest.TestCase):
- def test_only_modem_policy_changes_and_repeated_setup_is_safe(self):
- with tempfile.TemporaryDirectory() as folder:
- root = Path(folder)
- for identifier, name in [('subsys1', 'adsp'), ('subsys7', 'modem')]:
- device = root/identifier
- device.mkdir()
- (device/'name').write_text(name+'\n')
- (device/'restart_level').write_text('SYSTEM\n')
- recovery.configure(root)
- recovery.configure(root)
- self.assertEqual((root/'subsys1/restart_level').read_text(), 'SYSTEM\n')
- self.assertEqual((root/'subsys7/restart_level').read_text(), 'RELATED\n')
-
- def test_missing_modem_is_reported(self):
- with tempfile.TemporaryDirectory() as folder:
- with self.assertRaisesRegex(RuntimeError, 'Expected one'):
- recovery.configure(Path(folder))
-
- def test_unknown_policy_is_preserved(self):
- with tempfile.TemporaryDirectory() as folder:
- root = Path(folder)
- device = root/'subsys0'
- device.mkdir()
- (device/'name').write_text('modem\n')
- (device/'restart_level').write_text('UNKNOWN\n')
- with self.assertRaisesRegex(RuntimeError, 'Unsupported'):
- recovery.configure(root)
- self.assertEqual((device/'restart_level').read_text(), 'UNKNOWN\n')
diff --git a/tundra/tests/test_network_clock.py b/tundra/tests/test_network_clock.py
deleted file mode 100644
index 9c4e814a..00000000
--- a/tundra/tests/test_network_clock.py
+++ /dev/null
@@ -1,31 +0,0 @@
-import importlib.util,json,tempfile,unittest
-from pathlib import Path
-from types import SimpleNamespace
-from unittest.mock import patch
-spec=importlib.util.spec_from_file_location('mobile',Path(__file__).parents[1]/'runtime/mobile-settings.py')
-m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m)
-class NetworkClock(unittest.TestCase):
- def test_monotonic_age_is_added_to_nitz(self):
- payload={'data':[{'UTC':{'data':1791836110},'Received':{'data':530}}]}
- with patch.object(m.subprocess,'run',return_value=SimpleNamespace(returncode=0,stdout=json.dumps(payload))),patch.object(m.time,'monotonic',return_value=550):
- self.assertEqual(m.network_time(),1791836130)
- def test_rejects_future_or_missing_received_timestamp(self):
- for received in (None,999999,True):
- payload={'data':[{'UTC':{'data':1791836110},'Received':{'data':received}}]}
- with patch.object(m.subprocess,'run',return_value=SimpleNamespace(returncode=0,stdout=json.dumps(payload))),patch.object(m.time,'monotonic',return_value=550):
- with self.assertRaisesRegex(RuntimeError,'NETWORK_TIME_UNAVAILABLE'):m.network_time()
- def test_manual_mode_persists_and_prevents_network_write(self):
- with tempfile.TemporaryDirectory() as folder,patch.object(m,'CLOCK_CONFIG',Path(folder)/'clock.json'),patch.object(m,'network_time') as read,patch.object(m.time,'clock_settime') as write:
- self.assertTrue(m.clock_automatic());m.save_clock_automatic(False)
- self.assertFalse(m.clock_automatic());m.sync_network_clock();read.assert_not_called();write.assert_not_called()
- self.assertEqual(m.CLOCK_CONFIG.stat().st_mode & 0o777,0o600)
- def test_only_corrects_a_real_difference(self):
- with patch.object(m,'clock_automatic',return_value=True),patch.object(m,'network_time',return_value=1791836130),patch.object(m.time,'time',return_value=1791836129),patch.object(m.time,'clock_settime') as write:
- m.sync_network_clock();write.assert_not_called()
- with patch.object(m,'clock_automatic',return_value=True),patch.object(m,'network_time',return_value=1791836130),patch.object(m.time,'time',return_value=1791000000),patch.object(m.time,'clock_settime') as write:
- m.sync_network_clock();write.assert_called_once_with(m.time.CLOCK_REALTIME,1791836130)
- def test_manual_write_rejected_in_auto_mode(self):
- with patch.object(m,'clock_automatic',return_value=True),patch.object(m.subprocess,'run') as command:
- with self.assertRaisesRegex(RuntimeError,'AUTOMATIC_TIME_ENABLED'):m.device_request({'action':'setTime','milliseconds':1791836130000})
- command.assert_not_called()
-if __name__=='__main__':unittest.main()
diff --git a/tundra/tests/test_persistent_install.py b/tundra/tests/test_persistent_install.py
index 6dcdf494..e04941a5 100644
--- a/tundra/tests/test_persistent_install.py
+++ b/tundra/tests/test_persistent_install.py
@@ -67,19 +67,9 @@ class InstallTests(unittest.TestCase):
patch.object(install,'load',return_value=boot),patch.object(install,'flash_boot_a') as flash:
install.main();flash.assert_not_called()
- def test_workspace_build_never_falls_back_to_shared_inventory(self):
- self.build['deviceWorkspaceSha256'] = 'a'*64
- boot=SimpleNamespace(checked_plan=lambda: self.fail('Shared inventory used'))
- with patch.object(sys,'argv',['install','--build',str(self.folder)]), \
- patch.object(install,'checked_build',return_value=(self.build,self.qualification,self.image)), \
- patch.object(install,'load',return_value=boot), patch.object(install,'flash_boot_a') as flash:
- with self.assertRaisesRegex(ValueError, 'requires --device-workspace'):
- install.main()
- flash.assert_not_called()
-
def test_restore_does_not_require_working_candidate(self):
backup=self.folder/'boot_a.img';backup.write_bytes(b'original')
- boot=SimpleNamespace(checked_backup_plan=lambda:{'serialSha256':'serial','backupDirectory':str(self.folder)},
+ boot=SimpleNamespace(checked_plan=lambda:({'serialSha256':'serial','backupDirectory':str(self.folder)},None),
find_fastboot=lambda *args:'device',check_device=lambda *args:{'product':'sargo'})
with patch.object(sys,'argv',['install','--build',str(self.folder),'--restore']), \
patch.object(install,'load',return_value=boot), \
diff --git a/tundra/tests/test_radio_control.py b/tundra/tests/test_radio_control.py
index e89a49fe..7252182e 100644
--- a/tundra/tests/test_radio_control.py
+++ b/tundra/tests/test_radio_control.py
@@ -1,7 +1,4 @@
import importlib.util
-import json
-import socket
-import subprocess
from pathlib import Path
import unittest
from types import SimpleNamespace
@@ -15,107 +12,6 @@ spec.loader.exec_module(radio)
class RadioControlTests(unittest.TestCase):
- def test_message_state_accepts_modem_hex_case_but_not_other_objects(self):
- inbox = SimpleNamespace(outgoing_state=lambda path: 'sent')
- spec = SimpleNamespace(loader=SimpleNamespace(exec_module=lambda module: None))
- with patch.object(radio.importlib.util, 'spec_from_file_location', return_value=spec), \
- patch.object(radio.importlib.util, 'module_from_spec', return_value=inbox):
- for path in ['/ril_0/message_18DE1E64E8B16EA9E5E57366556C971B822E2614',
- '/ril_0/message_ab12']:
- self.assertEqual(radio.request({'version':1, 'operation':'messageState',
- 'path':path}), {'state':'sent'})
- for path in ['/ril_0/voicecall01', '/ril_0/message_Z', '/ril_0/message_1/other']:
- with self.assertRaisesRegex(ValueError, 'INVALID_SMS_PATH'):
- radio.request({'version':1, 'operation':'messageState', 'path':path})
-
- def test_slow_settings_transaction_does_not_block_call_controls(self):
- import threading
- started, release = threading.Event(), threading.Event()
- worker = radio.SettingsWorker()
- settings_server, settings_client = socket.socketpair()
- voice_server, voice_client = socket.socketpair()
- def request(value):
- if value['operation'] == 'mobileSettings':
- started.set()
- if not release.wait(2): raise RuntimeError('test settings timeout')
- return {'saved':True}
- return {'calls':[]}
- try:
- with patch.object(radio,'request',side_effect=request), settings_client, voice_server, voice_client:
- settings_client.sendall(b'{"version":1,"operation":"mobileSettings","action":"editApn"}\n')
- self.assertTrue(radio.handle_connection(settings_server,worker))
- self.assertTrue(started.wait(1))
- voice_client.sendall(b'{"version":1,"operation":"calls"}\n')
- self.assertFalse(radio.handle_connection(voice_server,worker))
- voice_client.settimeout(1)
- self.assertEqual(json.loads(voice_client.recv(4096))['result'],{'calls':[]})
- release.set()
- settings_client.settimeout(1)
- self.assertTrue(json.loads(settings_client.recv(4096))['result']['saved'])
- finally:
- release.set(); worker.close(); settings_server.close()
-
- def test_stale_queued_settings_are_not_applied(self):
- worker=radio.SettingsWorker()
- server, client=socket.socketpair()
- worker.slots.acquire()
- try:
- with client, patch.object(radio.time,'monotonic',return_value=20), patch.object(radio,'request') as request:
- worker.run(server,{'operation':'mobileSettings'},0)
- self.assertEqual(json.loads(client.recv(4096)),{'ok':False,'error':'RADIO_BUSY'})
- request.assert_not_called()
- finally:worker.close()
-
- def test_full_settings_queue_is_bounded(self):
- worker=radio.SettingsWorker()
- server, client=socket.socketpair()
- try:
- for _ in range(8):self.assertTrue(worker.slots.acquire(blocking=False))
- with server, client:
- client.sendall(b'{"version":1,"operation":"mobileSettings","action":"sim"}\n')
- self.assertFalse(radio.handle_connection(server,worker))
- self.assertEqual(json.loads(client.recv(4096)),{'ok':False,'error':'RADIO_BUSY'})
- finally:
- for _ in range(8):worker.slots.release()
- worker.close()
-
- def test_closed_client_does_not_crash_broker_or_repeat_sms(self):
- server, client = socket.socketpair()
- with server:
- client.sendall(b'{"version":1,"operation":"sms"}\n')
- client.close()
- with patch.object(radio, 'request', return_value={'state':'pending'}) as request:
- radio.handle_connection(server)
- request.assert_called_once()
-
- def test_incomplete_request_cannot_dial(self):
- server, client = socket.socketpair()
- with server, client:
- client.sendall(b'{"version":1,"operation":"dial"}')
- client.shutdown(socket.SHUT_WR)
- with patch.object(radio, 'request') as request:
- radio.handle_connection(server)
- request.assert_not_called()
-
- def test_modem_timeout_does_not_disclose_sms_arguments(self):
- server, client = socket.socketpair()
- with server, client:
- client.sendall(b'{"version":1,"operation":"sms"}\n')
- error = subprocess.TimeoutExpired(['busctl', 'private-number', 'private-body'], 15)
- with patch.object(radio, 'request', side_effect=error):
- radio.handle_connection(server)
- response = json.loads(client.recv(4096))
- self.assertEqual(response, {'ok':False, 'error':'RADIO_TIMEOUT'})
-
- def test_slow_client_timeout_does_not_escape_handler(self):
- from unittest.mock import Mock
- conn = Mock()
- conn.recv.side_effect = socket.timeout()
- with patch.object(radio, 'request') as request:
- radio.handle_connection(conn)
- request.assert_not_called()
- conn.sendall.assert_not_called()
-
def test_void_dbus_success_is_not_an_invalid_reply(self):
with patch.object(radio.subprocess, 'run', return_value=SimpleNamespace(
returncode=0, stdout='', stderr='')):
diff --git a/tundra/tests/test_ramdisk.py b/tundra/tests/test_ramdisk.py
index 9021849e..0b0c5db0 100644
--- a/tundra/tests/test_ramdisk.py
+++ b/tundra/tests/test_ramdisk.py
@@ -78,21 +78,6 @@ on boot
self.assertNotIn('critical',output)
self.assertIn('class core',output)
- def test_hardware_policy_keeps_one_primary_audio_owner(self):
- from unittest.mock import patch
- source = '''service vendor.audio-hal-2-0 /vendor/bin/hw/android.hardware.audio@2.0-service
- class hal
-service vendor.camera-provider-2-4 /vendor/bin/hw/android.hardware.camera.provider@2.4-service
- class hal
-on boot
- start vendor.audio-hal-2-0
- start vendor.camera-provider-2-4
-'''
- with patch.object(policy, 'SERVICES', policy.SERVICES | policy.HARDWARE_SERVICES):
- output, report = policy.filter_rc(source)
- self.assertNotIn('vendor.audio-hal-2-0', output)
- self.assertIn('vendor.camera-provider-2-4', report['services'])
-
def test_display_keeps_its_platform_properties_and_configstore(self):
output,report=policy.filter_rc('on post-fs-data\n load_system_props\nservice vendor.configstore-hal /vendor/bin/hw/android.hardware.configstore@1.1-service\n class hal animation\n user system\n')
self.assertIn('load_system_props',output)
diff --git a/tundra/tests/test_recovery_kit.py b/tundra/tests/test_recovery_kit.py
deleted file mode 100644
index 2ac902a5..00000000
--- a/tundra/tests/test_recovery_kit.py
+++ /dev/null
@@ -1,109 +0,0 @@
-# SPDX-License-Identifier: MPL-2.0
-import hashlib
-import importlib.util
-import json
-from pathlib import Path
-import subprocess
-import sys
-import tempfile
-import unittest
-from unittest.mock import patch
-
-TOOLS = Path(__file__).resolve().parents[1]/'tools'
-spec = importlib.util.spec_from_file_location('recovery', TOOLS/'restore-sargo.py')
-recovery = importlib.util.module_from_spec(spec)
-spec.loader.exec_module(recovery)
-
-
-class RecoveryTests(unittest.TestCase):
- def setUp(self):
- temp = tempfile.TemporaryDirectory(); self.addCleanup(temp.cleanup)
- self.root = Path(temp.name)
- self.image = self.root/'boot_a.img'
- self.image.write_bytes(b'ANDROID!'+bytes(4088))
- self.size = patch.object(recovery, 'BOOT_SIZE', 4096)
- self.size.start(); self.addCleanup(self.size.stop)
- self.manifest = {'schema': 1, 'device': 'sargo', 'partition': 'boot_a', 'slot': 'a',
- 'image': 'boot_a.img', 'bytes': 4096,
- 'serialSha256': hashlib.sha256(b'PHONE').hexdigest(),
- 'imageSha256': recovery.digest(self.image), 'backupManifestSha256': 'a'*64}
- self.save()
-
- def save(self):
- (self.root/'recovery.json').write_text(json.dumps(self.manifest))
-
- def test_local_review_does_not_require_checkout_or_phone(self):
- with patch.object(sys, 'argv', ['restore', '--kit', str(self.root)]), \
- patch.object(recovery.subprocess, 'run') as run, \
- patch.object(recovery.subprocess, 'check_output') as output, \
- patch.object(recovery.os, 'umask'):
- recovery.main()
- run.assert_not_called(); output.assert_not_called()
-
- def test_corrupted_backup_rejected(self):
- self.image.write_bytes(b'ANDROID!'+b'x'*4088)
- with self.assertRaisesRegex(ValueError, 'checksum mismatch'):
- recovery.checked_kit(self.root)
-
- def test_incomplete_export_cannot_restore(self):
- (self.root/'.incomplete').touch()
- with patch.object(sys, 'argv', ['restore', '--kit', str(self.root), '--restore']), \
- patch.object(recovery, 'checked_device') as device:
- with self.assertRaisesRegex(ValueError, 'did not finish'):
- recovery.main()
- device.assert_not_called()
-
- def test_missing_header_even_with_matching_hash_rejected(self):
- self.image.write_bytes(bytes(4096))
- self.manifest['imageSha256'] = recovery.digest(self.image); self.save()
- with self.assertRaisesRegex(ValueError, 'boot header'):
- recovery.checked_kit(self.root)
-
- def test_other_slot_and_path_are_rejected(self):
- for key, value in (('partition', 'userdata'), ('slot', 'b'), ('image', '../boot_a.img')):
- original = self.manifest[key]
- self.manifest[key] = value; self.save()
- with self.assertRaises(ValueError):
- recovery.checked_kit(self.root)
- self.manifest[key] = original
-
- def test_wrong_phone_never_gets_a_write(self):
- with patch.object(recovery.subprocess, 'check_output', return_value='OTHER fastboot\n'), \
- patch.object(recovery.subprocess, 'run') as run:
- with self.assertRaisesRegex(ValueError, 'not uniquely present'):
- recovery.checked_device(self.manifest)
- run.assert_not_called()
-
- def test_locked_slot_b_wrong_product_and_wrong_partition_size_rejected(self):
- original = {'product': 'sargo', 'unlocked': 'yes', 'current-slot': 'a',
- 'partition-size:boot_a': '0x1000'}
- for name, value in (('product', 'bonito'), ('unlocked', 'no'),
- ('current-slot', 'b'), ('partition-size:boot_a', '0x2000')):
- values = dict(original, **{name: value})
- with patch.object(recovery.subprocess, 'check_output', return_value='PHONE fastboot\n'), \
- patch.object(recovery, 'getvar', side_effect=lambda serial, key: values[key]):
- with self.assertRaises(ValueError):
- recovery.checked_device(self.manifest)
-
- def test_restore_only_writes_boot_a_without_reboot(self):
- with patch.object(sys, 'argv', ['restore', '--kit', str(self.root), '--restore']), \
- patch.object(recovery, 'checked_device', return_value='PHONE'), \
- patch.object(recovery.subprocess, 'run') as run, \
- patch.object(recovery.os, 'umask'):
- recovery.main()
- run.assert_called_once_with(['fastboot', '-s', 'PHONE', 'flash', 'boot_a',
- str(self.image.resolve())], check=True, timeout=90)
- self.assertTrue(json.loads((self.root/'restore-report.json').read_text())['completed'])
-
- def test_flash_failure_is_recorded_as_incomplete(self):
- with patch.object(sys, 'argv', ['restore', '--kit', str(self.root), '--restore']), \
- patch.object(recovery, 'checked_device', return_value='PHONE'), \
- patch.object(recovery.subprocess, 'run', side_effect=subprocess.CalledProcessError(1, 'fastboot')), \
- patch.object(recovery.os, 'umask'):
- with self.assertRaises(subprocess.CalledProcessError):
- recovery.main()
- self.assertFalse(json.loads((self.root/'restore-report.json').read_text())['completed'])
-
-
-if __name__ == '__main__':
- unittest.main()
diff --git a/tundra/tests/test_runtime_packages.py b/tundra/tests/test_runtime_packages.py
deleted file mode 100644
index c59bf54c..00000000
--- a/tundra/tests/test_runtime_packages.py
+++ /dev/null
@@ -1,59 +0,0 @@
-"""Check the pinned Bluetooth libraries and native TLS trust store offline."""
-import importlib.util
-import json
-from pathlib import Path
-import ssl
-import tempfile
-import unittest
-import xml.etree.ElementTree as ET
-
-ROOT = Path(__file__).resolve().parents[1]
-spec = importlib.util.spec_from_file_location('packages', ROOT/'runtime/runtime-packages.py')
-packages = importlib.util.module_from_spec(spec)
-spec.loader.exec_module(packages)
-CACHE = ROOT/'downloads/native-packages-20261008'
-
-
-class RuntimePackages(unittest.TestCase):
- def test_corrupt_archive_is_rejected_before_extraction(self):
- with tempfile.TemporaryDirectory() as folder:
- base = Path(folder)
- cache = base/'cache'
- cache.mkdir()
- (cache/packages.PACKAGES[0][0]).write_bytes(b'corrupt')
- with self.assertRaisesRegex(ValueError, 'checksum mismatch'):
- packages.stage(base/'root', cache)
- self.assertFalse((base/'root').exists())
-
- @unittest.skipUnless(all((CACHE/name).exists() for name, _, _ in packages.PACKAGES),
- 'Pinned package cache is not installed')
- def test_staged_payload_is_repeatable_and_certificates_are_usable(self):
- with tempfile.TemporaryDirectory() as folder:
- root = Path(folder)
- first = packages.stage(root, CACHE)
- self.assertEqual(packages.stage(root, CACHE), first)
- self.assertEqual(json.loads((root/'usr/share/vulpes/runtime-packages.json').read_text()), first)
- self.assertTrue((root/'usr/libexec/bluetooth/bluetoothd').is_file())
- self.assertFalse((root/'usr/bin/btmon').exists())
- self.assertTrue((root/'usr/bin/bluetoothctl').is_file())
- self.assertTrue((root/'usr/lib/audiosystem-passthrough/audiosystem-passthrough').is_file())
- context = ssl.create_default_context(cafile=str(root/'etc/ssl/certs/ca-certificates.crt'))
- self.assertGreater(context.cert_store_stats()['x509_ca'], 50)
- self.assertTrue((root/'usr/lib/ssl/cert.pem').is_file())
- providers = ET.parse(root/'usr/share/mobile-broadband-provider-info/serviceproviders.xml')
- self.assertGreater(len(providers.findall('country')), 100)
- orange = [provider for provider in providers.findall("./country[@code='fr']/provider")
- if any(n.attrib == {'mcc':'208', 'mnc':'01'}
- for n in provider.findall('.//network-id'))]
- self.assertTrue(orange)
- self.assertTrue(any(apn.get('value') == 'orange' for provider in orange
- for apn in provider.findall('.//apn')))
- target = root/'usr/libexec/bluetooth/bluetoothd'
- target.write_bytes(b'unknown local binary')
- with self.assertRaisesRegex(ValueError, 'Conflicting package file'):
- packages.stage(root, CACHE)
- self.assertEqual(target.read_bytes(), b'unknown local binary')
-
-
-if __name__ == '__main__':
- unittest.main()
diff --git a/tundra/tests/test_sparse_backup.py b/tundra/tests/test_sparse_backup.py
deleted file mode 100644
index 2305b36d..00000000
--- a/tundra/tests/test_sparse_backup.py
+++ /dev/null
@@ -1,37 +0,0 @@
-# SPDX-License-Identifier: MPL-2.0
-import io
-from pathlib import Path
-import struct
-import sys
-import unittest
-sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'tools'))
-from sparse_backup import receive
-
-
-def record(offset,data):return struct.pack('.ext4')
parser.add_argument('--hold-hal',action='store_true',help='Boot basic.target first so HAL startup can be traced over USB')
@@ -67,16 +64,11 @@ def main():
raise ValueError('Data image belongs to another system image')
os.umask(0o077)
built_at=int(time.time())
- layout=json.loads(args.layout.read_text())
- if args.device_workspace:
- from sargo_workspace import checked_workspace
- plan, inventoried_layout = checked_workspace(args.device_workspace)
- if layout != inventoried_layout:
- raise ValueError('Layout does not belong to this device workspace')
- else:
- plan,_=load_boot().checked_plan()
- storage_table = mount_table(layout)
out=args.output.resolve();out.mkdir(parents=True,exist_ok=False)
+ plan,_=load_boot().checked_plan()
+ layout=json.loads(args.layout.read_text())
+ if layout['device']!='/dev/mmcblk0p72' or layout['table']!='0 104448000 linear 259:40 329728' or int(layout['size'])!=53648801280:
+ raise ValueError('Recipe only supports the inventoried read-only Sargo layout')
source=Path(plan['backupDirectory'])/'boot_a.img'
packager=checked_source('aosp-mkbootimg')
unpack=out/'original'
@@ -110,12 +102,7 @@ def main():
(out/'known_hosts').write_text('[10.15.19.82]:2222 '+hostpublic+'\n[127.0.0.1]:2222 '+hostpublic+'\n')
init=(ROOT/'boot/rescue-init.sh').read_bytes().replace(b'# No LVM activation',
('EXPECTED_STORAGE_HASH='+shlex.quote(layout['firstMiBSha256'])+'\n# No LVM activation').encode())
- init=init.replace(b"'0 104448000 linear /dev/mmcblk0p72 329728'",
- shlex.quote(storage_table).encode())
- sources=[Path(__file__),ROOT/'boot/rescue-init.sh',ROOT/'tools/ramdisk_reference.py',
- ROOT/'tools/storage_layout.py']
- if args.device_workspace:
- sources += [ROOT/'tools/sargo_workspace.py', ROOT/'tools/boot_backup.py']
+ sources=[Path(__file__),ROOT/'boot/rescue-init.sh',ROOT/'tools/ramdisk_reference.py']
if state:
# Keep the existing layout intact; only /tundra/data/.ext4 is used
# for Tundra state. Opening its containing filesystem RW is necessary.
@@ -181,10 +168,23 @@ def main():
('SYSTEM_IMAGE=/installed/tundra/images/'+sha+'.ext4\nSYSTEM_IMAGE_SHA='+sha+'\n# A kernel fallback').encode())
# Bundle UI changes with the boot trial rather than retransferring
# the entire immutable system image for each CSS/JS adjustment.
- from shared_ui import build_payload
- payload, shared_ui = build_payload()
- files['shared-ui.tar'] = (0o600, payload)
- sources.append(ROOT/'tools/shared_ui.py')
+ buffer = io.BytesIO()
+ selected = [*sorted((ROOT.parent/'host').rglob('*')),
+ *sorted((ROOT.parent/'services').rglob('*.mjs')),
+ *sorted((ROOT.parent/'gaia/shared/elements/vulpes-fonts').rglob('*')),
+ *sorted((ROOT.parent/'overrides').rglob('*')),
+ ROOT.parent/'assets/webapps/search.gaiamobile.org/manifest.webapp',
+ ROOT.parent/'tools/serve-gaia.py',
+ ROOT.parent/'release.json', ROOT.parent/'tests/ProbeChild.sys.mjs',
+ ROOT/'droidian/probe.sys.mjs', ROOT/'droidian/session.py']
+ with tarfile.open(fileobj=buffer, mode='w') as archive:
+ for path in selected:
+ if not path.is_file(): continue
+ relative = str(path.relative_to(ROOT.parent))
+ target = 'trial/'+path.name if path in (ROOT/'droidian/probe.sys.mjs', ROOT/'droidian/session.py') else relative
+ archive.add(path, arcname='opt/vulpes/'+target, filter=owned)
+ shared_ui[relative] = digest(path)
+ files['shared-ui.tar'] = (0o600, buffer.getvalue())
if state:
config = ('STATE_IMAGE=/installed/tundra/data/'+state['uuid']+'.ext4\n'
'STATE_UUID='+state['uuid']+'\nSTATE_BYTES='+str(state['bytes'])+'\n')
@@ -229,7 +229,6 @@ def main():
save(out/'build.json',{'scope':'Private persistent Sargo development boot; unqualified' if state else ('Private temporary native-root trial; unqualified' if args.native else 'Private temporary SSH boot; installed root read-only; no graphical session'),
'builtAtUnix':built_at,'runtimeConfigOverlay':args.runtime_config,'networkTrial':args.network,'hardwareTrial':args.hardware,'interactiveDiagnostics':args.diagnostics,'image':image.name,'imageSha256':digest(image),'sourceBootSha256':digest(source),
'serialSha256':plan['serialSha256'],'kernelSha256':kernel_hash,
- 'deviceWorkspaceSha256':plan.get('workspaceSha256'),
'ramdiskSha256':digest(ramdisk),'roundtripPassed':True,'flashReady':False,'localDebugger':args.local_debugger,'watchdogSeconds':None if args.no_deadline else (600 if args.native else 480),'persistentState':state,'storageWrites':bool(state),'nativeRootRequested':args.native,'halHeldForDiagnostics':args.hold_hal,
'layout':layout,'layoutSha256':digest(args.layout),'systemImage':system,'sharedUiHashes':shared_ui,'sourceSnapshot':True,'sources':{str(p.relative_to(ROOT)):digest(p) for p in sources},
'helpers':{n:__import__('hashlib').sha256(b).hexdigest() for n,b in helpers.items()},
diff --git a/tundra/tools/build-standalone-boot.py b/tundra/tools/build-standalone-boot.py
deleted file mode 100644
index cedb0557..00000000
--- a/tundra/tools/build-standalone-boot.py
+++ /dev/null
@@ -1,146 +0,0 @@
-#!/usr/bin/env python3
-# SPDX-License-Identifier: MPL-2.0
-"""Build a raw-userdata Tundra boot, or its nondestructive container test variant."""
-import argparse
-import json
-import os
-from pathlib import Path
-import shlex
-import shutil
-import stat
-import subprocess
-import sys
-import tempfile
-from ramdisk_reference import read_newc
-from standalone_volume import checked_volume
-from tundra import ROOT, checked_source, cpio, digest
-
-
-def storage_scripts(init, native, volume, trial):
- start = init.index(b'# Use the inventoried filesystem')
- end = init.index(b'echo TUNDRA_RESCUE_READY', start)
- legacy = init[start:end]
- # The container test keeps the old mount solely as a carrier for the image.
- if trial:
- legacy += b'mkdir -p /run/legacy-storage\nmount --move /installed /run/legacy-storage\n'
- else:
- legacy = b'# Standalone boot does not open the previous operating system.\n'
- init = init[:start]+legacy+init[end:]
- init = init.replace(b'exec /bin/sh /native-root.sh', b'exec /bin/sh /standalone-mount.sh')
- native = native.replace(b'LOWER=/installed/home/droidian/vulpes-tundra-tests/runtime-reference-20260925/root',
- b': "${SYSTEM_IMAGE:?A bundled system image is required}"\nLOWER=/run/tundra-system')
- native = native.replace(b'/installed', b'/volume')
- native = native.replace(b'/reference/volume', b'/reference/tundra-volume')
- if volume.get('hardwareImages'):
- lines = ['mkdir -p /newroot/reference']
- for name in ('vendor.img', 'modem.img'):
- lines += ['test -s /volume/tundra/hardware/'+name,
- ': > /newroot/reference/'+name,
- 'mount --bind /volume/tundra/hardware/'+name+' /newroot/reference/'+name,
- 'mount -o remount,bind,ro /newroot/reference/'+name]
- config = json.dumps({'images':volume['hardwareImages']}, separators=(',',':'))
- lines += ['mkdir -p /newroot/etc/tundra',
- 'printf \'%s\\n\' '+shlex.quote(config)+' > /newroot/etc/tundra/hardware-images.json']
- native = native.replace(b'# Installed lower layers', ('\n'.join(lines)+'\n# Installed lower layers').encode())
- if not trial:
- if b'/dev/mapper' in init or b'EXPECTED_STORAGE_HASH' in init:
- # The old expected hash assignment is unnecessary in the raw image.
- init = b'\n'.join(line for line in init.split(b'\n') if not line.startswith(b'EXPECTED_STORAGE_HASH='))
- if b'/dev/mapper' in init or b'/installed/' in native:
- raise ValueError('Legacy storage leaked into standalone boot')
- device = '/run/legacy-storage/tundra/standalone/'+volume['uuid']+'.ext4' if trial else '/dev/mmcblk0p72'
- config = 'VOLUME_DEVICE='+shlex.quote(device)+'\nVOLUME_UUID='+shlex.quote(volume['uuid'])+'\nVOLUME_OPTIONS='+('loop,rw' if trial else 'rw')+'\n'
- mount = (ROOT/'boot/standalone-mount.sh').read_bytes().replace(b'# VOLUME_CONFIGURATION', config.encode())
- return init, native, mount
-
-
-def main():
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument('--volume', type=Path, required=True)
- parser.add_argument('--prepared', type=Path, required=True)
- parser.add_argument('--device-workspace', type=Path, required=True)
- parser.add_argument('--output', type=Path, required=True)
- parser.add_argument('--container-trial', action='store_true')
- parser.add_argument('--no-deadline', action='store_true')
- parser.add_argument('--boot-control', type=Path, help='Pinned ARM64 qbootctl binary')
- parser.add_argument('--no-maintenance', action='store_true', help='Exclude private USB SSH credentials and server')
- parser.add_argument('--local-debugger', action='store_true', help='Private RAM tests: loopback Marionette, never a public image')
- args = parser.parse_args(); os.umask(0o077)
- if args.local_debugger and args.no_maintenance: parser.error('Local debugger requires a private maintenance image')
- volume = checked_volume(args.volume)
- if args.no_deadline and not args.container_trial and not args.boot_control:
- parser.error('Persistent standalone images require --boot-control')
- if args.boot_control and digest(args.boot_control) != 'dcb00e4c4f038071a5d9979263128325e16b848903371f6c00d49cf3e25c34d9':
- raise ValueError('Unqualified boot-control binary')
- if args.boot_control and digest(args.boot_control.with_name('copyright')) != '8969e659aff7ed5bb3249341764c2bd048d5abe7400076f01485650e727e8c42':
- raise ValueError('Boot-control license notice missing or changed')
- system = json.loads((args.prepared/'system.json').read_text())
- state = json.loads((args.prepared/'data/state.json').read_text())
- if volume['systemImageSha256'] != system['imageSha256'] or volume['stateUuid'] != state['uuid']:
- raise ValueError('Volume does not contain this system/data pair')
- command = [sys.executable,str(ROOT/'tools/build-rescue-boot.py'), '--output',str(args.output),
- '--device-workspace',str(args.device_workspace),'--layout',str(args.device_workspace/'layout.json'),
- '--native','--runtime-config','--network','--hardware',
- '--system-image',str(args.prepared/'system.json'),'--state',str(args.prepared/'data/state.json')]
- if args.no_deadline: command.append('--no-deadline')
- if args.local_debugger: command.append('--local-debugger')
- subprocess.run(command,check=True)
- out = args.output.resolve(); build = json.loads((out/'build.json').read_text())
- ramdisk = out/'initramfs.cpio.gz'; files = read_newc(ramdisk.read_bytes())
- init,native,mount = storage_scripts(files['init']['data'],files['native-root.sh']['data'],volume,args.container_trial)
- if args.boot_control:
- files['sbin/tundra-qbootctl']={'mode':stat.S_IFREG|0o755,'data':args.boot_control.read_bytes()}
- files['qbootctl-copyright']={'mode':stat.S_IFREG|0o644,'data':args.boot_control.with_name('copyright').read_bytes()}
- files['standalone-boot-success.py']={'mode':stat.S_IFREG|0o755,'data':(ROOT/'runtime/mark-boot-success.py').read_bytes()}
- unit=b'[Unit]\nDescription=Acknowledge a healthy standalone Vulpes boot\nAfter=tundra-display.service\n[Service]\nType=oneshot\nTimeoutStartSec=650\nExecStart=/usr/bin/python3 /usr/libexec/tundra-mark-boot-success.py\n'
- files['standalone-boot-success.service']={'mode':stat.S_IFREG|0o644,'data':unit}
- setup=b"""mkdir -p /newroot/usr/libexec /newroot/etc/systemd/system/tundra.target.wants
-mkdir -p /newroot/usr/share/doc/qbootctl
-cp /qbootctl-copyright /newroot/usr/share/doc/qbootctl/copyright
-cp /sbin/tundra-qbootctl /newroot/usr/libexec/tundra-qbootctl
-cp /standalone-boot-success.py /newroot/usr/libexec/tundra-mark-boot-success.py
-cp /standalone-boot-success.service /newroot/etc/systemd/system/tundra-boot-success.service
-ln -sf ../tundra-boot-success.service /newroot/etc/systemd/system/tundra.target.wants/tundra-boot-success.service
-"""
- native=native.replace(b'# Installed lower layers',setup+b'# Installed lower layers')
- if args.no_maintenance:
- init=b'\n'.join(line for line in init.split(b'\n') if not line.startswith(b'/sbin/dropbear '))
- init=init.replace(b'TUNDRA_USB_SSH_READY',b'TUNDRA_USB_READY')
- start=native.index(b'# Keep a minimal, already tested rescue environment')
- end=native.index(b'for fs in dev proc sys run;',start)
- native=native[:start]+b'# Public boot: no USB maintenance account or shared SSH key.\n'+native[end:]
- for name in ('etc/tundra-hostkey','root/.ssh/authorized_keys','sbin/dropbear'):
- files.pop(name,None)
- files['init']['data']=init; files['native-root.sh']['data']=native
- files['standalone-mount.sh']={'mode':stat.S_IFREG|0o755,'data':mount}
- files['volume']={'mode':stat.S_IFDIR|0o755,'data':b''}
- entries=[]
- for name, item in sorted(files.items()):
- entries.append((name,item['mode'],item['data'],5 if name=='dev/console' else 0,1 if name=='dev/console' else 0))
- ramdisk.write_bytes(cpio(entries))
- packager=checked_source('aosp-mkbootimg'); original=out/build['image']
- with tempfile.TemporaryDirectory() as temp:
- unpack=Path(temp)/'unpack'
- text=subprocess.check_output([sys.executable,str(packager/'unpack_bootimg.py'),'--boot_img',str(original),'--out',str(unpack),'--format=mkbootimg'],text=True)
- options=shlex.split(text); fields=dict(zip(options[::2],options[1::2]));fields['--ramdisk']=str(ramdisk)
- image=out/'boot.img'; command=[sys.executable,str(packager/'mkbootimg.py')]
- for key,value in fields.items():command.extend([key,value])
- subprocess.run(command+['-o',str(image)],check=True)
- verify=Path(temp)/'verify'
- subprocess.run([sys.executable,str(packager/'unpack_bootimg.py'),'--boot_img',str(image),'--out',str(verify)],check=True,stdout=subprocess.DEVNULL)
- if digest(verify/'kernel')!=build['kernelSha256'] or digest(verify/'ramdisk')!=digest(ramdisk):
- raise ValueError('Standalone boot roundtrip failed')
- original.unlink()
- for path in (Path(__file__).resolve(),ROOT/'tools/standalone_volume.py',ROOT/'boot/standalone-mount.sh',ROOT/'runtime/mark-boot-success.py'):
- relative=str(path.relative_to(ROOT)); target=out/'sources/tundra'/relative
- target.parent.mkdir(parents=True,exist_ok=True);shutil.copy2(path,target);build['sources'][relative]=digest(path)
- build.update(image='boot.img',imageSha256=digest(image),ramdiskSha256=digest(ramdisk),
- storageMode='container-test' if args.container_trial else 'raw-userdata-ext4',
- standaloneVolume=volume,flashReady=False,bootControlSha256=digest(args.boot_control) if args.boot_control else None,
- containsPrivateTrialHostKey=not args.no_maintenance,maintenanceEnabled=not args.no_maintenance,
- scope='Standalone Tundra boot candidate; hardware qualification required')
- (out/'build.json').write_text(json.dumps(build,indent=2)+'\n')
- print(image)
-
-
-if __name__=='__main__':main()
diff --git a/tundra/tools/build-standalone-volume.py b/tundra/tools/build-standalone-volume.py
deleted file mode 100644
index bc2e28a7..00000000
--- a/tundra/tools/build-standalone-volume.py
+++ /dev/null
@@ -1,83 +0,0 @@
-#!/usr/bin/env python3
-# SPDX-License-Identifier: MPL-2.0
-"""Build a fresh ext4 userdata image. No connection to a phone or partition writes."""
-import argparse
-import importlib.util
-import json
-import os
-from pathlib import Path
-import shutil
-import subprocess
-import sys
-import uuid
-from persistent_state import FEATURES
-from tundra import digest
-from standalone_volume import checked_volume
-
-
-def main():
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument('--prepared', required=True, type=Path)
- parser.add_argument('--output', required=True, type=Path)
- parser.add_argument('--hardware', type=Path, help='Locally supplied pinned vendor/modem images')
- parser.add_argument('--size-mib', type=int, default=8192)
- args = parser.parse_args()
- if not 5120 <= args.size_mib <= 32768:
- parser.error('Choose 5120–32768 MiB')
- spec = importlib.util.spec_from_file_location('stage', Path(__file__).with_name('stage-sargo-files.py'))
- stage = importlib.util.module_from_spec(spec); spec.loader.exec_module(stage)
- files = stage.inputs(args.prepared)
- hardware = {}
- if args.hardware:
- lock = json.loads((Path(__file__).resolve().parents[1]/'manifests/sargo-hardware.json').read_text())
- hardware = lock['images']
- for name, record in hardware.items():
- source = args.hardware/record['source']
- if source.is_symlink() or source.stat().st_size != record['bytes'] or digest(source) != record['sha256']:
- raise ValueError('Hardware image changed: '+name)
- payload_bytes = sum(p.stat().st_size for _,_,p,_ in files)
- payload_bytes += sum(record['bytes'] for record in hardware.values())
- if args.size_mib*1024**2 < payload_bytes+512*1024**2:
- raise ValueError('Insufficient space for payload and filesystem metadata')
- os.umask(0o077)
- out = args.output.resolve(); out.mkdir(parents=True, exist_ok=False)
- (out/'.incomplete').touch()
- seed = out/'seed'; seed.mkdir()
- for kind, name, source, record in files:
- target = seed/'tundra'/kind/name; target.parent.mkdir(parents=True, exist_ok=True)
- # mke2fs reads the file; a hard link avoids another multi-gigabyte copy.
- os.link(source, target)
- if hardware:
- directory = seed/'tundra/hardware'; directory.mkdir()
- for name, record in hardware.items():
- os.link(args.hardware/record['source'], directory/name)
- system = json.loads((args.prepared/'system.json').read_text())
- state = json.loads((args.prepared/'data/state.json').read_text())
- identifier = str(uuid.uuid4())
- (seed/'tundra-volume-id').write_text(identifier+'\n')
- image = out/'userdata.ext4'
- with image.open('xb') as f: f.truncate(args.size_mib*1024**2)
- subprocess.run(['mke2fs','-q','-t','ext4','-F','-b','4096','-I','256','-O',FEATURES,
- '-U',identifier,'-L','TUNDRA_VOLUME','-d',str(seed),str(image)],check=True)
- names=['/tundra-volume-id','/tundra','/tundra/images','/tundra/data']
- names += ['/tundra/'+kind+'/'+name for kind,name,_,_ in files]
- if hardware:
- names += ['/tundra/hardware', *['/tundra/hardware/'+name for name in hardware]]
- for name in names:
- for field in ('uid','gid'):
- subprocess.run(['debugfs','-w','-R',f'set_inode_field {name} {field} 0',str(image)],check=True,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
- result = subprocess.run(['e2fsck','-f','-n',str(image)],capture_output=True,text=True)
- (out/'filesystem-check.txt').write_text(result.stdout+result.stderr)
- if result.returncode: raise ValueError('Standalone filesystem check failed')
- manifest={'schema':1,'device':'sargo','storage':'raw-userdata-ext4','image':image.name,
- 'uuid':identifier,'bytes':image.stat().st_size,'imageSha256':digest(image),
- 'systemImageSha256':system['imageSha256'],'stateUuid':state['uuid'],
- 'filesystemCheckPassed':True,'flashReady':False,'hardwareImages':hardware,
- 'scope':'Fresh standalone storage; flashing userdata destroys previous user data'}
- (out/'volume.json').write_text(json.dumps(manifest,indent=2)+'\n')
- checked_volume(out/'volume.json')
- (out/'.incomplete').unlink()
- print(out/'volume.json')
-
-
-if __name__ == '__main__': main()
diff --git a/tundra/tools/build-system-image.py b/tundra/tools/build-system-image.py
index fffd8014..b8700fc9 100644
--- a/tundra/tools/build-system-image.py
+++ b/tundra/tools/build-system-image.py
@@ -14,23 +14,6 @@ import sys
ROOT = Path(__file__).resolve().parents[1]
PROJECT = ROOT.parent
ANDROID_SHA = '132d0e18c6a6a111293447e940267f35aa2b14fa00a872ef160b20dc2014d886'
-HARDWARE_PACKAGES = {'systemd', 'phoc', 'libhybris', 'ofono',
- 'ofono-binder-plugin', 'network-manager', 'pulseaudio'}
-
-
-def validate_reference(reference):
- manifest = json.loads((reference/'manifest.json').read_text())
- if manifest.get('architecture') != 'arm64':
- raise ValueError('Sargo requires an ARM64 runtime reference')
- packages = {name.split(':', 1)[0] for name in manifest.get('packages', {})}
- missing = HARDWARE_PACKAGES - packages
- if missing:
- raise ValueError('Incomplete hardware runtime; missing packages: '+', '.join(sorted(missing)))
- files = manifest.get('files', {})
- for name in ('/usr/sbin/ofonod', '/usr/sbin/NetworkManager', '/usr/bin/phoc'):
- if name not in files:
- raise ValueError('Missing hardware executable in runtime inventory: '+name)
- return manifest
def digest(path):
@@ -38,39 +21,6 @@ def digest(path):
return hashlib.file_digest(stream, 'sha256').hexdigest()
-def prepare_distribution_metadata(product, reference):
- """Keep license notices, but leave working documents out of the image."""
- removed = []
- for path in sorted(product.rglob('*')):
- if not path.is_file() or path.is_symlink():
- continue
- if path.suffix.lower() != '.md':
- continue
- if path.stem.lower() in ('license', 'copying', 'notice'):
- target = path.with_suffix('')
- if target.exists() and target.read_bytes() != path.read_bytes():
- raise ValueError('Conflicting license notices: '+str(path))
- if not target.exists():
- path.rename(target)
- else:
- path.unlink()
- else:
- removed.append(str(path.relative_to(product)))
- path.unlink()
- for name in ('LICENSE', 'NOTICE'):
- if (product/name).exists() and (product/name).read_bytes() != (PROJECT/name).read_bytes():
- raise ValueError('Refusing to replace an existing notice: '+name)
- shutil.copy2(PROJECT/name, product/name)
- manifest = json.loads((reference/'manifest.json').read_text())
- packages = [{key: info[key] for key in
- ('binary:Package', 'Version', 'Architecture', 'source:Package', 'source:Version')
- if key in info} for info in manifest['packages'].values()]
- (product/'runtime-packages.json').write_text(
- json.dumps({'schema': 1, 'packages': packages}, indent=2)+'\n')
- return {'internalMarkdownRemoved': removed, 'packageCount': len(packages),
- 'licenseNoticesRetained': True}
-
-
def module(name, path):
spec = importlib.util.spec_from_file_location(name, path)
result = importlib.util.module_from_spec(spec)
@@ -97,18 +47,15 @@ def pack(root, image):
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=Path, required=True)
- parser.add_argument('--reference', type=Path, help='Explicit ARM64 hardware runtime reference')
+ parser.add_argument('--reference', type=Path, default=ROOT/'downloads/sargo-installed/runtime-reference-20260925')
parser.add_argument('--product', type=Path, default=ROOT/'out/droidian/product')
parser.add_argument('--pack', nargs=2, type=Path, help=argparse.SUPPRESS)
args = parser.parse_args()
if args.pack:
pack(*args.pack)
return
- if args.reference is None:
- parser.error('--reference is required; display-only runtime references are not sufficient')
if os.geteuid() == 0:
parser.error('Build as a regular user')
- validate_reference(args.reference)
android = ROOT/'downloads/sargo-installed/android-rootfs.img'
if digest(android) != ANDROID_SHA:
raise ValueError('Android HAL reference changed')
@@ -128,7 +75,7 @@ def main():
ignore=shutil.ignore_patterns('__pycache__', 'test', 'tests'))
for name in ('tools', 'tests', 'logs', 'profiles'):
(product/name).mkdir(exist_ok=True)
- for name in ('serve-gaia.py', 'project.py', 'run-host.py', 'installed_apps.py'):
+ for name in ('serve-gaia.py', 'project.py', 'run-host.py'):
shutil.copy2(PROJECT/'tools'/name, product/'tools'/name)
shutil.copy2(PROJECT/'tests/ProbeChild.sys.mjs', product/'tests/ProbeChild.sys.mjs')
shutil.copy2(PROJECT/'release.json', product/'release.json')
@@ -137,7 +84,6 @@ def main():
shutil.copytree(ROOT/'droidian', product/'trial', ignore=shutil.ignore_patterns('__pycache__'))
for name in ('libEGL_vulpes_hybris.so', 'manifest.json'):
shutil.copy2(args.product/'trial/graphics'/name, product/'trial/graphics'/name)
- distribution = prepare_distribution_metadata(product, args.reference)
for name in ('home/droidian', 'etc/dbus-1', 'reference'):
(root/name).mkdir(parents=True, exist_ok=True)
shutil.copy2(android, root/'reference/android-rootfs.img')
@@ -170,7 +116,6 @@ RemainAfterExit=yes
'androidSha256': ANDROID_SHA, 'engine': lock,
'release': json.loads((product/'release.json').read_text()),
'staging': staging, 'filesystemCheckPassed': True,
- 'distributionContents': distribution,
'flashReady': False, 'hardwareBootTested': False,
'scope': 'Self-contained userspace; vendor/firmware partitions still required; RAM boot candidate',
'sourceHashes': {str(p.relative_to(PROJECT)): digest(p) for p in
diff --git a/tundra/tools/collect-sargo-installation.py b/tundra/tools/collect-sargo-installation.py
deleted file mode 100644
index 12e381a5..00000000
--- a/tundra/tools/collect-sargo-installation.py
+++ /dev/null
@@ -1,80 +0,0 @@
-#!/usr/bin/env python3
-# SPDX-License-Identifier: MPL-2.0
-"""Run as root on Sargo to collect boot backups and its existing ext4 layout.
-
-Only regular output files are written. No partition, slot, filesystem, service
-or network configuration is changed. Transfer the private output to your PC.
-"""
-import argparse
-import datetime
-import fcntl
-import hashlib
-import importlib.util
-import json
-import os
-from pathlib import Path
-import stat
-import struct
-
-from boot_backup import SIZES, verify
-
-
-def identity(proc=Path('/proc')):
- compatible = (proc/'device-tree/compatible').read_bytes().split(b'\0')
- model = (proc/'device-tree/model').read_text().strip('\0')
- if b'google,b4s4-sdm670' not in compatible or ' S4 ' not in model:
- raise ValueError('Expected the Sargo device tree')
- cmdline = dict(x.split('=', 1) for x in (proc/'cmdline').read_text().split() if '=' in x)
- serial = cmdline.get('androidboot.serialno', '')
- if not serial or cmdline.get('androidboot.slot_suffix') != '_a':
- raise ValueError('Expected a device serial and active slot A')
- return {'serialSha256': hashlib.sha256(serial.encode()).hexdigest(), 'activeSlot': 'a'}
-
-
-def collect(mountpoint, output):
- if os.geteuid() != 0:
- raise ValueError('Raw partition backup requires root on the phone')
- device = identity()
- spec = importlib.util.spec_from_file_location('storage', Path(__file__).with_name('inventory-storage.py'))
- storage = importlib.util.module_from_spec(spec); spec.loader.exec_module(storage)
- layout = {**storage.inventory(mountpoint), **device}
- output = Path(output).absolute()
- output.mkdir(mode=0o700)
- (output/'.incomplete').touch()
- backup = output/'backup'; backup.mkdir(mode=0o700)
- manifest = {'device': 'sargo', 'rawPartitions': True, **device,
- 'recordedAt': datetime.datetime.now(datetime.timezone.utc).isoformat(),
- 'files': {}, 'scope': 'Boot, DTBO and vbmeta only; not a backup of user data'}
- for name, size in SIZES.items():
- path = (Path('/dev/disk/by-partlabel')/name.removesuffix('.img')).resolve(strict=True)
- fd = os.open(path, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW)
- with os.fdopen(fd, 'rb', buffering=0) as source:
- if not stat.S_ISBLK(os.fstat(source.fileno()).st_mode):
- raise ValueError('Expected a partition block device')
- actual = struct.unpack('Q', fcntl.ioctl(source.fileno(), 0x80081272, bytes(8)))[0]
- if actual != size:
- raise ValueError('Unexpected partition size: '+name)
- sha = hashlib.sha256(); left = size
- with (backup/name).open('xb') as target:
- while left:
- block = source.read(min(left, 1024*1024))
- if not block:
- raise ValueError('Truncated partition read: '+name)
- target.write(block); sha.update(block); left -= len(block)
- target.flush(); os.fsync(target.fileno())
- manifest['files'][name] = {'bytes': size, 'sha256': sha.hexdigest()}
- if identity() != device or storage.inventory(mountpoint) != {k:v for k,v in layout.items() if k not in device}:
- raise ValueError('Device identity or storage changed during collection')
- (backup/'manifest.json').write_text(json.dumps(manifest, indent=2)+'\n')
- (output/'layout.json').write_text(json.dumps(layout, indent=2)+'\n')
- verify(backup)
- (output/'.incomplete').unlink()
- return output
-
-
-if __name__ == '__main__':
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument('--mountpoint', required=True)
- parser.add_argument('--output', required=True, type=Path)
- args = parser.parse_args(); os.umask(0o077)
- print(collect(args.mountpoint, args.output))
diff --git a/tundra/tools/diagnose-telephony.py b/tundra/tools/diagnose-telephony.py
deleted file mode 100644
index 5d8d7564..00000000
--- a/tundra/tools/diagnose-telephony.py
+++ /dev/null
@@ -1,116 +0,0 @@
-#!/usr/bin/env python3
-# SPDX-License-Identifier: MPL-2.0
-"""Report native telephony health without dialing or exposing subscriber data."""
-import datetime
-import json
-from pathlib import Path
-import re
-import subprocess
-
-
-FIELDS = {
- 'Modem': ('Powered', 'Online', 'Emergency', 'Revision'),
- 'SimManager': ('Present', 'PinRequired', 'LockedPins'),
- 'NetworkRegistration': ('Status', 'Mode', 'Technology', 'Strength'),
- 'RadioSettings': ('TechnologyPreference', 'AvailableTechnologies'),
- 'ConnectionManager': ('Attached', 'Powered', 'RoamingAllowed'),
- 'IpMultimediaSystem': ('Registered', 'VoiceCapable', 'SmsCapable'),
- 'CallVolume': ('Muted', 'SpeakerVolume', 'MicrophoneVolume'),
-}
-
-
-def select_properties(interface, properties):
- return {key: properties[key].get('data') for key in FIELDS[interface]
- if isinstance(properties.get(key), dict) and 'data' in properties[key]}
-
-
-def registration_events(text):
- """Keep RAT and denial reason; never copy cell location or raw log lines."""
- events = []
- pattern = re.compile(
- r'binder_network_poll_(voice|data)_state_cb\(\).*?slot\d+ '
- r'(registered|roaming|searching|unregistered|denied|unknown),'
- r'([^,]*),(-?\d+),(-?\d+),(-?\d+),(-?\d+)')
- for line in text.splitlines():
- match = pattern.search(line)
- if not match:
- continue
- domain, status, technology, rat, _lac, _cell, reason = match.groups()
- event = {'domain': domain, 'status': status,
- 'technology': technology, 'radioTechnology': int(rat),
- 'denialReason': int(reason)}
- if not events or event != events[-1]:
- events.append(event)
- return events[-40:]
-
-
-def run(command):
- return subprocess.run(command, capture_output=True, text=True, timeout=12)
-
-
-def modem_crashes(text):
- signals = []
- for line in text.splitlines():
- if 'qcrild' not in line:
- continue
- match = re.search(r'Fatal signal (\d+) \((SIG[A-Z]+)\)', line)
- if match:
- event = {'signal': int(match[1]), 'name': match[2]}
- timestamp = re.match(r'\s*(\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+)\s', line)
- if timestamp:
- event['logTime'] = timestamp[1]
- signals.append(event)
- return {'signals': signals,
- 'initialAttachTimeoutInBacktrace': 'qcril_qmi_nas_initial_attach_timeout_handler' in text}
-
-
-def collect(command=run, root=Path('/')):
- report = {'recordedAt': datetime.datetime.now(datetime.timezone.utc).isoformat(),
- 'properties': {}, 'services': {}, 'errors': {}}
- for interface in FIELDS:
- try:
- result = command(['busctl', '--system', '--timeout=8', '--json=short',
- 'call', 'org.ofono', '/ril_0',
- 'org.ofono.' + interface, 'GetProperties'])
- if result.returncode:
- report['errors'][interface] = 'unavailable'
- continue
- data = json.loads(result.stdout)['data'][0]
- report['properties'][interface] = select_properties(interface, data)
- except (OSError, subprocess.TimeoutExpired, ValueError, KeyError, IndexError, TypeError):
- report['errors'][interface] = 'unavailable'
- for service in ('ofono', 'tundra-radio', 'tundra-sms', 'tundra-audio', 'tundra-call-audio'):
- try:
- result = command(['systemctl', 'is-active', service])
- state = result.stdout.strip()
- report['services'][service] = state if state in (
- 'active', 'inactive', 'failed', 'activating', 'deactivating') else 'unknown'
- except (OSError, subprocess.TimeoutExpired):
- report['services'][service] = 'unknown'
- try:
- result = command(['journalctl', '-u', 'ofono', '--since', '-10 min',
- '--no-pager', '-n', '2000'])
- report['registrationEvents'] = registration_events(result.stdout)
- report['registrationLogAvailable'] = result.returncode == 0
- except (OSError, subprocess.TimeoutExpired):
- report['registrationEvents'] = []
- report['registrationLogAvailable'] = False
- try:
- result = command(['lxc-attach', '-n', 'tundra-hal', '--', '/system/bin/logcat',
- '-b', 'crash', '-d', '-t', '300'])
- report['qualcommCrashes'] = modem_crashes(result.stdout)
- report['qualcommCrashes']['logAvailable'] = result.returncode == 0
- except (OSError, subprocess.TimeoutExpired):
- report['qualcommCrashes'] = {'logAvailable': False}
- try:
- data = json.loads((root/'run/tundra-call-audio/status.json').read_text())
- report['audio'] = {key: data[key] for key in (
- 'active', 'routed', 'speaker', 'muted', 'volume', 'recordedAt') if key in data}
- report['audio']['hasError'] = bool(data.get('error'))
- except (OSError, ValueError, TypeError):
- report['audio'] = {'available': False}
- return report
-
-
-if __name__ == '__main__':
- print(json.dumps(collect(), indent=2))
diff --git a/tundra/tools/export-sargo-recovery.py b/tundra/tools/export-sargo-recovery.py
deleted file mode 100644
index e05a25fa..00000000
--- a/tundra/tools/export-sargo-recovery.py
+++ /dev/null
@@ -1,70 +0,0 @@
-#!/usr/bin/env python3
-# SPDX-License-Identifier: MPL-2.0
-"""Export the qualified original boot backup and a standalone restoration tool.
-
-The kit is private and specific to one phone. Keep it outside the project and
-do not upload it as a release. Exporting performs no phone operations.
-"""
-import argparse
-import importlib.util
-import json
-import os
-from pathlib import Path
-import shutil
-from tundra import ROOT, digest
-
-
-def load(name, path):
- spec = importlib.util.spec_from_file_location(name, path)
- module = importlib.util.module_from_spec(spec)
- spec.loader.exec_module(module)
- return module
-
-
-def export(plan, output):
- output = Path(output).absolute()
- if output.exists() or output.is_symlink():
- raise ValueError('Recovery output exists; keep the existing backup')
- backup = Path(plan['backupDirectory'])
- from boot_backup import verify
- manifest = verify(backup)
- if digest(backup/'manifest.json') != plan['backupManifestSha256']:
- raise ValueError('Backup manifest changed')
- output.mkdir(mode=0o700)
- marker = output/'.incomplete'
- marker.write_text('Recovery export incomplete\n')
- shutil.copyfile(backup/'boot_a.img', output/'boot_a.img')
- (output/'boot_a.img').chmod(0o600)
- tool = ROOT/'tools/restore-sargo.py'
- shutil.copyfile(tool, output/tool.name)
- (output/tool.name).chmod(0o700)
- recovery = {'schema': 1, 'device': 'sargo', 'partition': 'boot_a', 'slot': 'a',
- 'serialSha256': plan['serialSha256'], 'image': 'boot_a.img',
- 'bytes': manifest['files']['boot_a.img']['bytes'],
- 'imageSha256': manifest['files']['boot_a.img']['sha256'],
- 'backupManifestSha256': plan['backupManifestSha256'],
- 'restoreToolSha256': digest(tool), 'private': True,
- 'scope': 'Original qualified boot_a only; not a userdata backup or a generic ROM'}
- (output/'recovery.json').write_text(json.dumps(recovery, indent=2)+'\n')
- load('restore_check', tool).checked_kit(output)
- marker.unlink()
- return output
-
-
-def main():
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument('--output', type=Path, required=True)
- parser.add_argument('--device-workspace', type=Path)
- args = parser.parse_args()
- os.umask(0o077)
- if args.device_workspace:
- from sargo_workspace import checked_workspace
- plan, _ = checked_workspace(args.device_workspace)
- else:
- plan = load('backup_plan', ROOT/'tools/test-sargo-boot.py').checked_backup_plan()
- print(export(plan, args.output))
- print('Keep this private kit on another disk. Its boot image belongs only to this phone.')
-
-
-if __name__ == '__main__':
- main()
diff --git a/tundra/tools/flash-standalone-sargo.py b/tundra/tools/flash-standalone-sargo.py
deleted file mode 100644
index f7a01d1b..00000000
--- a/tundra/tools/flash-standalone-sargo.py
+++ /dev/null
@@ -1,116 +0,0 @@
-#!/usr/bin/env python3
-# SPDX-License-Identifier: MPL-2.0
-"""Install a standalone Sargo package using Fastboot only (Python 3, any host OS).
-
-Default: verify files and display the destructive installation plan.
---install --erase-userdata replaces the old OS and ALL files in userdata.
-Keep a separate backup. The unlocked bootloader and device calibration remain.
-"""
-import argparse
-import hashlib
-import json
-from pathlib import Path
-import re
-import shutil
-import struct
-import subprocess
-import sys
-
-PARTITIONS=('userdata','dtbo_a','vbmeta_a','boot_a')
-NAMES={'userdata':'userdata.img','dtbo_a':'dtbo.img','vbmeta_a':'vbmeta.img','boot_a':'boot.img'}
-
-
-def digest(path):
- sha=hashlib.sha256()
- with path.open('rb') as source:
- while block:=source.read(1024*1024):sha.update(block)
- return sha.hexdigest()
-
-
-def sparse_size(path):
- with path.open('rb') as source:header=source.read(28)
- if len(header)!=28:raise ValueError('Truncated userdata image')
- magic,major,minor,file_header,chunk_header,block_size,blocks,chunks,crc=struct.unpack('size:raise ValueError('Image exceeds partition: '+partition)
- return info
-
-
-def main():
- parser=argparse.ArgumentParser(description=__doc__)
- parser.add_argument('--package',type=Path,default=Path(__file__).resolve().parent)
- parser.add_argument('--serial')
- parser.add_argument('--install',action='store_true')
- parser.add_argument('--erase-userdata',action='store_true')
- parser.add_argument('--experimental',action='store_true')
- parser.add_argument('--reboot',action='store_true')
- args=parser.parse_args();value=package(args.package)
- print('Pixel 3a / Sargo: replace userdata, dtbo_a, vbmeta_a and boot_a; activate slot A.')
- print('ALL existing user data and the old OS in userdata will be lost. Bootloader remains unlocked.')
- print('No existing Android or Droidian installation is required.')
- if not args.install:
- print('Review only; no device command sent.');return
- if not args.erase_userdata:parser.error('--install requires --erase-userdata')
- if not value.get('flashQualified') and not args.experimental:
- parser.error('This candidate is not qualified; private tests require --experimental')
- fastboot=shutil.which('fastboot')
- if not fastboot:raise ValueError('Install Android platform-tools (fastboot) on the host computer')
- devices=subprocess.check_output([fastboot,'devices'],text=True,timeout=10).splitlines()
- serials=[line.split()[0] for line in devices if len(line.split())==2 and line.split()[1]=='fastboot']
- serial=args.serial
- if serial is None:
- if len(serials)!=1:raise ValueError('Connect exactly one Pixel or specify --serial')
- serial=serials[0]
- if serial not in serials:raise ValueError('Selected device not in Fastboot')
- info=preflight(fastboot,serial,value)
- report={'deviceSha256':hashlib.sha256(serial.encode()).hexdigest(),'preflight':info,'completed':[],'passed':False}
- path=args.package/'install-report.json'
- def save():path.write_text(json.dumps(report,indent=2)+'\n')
- save()
- subprocess.run([fastboot,'-s',serial,'erase','userdata'],check=True,timeout=300)
- report['completed'].append('erase:userdata');save()
- for partition in PARTITIONS:
- subprocess.run([fastboot,'-s',serial,'flash',partition,str((args.package/NAMES[partition]).resolve())],check=True,timeout=1200)
- report['completed'].append(partition);save()
- subprocess.run([fastboot,'-s',serial,'set_active','a'],check=True,timeout=30)
- report['passed']=True;save()
- if args.reboot:subprocess.run([fastboot,'-s',serial,'reboot'],check=True,timeout=30)
- print('Images written. Keep the bootloader unlocked. Select Start to launch Vulpes.')
-
-
-if __name__=='__main__':main()
diff --git a/tundra/tools/install-sargo.py b/tundra/tools/install-sargo.py
index 6263b261..4214fca4 100644
--- a/tundra/tools/install-sargo.py
+++ b/tundra/tools/install-sargo.py
@@ -23,8 +23,6 @@ def load(name, path):
def checked_build(folder):
folder=Path(folder).resolve()
build=json.loads((folder/'build.json').read_text())
- if build.get('storageMode') in ('raw-userdata-ext4', 'container-test'):
- raise ValueError('Use the standalone installer; the old boot-only plan is incompatible')
if build.get('localDebugger'):raise ValueError('Diagnostic debugger image cannot be flashed')
qualification=json.loads((folder/'qualification.json').read_text())
if (not qualification.get('passed') or not qualification.get('privateInstallReady')
@@ -55,32 +53,18 @@ def flash_boot_a(serial, image):
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--build',type=Path,required=True)
- parser.add_argument('--device-workspace',type=Path)
action=parser.add_mutually_exclusive_group()
action.add_argument('--flash',action='store_true',help='Install this boot image in boot_a; does not reboot automatically')
action.add_argument('--restore',action='store_true',help='Restore the verified original boot_a backup; retain Tundra data')
args=parser.parse_args();os.umask(0o077)
boot=load('sargo_boot',ROOT/'tools/test-sargo-boot.py')
- workspace_plan = None
- if args.device_workspace:
- from sargo_workspace import checked_workspace
- workspace_plan, workspace_layout = checked_workspace(args.device_workspace)
+ plan,_=boot.checked_plan()
if args.restore:
# Recovery must still work if the candidate or its proof was damaged.
- plan=workspace_plan or boot.checked_backup_plan()
image=Path(plan['backupDirectory'])/'boot_a.img'
build={'serialSha256':plan['serialSha256']}
else:
build,qualification,image=checked_build(args.build)
- if workspace_plan:
- plan = workspace_plan
- if (build.get('deviceWorkspaceSha256') != plan['workspaceSha256']
- or build['layout'] != workspace_layout):
- raise ValueError('Build was not prepared with this device workspace')
- else:
- if build.get('deviceWorkspaceSha256'):
- raise ValueError('This build requires --device-workspace; no fallback to shared inventory')
- plan,_=boot.checked_plan()
if plan['serialSha256']!=build['serialSha256']:raise ValueError('Build targets another phone')
print('Private Sargo installation plan')
print('Boot image:',image)
diff --git a/tundra/tools/inventory-storage.py b/tundra/tools/inventory-storage.py
deleted file mode 100644
index f26417f7..00000000
--- a/tundra/tools/inventory-storage.py
+++ /dev/null
@@ -1,55 +0,0 @@
-#!/usr/bin/env python3
-# SPDX-License-Identifier: MPL-2.0
-"""Inventory an existing Sargo ext4/LVM mount without changing any storage."""
-import argparse
-import hashlib
-import json
-import os
-from pathlib import Path
-import stat
-import subprocess
-
-from storage_layout import mount_table
-
-
-def command(*args):
- return subprocess.check_output(args, text=True, timeout=15,
- env={**os.environ, 'PATH': '/usr/sbin:/usr/bin:/sbin:/bin', 'LC_ALL': 'C'}).strip()
-
-
-def inventory(mountpoint):
- device = Path('/dev/mmcblk0p72')
- if not stat.S_ISBLK(device.stat().st_mode):
- raise ValueError('Sargo userdata block device is missing')
- label = command('blkid', '-s', 'PARTLABEL', '-o', 'value', str(device))
- if label != 'userdata':
- raise ValueError('Unexpected partition label')
- mounted = json.loads(command('findmnt', '--json', '--mountpoint', mountpoint,
- '--output', 'SOURCE,FSTYPE'))['filesystems']
- if len(mounted) != 1 or mounted[0]['fstype'] != 'ext4':
- raise ValueError('Expected one existing ext4 mount')
- source = mounted[0]['source']
- if not source.startswith('/dev/mapper/'):
- raise ValueError('Only the existing single-extent LVM layout is supported')
- table = command('dmsetup', 'table', source)
- with device.open('rb', buffering=0) as stream:
- first = stream.read(1048576)
- if len(first) != 1048576:
- raise ValueError('Cannot read userdata identity')
- layout = {
- 'schema': 2, 'device': str(device), 'partitionLabel': label,
- 'filesystem': mounted[0]['fstype'], 'table': table,
- 'backingDevice': Path('/sys/class/block/mmcblk0p72/dev').read_text().strip(),
- 'size': int(command('blockdev', '--getsize64', str(device))),
- 'firstMiBSha256': hashlib.sha256(first).hexdigest(),
- }
- mount_table(layout)
- return layout
-
-
-if __name__ == '__main__':
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument('--mountpoint', required=True,
- help='Existing root mount, or /reference/installed under Tundra')
- args = parser.parse_args()
- print(json.dumps(inventory(args.mountpoint), indent=2))
diff --git a/tundra/tools/package-standalone-sargo.py b/tundra/tools/package-standalone-sargo.py
deleted file mode 100644
index 0390514b..00000000
--- a/tundra/tools/package-standalone-sargo.py
+++ /dev/null
@@ -1,63 +0,0 @@
-#!/usr/bin/env python3
-# SPDX-License-Identifier: MPL-2.0
-"""Package boot, fresh userdata and boot metadata for a standalone installation.
-
-Builds local artifacts only. A candidate is never marked flash-qualified here.
-Private development credentials, if present in boot, prohibit redistribution.
-"""
-import argparse
-import importlib.util
-import json
-import os
-from pathlib import Path
-import shutil
-import subprocess
-from standalone_volume import checked_volume
-from tundra import ROOT,digest
-
-
-def main():
- parser=argparse.ArgumentParser(description=__doc__)
- parser.add_argument('--build',required=True,type=Path)
- parser.add_argument('--volume',required=True,type=Path)
- parser.add_argument('--dtbo',required=True,type=Path)
- parser.add_argument('--output',required=True,type=Path)
- args=parser.parse_args();os.umask(0o077)
- volume=checked_volume(args.volume);build=json.loads((args.build/'build.json').read_text())
- if build.get('storageMode')!='raw-userdata-ext4' or build.get('watchdogSeconds') is not None or build.get('localDebugger'):
- raise ValueError('Expected a persistent standalone boot without debugger or timed reboot')
- if build['standaloneVolume']!=volume or not volume.get('hardwareImages'):
- raise ValueError('Boot must match a volume with bundled vendor/modem images')
- boot=args.build/build['image']
- if digest(boot)!=build['imageSha256']:raise ValueError('Boot image changed')
- spec=importlib.util.spec_from_file_location('sources',ROOT/'tools/test-rescue-boot.py')
- sources=importlib.util.module_from_spec(spec);spec.loader.exec_module(sources);sources.verify_sources(args.build,build)
- lock=json.loads((ROOT/'manifests/sargo-hardware.json').read_text())
- if args.dtbo.stat().st_size!=lock['dtbo']['bytes'] or digest(args.dtbo)!=lock['dtbo']['sha256']:
- raise ValueError('Unqualified device-tree overlay')
- out=args.output.resolve();out.mkdir(parents=True,exist_ok=False)
- (out/'.incomplete').touch()
- shutil.copyfile(boot,out/'boot.img');shutil.copyfile(args.dtbo,out/'dtbo.img')
- subprocess.run(['avbtool','make_vbmeta_image','--flags','3','--padding_size','4096','--algorithm','NONE','--output',str(out/'vbmeta.img')],check=True)
- subprocess.run(['img2simg',str(args.volume.parent/volume['image']),str(out/'userdata.img')],check=True)
- # Roundtrip verifies the actual sparse payload, not only its header.
- restored=out/'userdata-roundtrip.ext4'
- subprocess.run(['simg2img',str(out/'userdata.img'),str(restored)],check=True)
- if digest(restored)!=volume['imageSha256']:raise ValueError('Sparse image roundtrip mismatch')
- restored.unlink()
- files={name:{'file':file,'bytes':(out/file).stat().st_size,'sha256':digest(out/file)}
- for name,file in [('boot_a','boot.img'),('userdata','userdata.img'),('dtbo_a','dtbo.img'),('vbmeta_a','vbmeta.img')]}
- value={'schema':1,'device':'sargo','storage':'raw-userdata-ext4','partitions':files,
- 'userdataBytes':volume['bytes'],'userdataRawSha256':volume['imageSha256'],
- 'volumeUuid':volume['uuid'],'systemImageSha256':volume['systemImageSha256'],
- 'flashQualified':False,'containsPrivateMaintenanceKey':bool(build.get('containsPrivateTrialHostKey')),
- 'redistributionReady':False,'previousOsRequired':False,
- 'requirements':['Unlocked Pixel 3a (sargo)','Original device calibration partitions retained'],
- 'scope':'Experimental standalone installation candidate; userdata is replaced in full'}
- (out/'package.json').write_text(json.dumps(value,indent=2)+'\n')
- shutil.copyfile(ROOT/'tools/flash-standalone-sargo.py',out/'flash-standalone-sargo.py')
- (out/'SHA256SUMS').write_text(''.join(digest(p)+' '+p.name+'\n' for p in sorted(out.iterdir()) if p.is_file() and p.name not in ('.incomplete','SHA256SUMS')))
- (out/'.incomplete').unlink();print(out/'package.json')
-
-
-if __name__=='__main__':main()
diff --git a/tundra/tools/prepare-sargo-device.py b/tundra/tools/prepare-sargo-device.py
deleted file mode 100644
index 111ea72e..00000000
--- a/tundra/tools/prepare-sargo-device.py
+++ /dev/null
@@ -1,67 +0,0 @@
-#!/usr/bin/env python3
-# SPDX-License-Identifier: MPL-2.0
-"""Import a phone's own boot backups and storage inventory into a private workspace.
-
-Inputs must come from collect-sargo-installation.py on that phone. This does not
-format, connect to, or flash a phone. Stock Android/F2FS is not supported.
-"""
-import argparse
-import json
-import os
-from pathlib import Path
-import shutil
-
-from boot_backup import verify
-from sargo_workspace import boot_kernel, checked_workspace
-from storage_layout import mount_table
-from tundra import digest
-
-
-def prepare(source, output):
- source, output = Path(source).resolve(), Path(output).absolute()
- if (source/'.incomplete').exists():
- raise ValueError('Source collection did not finish')
- if output.exists() or output.is_symlink():
- raise ValueError('Workspace exists; existing backups will not be overwritten')
- manifest = verify(source/'backup')
- layout = json.loads((source/'layout.json').read_text())
- import re
- serial = manifest.get('serialSha256', '')
- if (not re.fullmatch('[a-f0-9]{64}', serial) or layout.get('serialSha256') != serial
- or manifest.get('activeSlot') != 'a' or layout.get('activeSlot') != 'a'):
- raise ValueError('Collect a bound backup and storage inventory from the same Sargo on slot A')
- mount_table(layout)
- kernel = boot_kernel(source/'backup/boot_a.img')
- output.mkdir(mode=0o700)
- (output/'.incomplete').touch()
- (output/'backup').mkdir(mode=0o700)
- for name in ('manifest.json', *manifest['files']):
- shutil.copyfile(source/'backup'/name, output/'backup'/name)
- (output/'backup'/name).chmod(0o600)
- shutil.copyfile(source/'layout.json', output/'layout.json')
- value = {'schema': 1, 'device': 'sargo', 'activeSlot': 'a', 'serialSha256': serial,
- 'kernelSha256': kernel, 'private': True, 'flashReady': False,
- 'files': {name: digest(output/name) for name in ('backup/manifest.json', 'layout.json')}}
- (output/'device.json').write_text(json.dumps(value, indent=2)+'\n')
- # Verify the copied inputs before exposing a usable workspace.
- verify(output/'backup')
- for name, sha in value['files'].items():
- if digest(output/name) != sha:
- raise ValueError('Copied input changed: '+name)
- (output/'.incomplete').unlink()
- checked_workspace(output)
- return output
-
-
-def main():
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument('--inventory', type=Path, required=True)
- parser.add_argument('--output', type=Path, required=True)
- args = parser.parse_args()
- os.umask(0o077)
- print(prepare(args.inventory, args.output))
- print('Private per-phone workspace verified. No phone operation performed.')
-
-
-if __name__ == '__main__':
- main()
diff --git a/tundra/tools/prepare-sargo-package.py b/tundra/tools/prepare-sargo-package.py
deleted file mode 100644
index 8623af79..00000000
--- a/tundra/tools/prepare-sargo-package.py
+++ /dev/null
@@ -1,184 +0,0 @@
-#!/usr/bin/env python3
-# SPDX-License-Identifier: MPL-2.0
-"""Verify a Sargo system archive and prepare fresh local installation files.
-
-This tool never connects to a phone. The result is a userspace image and a new
-data image, not a flashable ROM. A device-specific boot/recovery qualification
-is still required. Get the manifest checksum through a trusted release channel.
-"""
-import argparse
-import hashlib
-import json
-import lzma
-import os
-from pathlib import Path
-import re
-import shutil
-import subprocess
-import sys
-
-from persistent_state import digest, validate_manifest
-
-CHUNK = 1024 * 1024
-TOOLS = Path(__file__).resolve().parent
-
-
-def sha256(value):
- if not isinstance(value, str) or not re.fullmatch(r'[a-f0-9]{64}', value):
- raise ValueError('Expected a SHA256 checksum')
- return value
-
-
-def checked_manifest(path, expected):
- path = Path(path)
- if path.is_symlink() or not path.is_file() or path.stat().st_size > CHUNK:
- raise ValueError('Expected a regular package manifest smaller than 1 MiB')
- data = path.read_bytes()
- if hashlib.sha256(data).hexdigest() != sha256(expected):
- raise ValueError('Package manifest checksum mismatch')
- package = json.loads(data)
- if package.get('device') != 'sargo':
- raise ValueError('Only Sargo system packages are supported')
- name = package.get('systemImage', '')
- if not isinstance(name, str) or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]*\.ext4\.xz', name):
- raise ValueError('Archive must be a plain .ext4.xz filename')
- if not isinstance(package.get('version'), str) or not package['version'].strip():
- raise ValueError('Missing package version')
- for key in ('archiveSha256', 'systemImageSha256'):
- sha256(package.get(key))
- for key, maximum in (('archiveBytes', 2 * 1024**3), ('uncompressedBytes', 8 * 1024**3)):
- size = package.get(key)
- if type(size) is not int or not 0 < size <= maximum:
- raise ValueError('Invalid '+key)
- if package['uncompressedBytes'] % 4096:
- raise ValueError('System image size must be aligned to 4096 bytes')
- for key in ('filesystemCheckPassed', 'compressionCheckPassed'):
- if package.get(key) is not True:
- raise ValueError('Package has not passed '+key)
- return package
-
-
-def checked_archive(folder, package):
- archive = Path(folder)/package['systemImage']
- if archive.is_symlink() or not archive.is_file():
- raise ValueError('Expected a regular archive beside the manifest')
- if archive.stat().st_size != package['archiveBytes'] or digest(archive) != package['archiveSha256']:
- raise ValueError('System archive checksum or size mismatch')
- return archive
-
-
-def unpack(archive, image, package):
- """Bound both memory and output; reject trailing data and truncated streams."""
- decoder = lzma.LZMADecompressor(format=lzma.FORMAT_XZ, memlimit=128 * CHUNK)
- compressed = hashlib.sha256()
- expanded = hashlib.sha256()
- total = count = 0
- created = False
- try:
- with Path(archive).open('rb') as source, Path(image).open('xb') as target:
- created = True
- while chunk := source.read(CHUNK):
- if decoder.eof:
- raise ValueError('Trailing data after XZ stream')
- compressed.update(chunk)
- count += len(chunk)
- if count > package['archiveBytes']:
- raise ValueError('Archive exceeds declared size')
- while True:
- block = decoder.decompress(chunk, max_length=CHUNK)
- chunk = b''
- total += len(block)
- if total > package['uncompressedBytes']:
- raise ValueError('System image exceeds declared size')
- expanded.update(block)
- target.write(block)
- if decoder.eof or decoder.needs_input:
- break
- if decoder.unused_data:
- raise ValueError('Trailing data after XZ stream')
- if not decoder.eof:
- raise ValueError('Truncated XZ stream')
- if (count != package['archiveBytes'] or total != package['uncompressedBytes']
- or compressed.hexdigest() != package['archiveSha256']
- or expanded.hexdigest() != package['systemImageSha256']):
- raise ValueError('Expanded image or archive checksum mismatch')
- target.flush()
- os.fsync(target.fileno())
- except BaseException:
- if created:
- Path(image).unlink()
- raise
-
-
-def prepare(manifest, expected, output, size_mib):
- if type(size_mib) is not int or not 512 <= size_mib <= 8192:
- raise ValueError('Choose 512–8192 MiB for fresh data')
- package = checked_manifest(manifest, expected)
- archive = checked_archive(Path(manifest).parent, package)
- output = Path(output).absolute()
- if output.exists() or output.is_symlink():
- raise ValueError('Output already exists; existing installation data must not be overwritten')
- if not output.parent.is_dir():
- raise ValueError('Create the output parent directory first')
- for tool in ('e2fsck', 'mke2fs', 'debugfs'):
- if not shutil.which(tool):
- raise ValueError('Missing required program: '+tool)
- required = package['uncompressedBytes'] + size_mib * CHUNK + 256 * CHUNK
- if shutil.disk_usage(output.parent).free < required:
- raise ValueError('Insufficient local disk space for system, data and reserve')
- output.mkdir(mode=0o700)
- marker = output/'.incomplete'
- marker.write_text('Preparation did not finish. Do not use these files.\n')
- image = output/'tundra-sargo-system.ext4'
- unpack(archive, image, package)
- check = subprocess.run(['e2fsck', '-f', '-n', str(image)],
- capture_output=True, text=True, timeout=300)
- (output/'filesystem-check.txt').write_text(check.stdout+check.stderr)
- if check.returncode:
- raise ValueError('System filesystem check failed; no data image created')
- system = {'schema': 1, 'device': 'sargo', 'image': image.name,
- 'imageSha256': package['systemImageSha256'], 'bytes': image.stat().st_size,
- 'filesystemCheckPassed': True, 'flashReady': False,
- 'packageVersion': package['version'], 'packageManifestSha256': expected}
- system_path = output/'system.json'
- system_path.write_text(json.dumps(system, indent=2)+'\n')
- subprocess.run([sys.executable, str(TOOLS/'build-state-image.py'), '--output', str(output/'data'),
- '--system-image', str(system_path), '--size-mib', str(size_mib)],
- check=True, timeout=300)
- state = validate_manifest(output/'data/state.json')
- if state['systemImageSha256'] != system['imageSha256']:
- raise ValueError('Data image belongs to another system image')
- report = {'schema': 1, 'device': 'sargo', 'version': package['version'],
- 'packageManifestSha256': expected, 'systemImageSha256': system['imageSha256'],
- 'stateUuid': state['uuid'], 'localPreparationPassed': True,
- 'phoneTouched': False, 'flashReady': False,
- 'remaining': ['Per-phone storage and boot backup inventory',
- 'Device-specific boot image and recovery qualification',
- 'Transfer, two RAM boots and persistence check before installation'],
- 'supportedStorage': 'Sargo userdata with an inventoried single linear ext4 extent; not stock Android/F2FS'}
- (output/'preparation.json').write_text(json.dumps(report, indent=2)+'\n')
- marker.unlink()
- return report
-
-
-def main():
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument('--manifest', required=True, type=Path)
- parser.add_argument('--manifest-sha256', required=True)
- parser.add_argument('--prepare', type=Path, metavar='NEW_DIRECTORY',
- help='Expand and check the system; create separate fresh data locally')
- parser.add_argument('--data-mib', type=int, default=1024)
- args = parser.parse_args()
- os.umask(0o077)
- if args.prepare:
- report = prepare(args.manifest, args.manifest_sha256, args.prepare, args.data_mib)
- print(json.dumps(report, indent=2))
- else:
- package = checked_manifest(args.manifest, args.manifest_sha256)
- checked_archive(args.manifest.parent, package)
- print('Manifest and compressed archive verified. No extraction or phone operation performed.')
- print('Not a flashable ROM. Never fastboot flash this ext4 file to userdata.')
-
-
-if __name__ == '__main__':
- main()
diff --git a/tundra/tools/refresh-boot-ui.py b/tundra/tools/refresh-boot-ui.py
deleted file mode 100644
index 69cda87a..00000000
--- a/tundra/tools/refresh-boot-ui.py
+++ /dev/null
@@ -1,117 +0,0 @@
-#!/usr/bin/env python3
-# SPDX-License-Identifier: MPL-2.0
-"""Refresh shared Gaia files in a verified private RAM boot, preserving hardware."""
-import argparse
-import importlib.util
-import json
-import os
-from pathlib import Path
-import shlex
-import shutil
-import stat
-import subprocess
-import sys
-import tempfile
-import time
-import io
-import tarfile
-from ramdisk_reference import read_newc
-from shared_ui import build_payload
-from tundra import ROOT, checked_source, cpio, digest, save
-
-
-def main():
- p=argparse.ArgumentParser(description=__doc__)
- p.add_argument('--base',type=Path,required=True)
- p.add_argument('--expected-sha256',required=True)
- p.add_argument('--output',type=Path,required=True)
- p.add_argument('--settings-adapters',action='store_true',help='Also replace native Settings bridges and hotspot firewall configuration')
- args=p.parse_args();os.umask(0o077)
- base=args.base.resolve();out=args.output.resolve()
- build=json.loads((base/'build.json').read_text())
- image=base/build['image']
- if digest(image)!=args.expected_sha256 or build['imageSha256']!=args.expected_sha256:
- raise ValueError('Base image hash mismatch')
- if not build.get('sourceSnapshot') or not build.get('maintenanceEnabled') or build.get('storageMode')!='raw-userdata-ext4':
- raise ValueError('Expected a private standalone build with a source snapshot')
- spec=importlib.util.spec_from_file_location('rescue',ROOT/'tools/test-rescue-boot.py')
- rescue=importlib.util.module_from_spec(spec);spec.loader.exec_module(rescue)
- rescue.verify_sources(base,build)
- payload,hashes=build_payload()
- packager=checked_source('aosp-mkbootimg')
- out.mkdir(parents=True,exist_ok=False)
- with tempfile.TemporaryDirectory(dir=out) as temporary:
- temp=Path(temporary)
- text=subprocess.check_output([sys.executable,str(packager/'unpack_bootimg.py'),'--boot_img',str(image),'--out',str(temp/'base'),'--format=mkbootimg'],text=True)
- options=shlex.split(text);fields=dict(zip(options[::2],options[1::2]))
- if fields['--header_version']!='0' or digest(temp/'base/kernel')!=build['kernelSha256']:
- raise ValueError('Unexpected boot format or kernel')
- original=read_newc((temp/'base/ramdisk').read_bytes())
- if 'shared-ui.tar' not in original or 'standalone-mount.sh' not in original:
- raise ValueError('Missing standalone UI overlay')
- files={name:{**item} for name,item in original.items()}
- files['shared-ui.tar']['data']=payload
- native_sources = {}
- if args.settings_adapters:
- names = ['radio-control.py','wifi-control.py','mobile-settings.py','camera-preview.py']
- native_sources = {'etc/tundra/'+name: ROOT/'runtime'/name for name in names}
- native_sources['etc/NetworkManager/conf.d/80-vulpes-hotspot.conf']=ROOT/'runtime/hotspot-firewall.conf'
- native_sources['etc/systemd/system/tundra-radio.service.d/clock.conf']=ROOT/'runtime/clock-service.conf'
- aliases={'usr/sbin/'+name:name+'-legacy' for name in ('iptables','ip6tables')}
- buffer = io.BytesIO()
- with tarfile.open(fileobj=io.BytesIO(original['native-config.tar']['data'])) as source, tarfile.open(fileobj=buffer,mode='w') as target:
- for member in source.getmembers():
- if member.name in native_sources or member.name in aliases: continue
- target.addfile(member,source.extractfile(member) if member.isfile() else None)
- for name,path in native_sources.items():
- content=path.read_bytes();info=tarfile.TarInfo(name);info.mode=0o644;info.size=len(content)
- info.uid=info.gid=0;target.addfile(info,io.BytesIO(content))
- for name,link in aliases.items():
- info=tarfile.TarInfo(name);info.type=tarfile.SYMTYPE;info.linkname=link;info.mode=0o777
- target.addfile(info)
- files['native-config.tar']['data']=buffer.getvalue()
- entries=[]
- for name,item in sorted(files.items()):
- if (stat.S_ISCHR(item['mode']) or stat.S_ISBLK(item['mode'])) and name!='dev/console':
- raise ValueError('Unrecognized device node: '+name)
- entries.append((name,item['mode'],item['data'],5 if name=='dev/console' else 0,1 if name=='dev/console' else 0))
- ramdisk=out/'initramfs.cpio.gz';ramdisk.write_bytes(cpio(entries))
- fields['--ramdisk']=str(ramdisk)
- command=[sys.executable,str(packager/'mkbootimg.py')]
- for k,v in fields.items():command.extend([k,v])
- target=out/'boot.img'
- subprocess.run(command+['-o',str(target)],check=True)
- if target.stat().st_size>67108864:raise ValueError('Boot partition size exceeded')
- subprocess.run([sys.executable,str(packager/'unpack_bootimg.py'),'--boot_img',str(target),'--out',str(temp/'verify')],check=True,stdout=subprocess.DEVNULL)
- if digest(temp/'verify/kernel')!=build['kernelSha256'] or digest(temp/'verify/ramdisk')!=digest(ramdisk):
- raise ValueError('Boot roundtrip failed')
- verified=read_newc((temp/'verify/ramdisk').read_bytes())
- if verified.keys()!=original.keys():raise ValueError('Ramdisk entry list changed')
- changed=[name for name in original if (verified[name]['mode'],verified[name]['data'])!=(original[name]['mode'],original[name]['data'])]
- permitted={'native-config.tar','shared-ui.tar'} if args.settings_adapters else {'shared-ui.tar'}
- if set(changed)-permitted:raise ValueError('Unexpected changes: '+str(changed))
- # A native-only fix may reproduce the same shared UI archive exactly.
- # Still verify every replacement, including unchanged payloads.
- for name in permitted:
- if (verified[name]['mode'],verified[name]['data']) != (files[name]['mode'],files[name]['data']):
- raise ValueError('Replacement mismatch: '+name)
- shutil.copytree(base/'sources',out/'sources')
- for name in hashes:
- target=out/'sources'/name;target.parent.mkdir(parents=True,exist_ok=True)
- shutil.copy2(ROOT.parent/name,target)
- for path in (Path(__file__).resolve(),ROOT/'tools/shared_ui.py',*native_sources.values(),*([ROOT/'runtime/prepare-native.py'] if args.settings_adapters else [])):
- target=out/'sources'/path.relative_to(ROOT.parent)
- target.parent.mkdir(parents=True,exist_ok=True);shutil.copy2(path,target)
- build['sources'][str(path.relative_to(ROOT))]=digest(path)
- shutil.copy2(base/'client-key',out/'client-key')
- build.update(image='boot.img',imageSha256=digest(out/'boot.img'),ramdiskSha256=digest(ramdisk),
- sharedUiHashes=hashes,builtAtUnix=int(time.time()),flashReady=False,
- baseImageSha256=args.expected_sha256,uiOnlyRefresh=not args.settings_adapters,changedRamdiskEntries=changed,
- scope='Private RAM Settings update; kernel and storage configuration preserved')
- save(out/'build.json',build)
- rescue.verify_sources(out,build)
- print(json.dumps({'image':str(out/'boot.img'),'sha256':build['imageSha256'],
- 'changed':changed,'sharedFiles':len(hashes),'flashed':False}))
-
-
-if __name__=='__main__':main()
diff --git a/tundra/tools/rescue-ssh.py b/tundra/tools/rescue-ssh.py
index 957b7faa..88c5c92e 100644
--- a/tundra/tools/rescue-ssh.py
+++ b/tundra/tools/rescue-ssh.py
@@ -24,7 +24,7 @@ def main():
if not (folder/'boot-report.json').is_file():
raise ValueError('This image has not been sent with the checked boot tool')
ssh=['ssh','-F','/dev/null','-o','BatchMode=yes','-o','IdentitiesOnly=yes',
- '-o','ConnectTimeout=5','-o','ServerAliveInterval=5','-o','ServerAliveCountMax='+('60' if args.stream else '6'),
+ '-o','ConnectTimeout=5','-o','ServerAliveInterval=5','-o','ServerAliveCountMax=6',
'-o','StrictHostKeyChecking=yes','-o','HostKeyAlgorithms=+ssh-rsa',
'-o','UserKnownHostsFile='+str(folder/'known_hosts'),'-i',str(folder/'client-key'),
'-p','2222','root@10.15.19.82']
diff --git a/tundra/tools/restore-sargo.py b/tundra/tools/restore-sargo.py
deleted file mode 100644
index 167ed039..00000000
--- a/tundra/tools/restore-sargo.py
+++ /dev/null
@@ -1,109 +0,0 @@
-#!/usr/bin/env python3
-# SPDX-License-Identifier: MPL-2.0
-"""Restore boot_a from a private recovery kit. Runs without the Vulpes checkout.
-
-By default this only verifies local files. --restore writes the saved boot_a
-to the matching unlocked Sargo on slot A. It never erases userdata, changes
-slots or reboots automatically. This does not restore or back up user data.
-"""
-import argparse
-import hashlib
-import json
-import os
-from pathlib import Path
-import re
-import subprocess
-
-BOOT_SIZE = 67108864
-
-
-def digest(path):
- with Path(path).open('rb') as stream:
- return hashlib.file_digest(stream, 'sha256').hexdigest()
-
-
-def checked_kit(folder):
- folder = Path(folder)
- path = folder/'recovery.json'
- if path.is_symlink() or not path.is_file() or path.stat().st_size > 65536:
- raise ValueError('Expected a regular recovery manifest')
- manifest = json.loads(path.read_text())
- if (manifest.get('schema') != 1 or manifest.get('device') != 'sargo'
- or manifest.get('partition') != 'boot_a' or manifest.get('slot') != 'a'
- or manifest.get('image') != 'boot_a.img' or manifest.get('bytes') != BOOT_SIZE):
- raise ValueError('Expected a Sargo slot A recovery kit')
- for key in ('serialSha256', 'imageSha256', 'backupManifestSha256'):
- if not re.fullmatch(r'[a-f0-9]{64}', str(manifest.get(key, ''))):
- raise ValueError('Missing recovery checksum: '+key)
- image = folder/'boot_a.img'
- if image.is_symlink() or not image.is_file() or image.stat().st_size != BOOT_SIZE:
- raise ValueError('Saved boot_a is missing or has the wrong size')
- if digest(image) != manifest['imageSha256']:
- raise ValueError('Saved boot_a checksum mismatch')
- with image.open('rb') as stream:
- if stream.read(8) != b'ANDROID!':
- raise ValueError('Saved boot_a has no Android boot header')
- return manifest, image
-
-
-def getvar(serial, name):
- result = subprocess.run(['fastboot', '-s', serial, 'getvar', name],
- capture_output=True, text=True, check=True, timeout=15)
- for line in (result.stdout+'\n'+result.stderr).splitlines():
- match = re.fullmatch(r'(?:\(bootloader\)\s*)?'+re.escape(name)+r':\s*(.+)', line.strip())
- if match:
- return match[1].strip()
- raise ValueError('Fastboot did not return '+name)
-
-
-def checked_device(manifest):
- output = subprocess.check_output(['fastboot', 'devices'], text=True, timeout=10)
- matches = []
- for line in output.splitlines():
- fields = line.split()
- if (len(fields) == 2 and fields[1] == 'fastboot'
- and hashlib.sha256(fields[0].encode()).hexdigest() == manifest['serialSha256']):
- matches.append(fields[0])
- if len(matches) != 1:
- raise ValueError('The phone belonging to this backup is not uniquely present in Fastboot')
- serial = matches[0]
- values = {name: getvar(serial, name) for name in
- ('product', 'unlocked', 'current-slot', 'partition-size:boot_a')}
- if (values['product'] != 'sargo' or values['unlocked'] != 'yes'
- or values['current-slot'] != 'a'
- or int(values['partition-size:boot_a'], 16) != BOOT_SIZE):
- raise ValueError('Expected the matching unlocked Sargo on slot A with a 64 MiB boot partition')
- return serial
-
-
-def main():
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument('--kit', type=Path, default=Path(__file__).resolve().parent)
- parser.add_argument('--restore', action='store_true')
- args = parser.parse_args()
- os.umask(0o077)
- if (args.kit/'.incomplete').exists():
- raise ValueError('Recovery kit export did not finish')
- manifest, image = checked_kit(args.kit)
- print('Saved boot_a verified:', manifest['imageSha256'])
- if not args.restore:
- print('Local verification only. No phone command sent.')
- return
- serial = checked_device(manifest)
- # Verify again after connecting, before allowing the only write command.
- current, image = checked_kit(args.kit)
- if current != manifest:
- raise ValueError('Recovery kit changed during device verification')
- report = {'operation': 'restore', 'partition': 'boot_a',
- 'imageSha256': manifest['imageSha256'], 'completed': False}
- path = args.kit/'restore-report.json'
- path.write_text(json.dumps(report, indent=2)+'\n')
- subprocess.run(['fastboot', '-s', serial, 'flash', 'boot_a', str(image.resolve())],
- check=True, timeout=90)
- report['completed'] = True
- path.write_text(json.dumps(report, indent=2)+'\n')
- print('Saved boot_a restored. Select Start on the phone. No userdata erase or slot change sent.')
-
-
-if __name__ == '__main__':
- main()
diff --git a/tundra/tools/restore-standalone-sargo.py b/tundra/tools/restore-standalone-sargo.py
deleted file mode 100644
index e08f0b9e..00000000
--- a/tundra/tools/restore-standalone-sargo.py
+++ /dev/null
@@ -1,83 +0,0 @@
-#!/usr/bin/env python3
-# SPDX-License-Identifier: MPL-2.0
-"""Restore a private full-storage backup after a standalone Sargo test.
-
-Default: verify the archive and partition backups only. --restore overwrites
-userdata and the three slot-A boot partitions. Backups are device-specific.
-"""
-import argparse
-import gzip
-import hashlib
-import json
-from pathlib import Path
-import shutil
-import subprocess
-
-
-def digest(path):
- sha=hashlib.sha256()
- with path.open('rb') as source:
- while block:=source.read(4*1024**2):sha.update(block)
- return sha.hexdigest()
-
-
-def main():
- parser=argparse.ArgumentParser(description=__doc__)
- parser.add_argument('--backup',required=True,type=Path)
- parser.add_argument('--restore',action='store_true')
- args=parser.parse_args();folder=args.backup.resolve()
- manifest=json.loads((folder/'recovery.json').read_text())
- if manifest.get('device')!='sargo' or manifest.get('activeSlot')!='a':raise ValueError('Wrong recovery target')
- data=json.loads((folder/'userdata.json').read_text())
- if not data.get('checked') or data.get('archive')!='userdata-original.raw.gz':raise ValueError('Unverified userdata backup')
- if not data.get('sourceWasUnmounted') or manifest.get('userdataSha256')!=data['sha256'] or manifest.get('userdataBytes')!=data['bytes']:
- raise ValueError('Storage backup is not bound to this recovery kit')
- for name,record in manifest['files'].items():
- if name not in ('boot_a.img','dtbo_a.img','vbmeta_a.img'):raise ValueError('Unexpected recovery partition')
- path=folder/name
- if path.is_symlink() or path.stat().st_size!=record['bytes'] or digest(path)!=record['sha256']:raise ValueError('Partition backup changed: '+name)
- if set(manifest['files'])!={'boot_a.img','dtbo_a.img','vbmeta_a.img'}:raise ValueError('Incomplete boot backup set')
- archive=folder/data['archive'];sha=hashlib.sha256();count=0
- with gzip.open(archive,'rb') as source:
- while block:=source.read(4*1024**2):
- count+=len(block)
- if count>data['bytes']:raise ValueError('Oversized backup')
- sha.update(block)
- if count!=data['bytes'] or sha.hexdigest()!=data['sha256']:raise ValueError('Storage backup changed')
- print('Private boot and full userdata backups verified.')
- if not args.restore:
- print('No phone action. --restore replaces its current userdata.');return
- fastboot=shutil.which('fastboot')
- if not fastboot:raise ValueError('Fastboot is required')
- serials=[line.split()[0] for line in subprocess.check_output([fastboot,'devices'],text=True).splitlines() if len(line.split())==2]
- matches=[serial for serial in serials if hashlib.sha256(serial.encode()).hexdigest()==manifest['serialSha256']]
- if len(matches)!=1:raise ValueError('Original phone not present in Fastboot')
- serial=matches[0]
- # Use the standalone installer's strict Fastboot variable parser.
- import importlib.util
- spec=importlib.util.spec_from_file_location('flash',Path(__file__).with_name('flash-standalone-sargo.py'))
- flash=importlib.util.module_from_spec(spec);spec.loader.exec_module(flash)
- if flash.getvar(fastboot,serial,'product')!='sargo' or flash.getvar(fastboot,serial,'unlocked')!='yes':raise ValueError('Wrong or locked phone')
- if int(flash.getvar(fastboot,serial,'partition-size:userdata'),16)!=data['bytes']:raise ValueError('Storage geometry changed')
- raw=folder/'userdata-restore.raw'
- if raw.exists():
- if raw.stat().st_size!=data['bytes'] or digest(raw)!=data['sha256']:raise ValueError('Existing restoration file changed')
- else:
- if shutil.disk_usage(folder).free Path(path).stat().st_size:
- raise ValueError('Boot payload exceeds the backup')
- stream.seek(page)
- kernel = stream.read(size)
- if len(kernel) != size:
- raise ValueError('Truncated kernel')
- result = hashlib.sha256(kernel).hexdigest()
- if result != KERNEL_SHA256:
- raise ValueError('Kernel has not been qualified with this system/HAL')
- return result
-
-
-def checked_workspace(folder):
- folder = Path(folder).resolve()
- if (folder/'.incomplete').exists():
- raise ValueError('Device workspace preparation did not finish')
- value = json.loads((folder/'device.json').read_text())
- if (value.get('schema') != 1 or value.get('device') != 'sargo'
- or value.get('activeSlot') != 'a'
- or not re.fullmatch(r'[a-f0-9]{64}', str(value.get('serialSha256', '')))):
- raise ValueError('Expected a Sargo slot A workspace with a device identity')
- for name, sha in value.get('files', {}).items():
- path = folder/name
- if (name not in ('backup/manifest.json', 'layout.json') or path.is_symlink()
- or not path.resolve().is_relative_to(folder) or digest(path) != sha):
- raise ValueError('Workspace input changed: '+name)
- if set(value.get('files', {})) != {'backup/manifest.json', 'layout.json'}:
- raise ValueError('Workspace is missing its backup or storage inventory')
- backup = verify(folder/'backup')
- layout = json.loads((folder/'layout.json').read_text())
- for record in (backup, layout):
- if record.get('serialSha256') != value['serialSha256'] or record.get('activeSlot') != 'a':
- raise ValueError('Storage and boot backup must belong to the same phone on slot A')
- mount_table(layout)
- kernel = boot_kernel(folder/'backup/boot_a.img')
- if kernel != value.get('kernelSha256'):
- raise ValueError('Workspace kernel checksum changed')
- plan = {'device': 'sargo', 'activeSlot': 'a', 'serialSha256': value['serialSha256'],
- 'backupDirectory': str(folder/'backup'),
- 'backupManifestSha256': value['files']['backup/manifest.json'],
- 'workspaceSha256': digest(folder/'device.json')}
- return plan, layout
diff --git a/tundra/tools/shared_ui.py b/tundra/tools/shared_ui.py
deleted file mode 100644
index ddede267..00000000
--- a/tundra/tools/shared_ui.py
+++ /dev/null
@@ -1,51 +0,0 @@
-# SPDX-License-Identifier: MPL-2.0
-"""Shared Gaia payload used by full builds and private UI-only RAM updates."""
-import io
-import tarfile
-from tundra import ROOT, digest
-
-
-def selected_files():
- return [*sorted((ROOT.parent/'host').rglob('*')),
- *sorted((ROOT.parent/'services').rglob('*.mjs')),
- *sorted((ROOT/'mms-codec').glob('*')),
- *sorted((ROOT.parent/'assets/webapps/marketplace.gaiamobile.org').rglob('*')),
- ROOT.parent/'tools/installed_apps.py',
- *sorted((ROOT.parent/'gaia/shared/elements/vulpes-fonts').rglob('*')),
- *sorted((ROOT.parent/'overrides').rglob('*')),
- ROOT.parent/'assets/webapps/communications.gaiamobile.org/contacts/js/views/form.js',
- ROOT.parent/'assets/webapps/communications.gaiamobile.org/contacts/js/contacts.js',
- ROOT.parent/'assets/webapps/communications.gaiamobile.org/contacts/js/activities.js',
- ROOT.parent/'assets/webapps/search.gaiamobile.org/manifest.webapp',
- ROOT.parent/'assets/webapps/homescreen.gaiamobile.org/gaia_build_defer_index.js',
- ROOT.parent/'assets/webapps/settings.gaiamobile.org/js/panels/app_permissions_list/panel.js',
- ROOT.parent/'assets/webapps/settings.gaiamobile.org/js/panels/screen_lock_passcode/panel.js',
- ROOT.parent/'assets/webapps/verticalhome.gaiamobile.org/gaia_build_defer_index.js',
- ROOT.parent/'assets/webapps/wallpaper.gaiamobile.org/style/wallpaper.css',
- ROOT.parent/'assets/webapps/ringtones.gaiamobile.org/gaia_build_defer_pick.js',
- ROOT.parent/'assets/webapps/ringtones.gaiamobile.org/gaia_build_defer_manage.js',
- ROOT.parent/'assets/webapps/ringtones.gaiamobile.org/js/tone_player.js',
- ROOT.parent/'gaia/apps/communications/dialer/js/telephony_helper.js',
- ROOT.parent/'tools/project.py',
- ROOT.parent/'tools/serve-gaia.py',
- ROOT.parent/'release.json', ROOT.parent/'tests/ProbeChild.sys.mjs',
- ROOT/'droidian/probe.sys.mjs', ROOT/'droidian/session.py']
-
-
-def build_payload():
- buffer = io.BytesIO()
- hashes = {}
- def owned(info):
- info.uid = info.gid = 0
- info.uname = info.gname = 'root'
- info.mtime = 0
- return info
- with tarfile.open(fileobj=buffer, mode='w') as archive:
- for path in selected_files():
- if not path.is_file():
- continue
- relative = str(path.relative_to(ROOT.parent))
- target = 'trial/' + path.name if path in (ROOT/'droidian/probe.sys.mjs', ROOT/'droidian/session.py') else relative
- archive.add(path, arcname='opt/vulpes/' + target, filter=owned)
- hashes[relative] = digest(path)
- return buffer.getvalue(), hashes
diff --git a/tundra/tools/sparse_backup.py b/tundra/tools/sparse_backup.py
deleted file mode 100644
index 425e2bda..00000000
--- a/tundra/tools/sparse_backup.py
+++ /dev/null
@@ -1,25 +0,0 @@
-# SPDX-License-Identifier: MPL-2.0
-"""Decode the read-only TUNRAW1 stream; reject truncation and overlapping blocks."""
-import struct
-
-
-def exact(stream, length):
- data=bytearray()
- while len(data)65536 or offset+length>expected_size:raise ValueError('Invalid backup extent')
- target.seek(offset);target.write(exact(stream,length));end=offset+length
diff --git a/tundra/tools/stage-sargo-files.py b/tundra/tools/stage-sargo-files.py
deleted file mode 100644
index 5a78ef0a..00000000
--- a/tundra/tools/stage-sargo-files.py
+++ /dev/null
@@ -1,138 +0,0 @@
-#!/usr/bin/env python3
-# SPDX-License-Identifier: MPL-2.0
-"""Stage prepared system/data files over pinned SSH; never flash a partition.
-
-Provide an SSH configuration with this phone's key and known_hosts. The account
-must be root. --native-root selects Tundra's rescue chroot; otherwise use the
-existing Linux root with its compatible ext4/LVM mount. Default: local review.
-"""
-import argparse
-import json
-import os
-from pathlib import Path
-import re
-import shlex
-import subprocess
-
-from persistent_state import validate_manifest
-from sargo_workspace import checked_workspace
-from tundra import digest
-
-# Runs inside the real phone root. No block device is opened for writing.
-RECEIVE = r'''
-import hashlib,json,os,pathlib,stat,subprocess,sys,tempfile
-config=json.loads(sys.argv[1]);layout=config['layout'];mount=config['mount']
-def command(*args):
- return subprocess.check_output(args,text=True,timeout=15,env={**os.environ,'PATH':'/usr/sbin:/usr/bin:/sbin:/bin','LC_ALL':'C'}).strip()
-cmdline=dict(x.split('=',1) for x in pathlib.Path('/proc/cmdline').read_text().split() if '=' in x)
-if ' S4 ' not in pathlib.Path('/proc/device-tree/model').read_text():raise ValueError('Unsupported board model')
-if b'google,b4s4-sdm670' not in pathlib.Path('/proc/device-tree/compatible').read_bytes().split(b'\0'):raise ValueError('Wrong model')
-if cmdline.get('androidboot.slot_suffix')!='_a' or hashlib.sha256(cmdline.get('androidboot.serialno','').encode()).hexdigest()!=config['serialSha256']:raise ValueError('Wrong phone or slot')
-device=pathlib.Path('/dev/mmcblk0p72')
-if not stat.S_ISBLK(device.stat().st_mode):raise ValueError('Not a partition')
-with device.open('rb') as source:
- if hashlib.sha256(source.read(1048576)).hexdigest()!=layout['firstMiBSha256']:raise ValueError('Storage identity changed')
-if int(command('blockdev','--getsize64',str(device)))!=layout['size']:raise ValueError('Storage size changed')
-mounted=json.loads(command('findmnt','--json','--mountpoint',mount,'--output','SOURCE,FSTYPE'))['filesystems']
-if len(mounted)!=1 or mounted[0]['fstype']!='ext4' or not mounted[0]['source'].startswith('/dev/mapper/'):raise ValueError('Unsupported filesystem')
-table=command('dmsetup','table',mounted[0]['source']).split()
-expected=layout['table'].split();backing=pathlib.Path('/sys/class/block/mmcblk0p72/dev').read_text().strip()
-if len(table)!=5 or table[:3]!=expected[:3] or table[3]!=backing or table[4]!=expected[4]:raise ValueError('Storage mapping changed')
-root=pathlib.Path(mount)/'tundra'
-folder=root/config['kind'];target=folder/config['name']
-for p in (root,folder):
- if p.is_symlink():raise ValueError('Destination is a symbolic link')
-if target.exists() or target.is_symlink():
- if config['kind']=='data':raise ValueError('Existing data retained; refusing to overwrite')
- if target.is_symlink() or not target.is_file() or target.stat().st_size!=config['bytes']:raise ValueError('Unexpected system file')
- with target.open('rb') as f:
- if hashlib.file_digest(f,'sha256').hexdigest()!=config['sha256']:raise ValueError('Existing system checksum mismatch')
- print(json.dumps({'existingSystemVerified':True}));sys.exit(0)
-if config['checkOnly']:
- s=os.statvfs(mount)
- if s.f_bavail*s.f_frsize < config['requiredBytes']+1024**3:raise ValueError('Insufficient space with 1 GiB reserve')
- print(json.dumps({'ready':True}));sys.exit(0)
-folder.mkdir(parents=True,exist_ok=True,mode=0o700)
-os.umask(0o077)
-fd,name=tempfile.mkstemp(prefix='.transfer-',dir=folder);partial=pathlib.Path(name)
-try:
- count=0;sha=hashlib.sha256()
- with os.fdopen(fd,'wb') as output:
- while data:=sys.stdin.buffer.read(1048576):
- count+=len(data)
- if count>config['bytes']:raise ValueError('Oversized transfer')
- output.write(data);sha.update(data)
- output.flush();os.fsync(output.fileno())
- if count!=config['bytes'] or sha.hexdigest()!=config['sha256']:raise ValueError('Transfer checksum mismatch')
- os.link(partial,target) # Atomic create; never replace an existing data file.
- fd=os.open(folder,os.O_RDONLY);os.fsync(fd);os.close(fd)
- print(json.dumps({'staged':True,'sha256':sha.hexdigest(),'bytes':count}))
-finally:
- partial.unlink(missing_ok=True)
-'''
-
-
-def inputs(prepared):
- prepared = Path(prepared)
- if (prepared/'.incomplete').exists():
- raise ValueError('Package preparation did not finish')
- system = json.loads((prepared/'system.json').read_text())
- if (system.get('device') != 'sargo' or system.get('image') != 'tundra-sargo-system.ext4'
- or system.get('filesystemCheckPassed') is not True):
- raise ValueError('Expected a checked system image')
- path = prepared/system['image']
- if path.is_symlink() or path.stat().st_size != system['bytes'] or digest(path) != system['imageSha256']:
- raise ValueError('System image changed')
- state = validate_manifest(prepared/'data/state.json')
- if state['systemImageSha256'] != system['imageSha256']:
- raise ValueError('State and system do not match')
- return [('images', system['imageSha256']+'.ext4', path, system),
- ('data', state['image'], prepared/'data'/state['image'], state)]
-
-
-def main():
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument('--prepared', required=True, type=Path)
- parser.add_argument('--device-workspace', required=True, type=Path)
- parser.add_argument('--ssh-config', required=True, type=Path)
- parser.add_argument('--host', default='sargo')
- parser.add_argument('--native-root', action='store_true')
- parser.add_argument('--stage', action='store_true')
- args = parser.parse_args(); os.umask(0o077)
- plan, layout = checked_workspace(args.device_workspace)
- files = inputs(args.prepared)
- if not re.fullmatch('[A-Za-z0-9][A-Za-z0-9._-]*', args.host):
- parser.error('Use an SSH host alias, not command-line options')
- if not args.stage:
- print('Local inputs checked. --stage transfers new files; no flash or reboot is implemented.')
- return
- ssh = ['ssh', '-F', str(args.ssh_config.resolve()), '-o', 'BatchMode=yes',
- '-o', 'StrictHostKeyChecking=yes', '-o', 'ConnectTimeout=10', args.host]
- python = ['chroot', '/proc/1/root', '/usr/bin/python3'] if args.native_root else ['/usr/bin/python3']
- records = []
- for kind, name, path, record in files:
- config = {'kind': kind, 'name': name, 'sha256': record['imageSha256'],
- 'bytes': record['bytes'], 'layout': layout, 'serialSha256': plan['serialSha256'],
- 'mount': '/reference/installed' if args.native_root else '/', 'checkOnly': True,
- 'requiredBytes': record['bytes']}
- def command():
- return ssh+[shlex.join(python+['-c', RECEIVE, json.dumps(config)])]
- result = subprocess.run(command(), capture_output=True, text=True, timeout=180)
- if result.returncode:
- raise RuntimeError('Phone preflight failed: '+result.stderr.strip())
- status = json.loads(result.stdout)
- if not status.get('existingSystemVerified'):
- config['checkOnly'] = False
- with path.open('rb') as source:
- result = subprocess.run(command(), stdin=source, stdout=subprocess.PIPE,
- text=True, check=True, timeout=900)
- status = json.loads(result.stdout)
- records.append({'kind': kind, 'name': name, **status})
- report = {'files': records, 'deviceWorkspaceSha256': plan['workspaceSha256'],
- 'flashCommandsSent': False, 'rebootRequested': False, 'passed': True}
- (args.prepared/'transfer.json').write_text(json.dumps(report, indent=2)+'\n')
- print('System and fresh data files verified on the matching phone. No flash or reboot sent.')
-
-
-if __name__ == '__main__':
- main()
diff --git a/tundra/tools/standalone_volume.py b/tundra/tools/standalone_volume.py
deleted file mode 100644
index 452f145b..00000000
--- a/tundra/tools/standalone_volume.py
+++ /dev/null
@@ -1,35 +0,0 @@
-# SPDX-License-Identifier: MPL-2.0
-"""Validate the fresh userdata filesystem used by standalone Sargo images."""
-import json
-from pathlib import Path
-import re
-import uuid
-from tundra import digest
-
-
-def checked_volume(path, verify_image=True):
- path = Path(path)
- value = json.loads(path.read_text())
- if (value.get('schema') != 1 or value.get('device') != 'sargo'
- or value.get('storage') != 'raw-userdata-ext4'
- or value.get('image') != 'userdata.ext4'
- or value.get('filesystemCheckPassed') is not True):
- raise ValueError('Expected a checked standalone Sargo volume')
- identifier = value.get('uuid')
- if not isinstance(identifier, str) or str(uuid.UUID(identifier)) != identifier:
- raise ValueError('Invalid volume UUID')
- for key in ('imageSha256', 'systemImageSha256'):
- if not re.fullmatch(r'[a-f0-9]{64}', value.get(key, '')):
- raise ValueError('Invalid '+key)
- if type(value.get('bytes')) is not int or not 5*1024**3 <= value['bytes'] <= 32*1024**3:
- raise ValueError('Invalid standalone filesystem size')
- hardware = value.get('hardwareImages', {})
- if hardware:
- lock = json.loads((Path(__file__).resolve().parents[1]/'manifests/sargo-hardware.json').read_text())
- if hardware != lock['images']:
- raise ValueError('Unqualified standalone hardware images')
- if verify_image:
- image = path.parent/value['image']
- if image.is_symlink() or image.stat().st_size != value['bytes'] or digest(image) != value['imageSha256']:
- raise ValueError('Standalone volume changed')
- return value
diff --git a/tundra/tools/storage_layout.py b/tundra/tools/storage_layout.py
deleted file mode 100644
index 2827bd14..00000000
--- a/tundra/tools/storage_layout.py
+++ /dev/null
@@ -1,32 +0,0 @@
-# SPDX-License-Identifier: MPL-2.0
-"""Validate an inventoried, single-extent Sargo userdata mapping."""
-import re
-
-
-def mount_table(layout):
- device = layout.get('device')
- if device != '/dev/mmcblk0p72':
- raise ValueError('Expected Sargo userdata, not another partition')
- if not re.fullmatch(r'[0-9a-f]{64}', layout.get('firstMiBSha256', '')):
- raise ValueError('Missing userdata identity checksum')
- match = re.fullmatch(r'0 ([0-9]+) linear ([0-9]+:[0-9]+) ([0-9]+)',
- layout.get('table', ''))
- if not match:
- raise ValueError('Only a single linear extent is supported')
- sectors, backing, offset = int(match[1]), match[2], int(match[3])
- size = int(layout['size'])
- if size <= 0 or size % 512 or sectors <= 0 or offset < 2048:
- raise ValueError('Invalid userdata extent geometry')
- if (sectors + offset) * 512 > size:
- raise ValueError('Extent exceeds userdata')
- if layout.get('schema') == 2:
- if (layout.get('partitionLabel') != 'userdata' or
- layout.get('filesystem') != 'ext4' or
- layout.get('backingDevice') != backing):
- raise ValueError('Mapping must be ext4 on the inventoried userdata device')
- elif not (layout['table'] == '0 104448000 linear 259:40 329728'
- and size == 53648801280):
- raise ValueError('A new layout requires a schema 2 inventory')
- # Kernel major/minor numbers can change between boots. The partition path
- # is stable with this Sargo kernel, and its first MiB is checked before use.
- return f'0 {sectors} linear {device} {offset}'
diff --git a/tundra/tools/test-rescue-boot.py b/tundra/tools/test-rescue-boot.py
index a7417469..23b1757b 100644
--- a/tundra/tools/test-rescue-boot.py
+++ b/tundra/tools/test-rescue-boot.py
@@ -20,20 +20,9 @@ def verify_sources(folder, build):
def main():
- p=argparse.ArgumentParser(description=__doc__);p.add_argument('--build',type=Path,required=True);p.add_argument('--boot',action='store_true')
- p.add_argument('--device-workspace',type=Path)
- args=p.parse_args()
+ p=argparse.ArgumentParser(description=__doc__);p.add_argument('--build',type=Path,required=True);p.add_argument('--boot',action='store_true');args=p.parse_args()
spec=importlib.util.spec_from_file_location('boot',ROOT/'tools/test-sargo-boot.py');boot=importlib.util.module_from_spec(spec);spec.loader.exec_module(boot)
- folder=args.build.resolve();build=json.loads((folder/'build.json').read_text());image=folder/build['image']
- if args.device_workspace:
- from sargo_workspace import checked_workspace
- plan,layout=checked_workspace(args.device_workspace)
- if build.get('deviceWorkspaceSha256') != plan['workspaceSha256'] or build['layout'] != layout:
- raise ValueError('Build was not prepared with this device workspace')
- else:
- if build.get('deviceWorkspaceSha256'):
- raise ValueError('This build requires --device-workspace; no fallback to shared inventory')
- plan,_=boot.checked_plan()
+ plan,_=boot.checked_plan();folder=args.build.resolve();build=json.loads((folder/'build.json').read_text());image=folder/build['image']
if build['imageSha256']!=digest(image) or build['serialSha256']!=plan['serialSha256'] or not build['roundtripPassed']:
raise ValueError('Image or target provenance mismatch')
verify_sources(folder,build)
diff --git a/tundra/tools/test-sargo-boot.py b/tundra/tools/test-sargo-boot.py
index fe10dff6..7d1a2139 100644
--- a/tundra/tools/test-sargo-boot.py
+++ b/tundra/tools/test-sargo-boot.py
@@ -17,8 +17,7 @@ from tundra import ROOT, digest, now, save
from usb_protocol import exchange, device_layout
-def checked_backup_plan():
- """Recovery only needs the qualified backup, not a working diagnostic."""
+def checked_plan():
path = ROOT/'logs/device/boot-backup-qualification.json'
validation = json.loads((ROOT/'VALIDATION.json').read_text())
if validation.get('bootBackup', {}).get('reportSha256') != digest(path):
@@ -28,14 +27,6 @@ def checked_backup_plan():
verify(backup)
if digest(backup/'manifest.json') != plan['backupManifestSha256']:
raise ValueError('Backup manifest changed; requalify')
- if (plan.get('device') != 'sargo' or plan.get('activeSlot') != 'a'
- or not re.fullmatch(r'[0-9a-f]{64}', plan.get('serialSha256', ''))):
- raise ValueError('Backup qualification must identify a Sargo on slot A')
- return plan
-
-
-def checked_plan():
- plan = checked_backup_plan()
image = ROOT/plan['diagnosticImage']
if digest(image) != plan['diagnosticImageSha256'] or digest(image.parent/'build.json') != plan['diagnosticBuildSha256']:
raise ValueError('Diagnostic image/build changed; requalify')