diff --git a/CHANGELOG.en.md b/CHANGELOG.en.md
index d88e6361..debb7487 100644
--- a/CHANGELOG.en.md
+++ b/CHANGELOG.en.md
@@ -2,805 +2,546 @@
[Français](CHANGELOG.md)
-One Gaia base, three platforms: **desktop**, **Android (APK)** and **Tundra / Sargo**.
-This journal covers visible changes and major technical milestones.
+Desktop, Android (APK) and Tundra/Sargo. Status indicates development sources, a built APK or a published release. Source changes require an update to the installed package.
-**Status:** “In development” refers to local sources, “APK built” to a test package,
-and “Published” to a public repository release. Shared source changes reach installed
-APKs and phone images only after those packages are updated.
+
-
-Gaia Settings · screenshot from the desktop version.
+## 2.7-preview.20 — APK built · 13 October 2026
+
+-  Preview 20 brings together shared fixes since APK Preview 17: Gaia settings, appearance, selectors, keyboard, contacts, Marketplace and media.
+-  Camera: WebM recording with posters and Gallery/Video playback. Microphone permission requested; controls follow hardware capabilities.
+-  Camera: more reliable sensor switching, stopping and resuming. Desktop and Android use their camera APIs; Sargo retains its HAL adapter.
+-  Sargo: Wi-Fi, Bluetooth, calls, SMS/MMS and network settings updates included in the sources. Unimplemented functions remain identified in their panels.
+-  Changelog: simplified French/English text and badges retained; desktop/APK/Sargo filters independent of headings. Markdown, website and release notes generated from one JSON.
+-  APK signed with the previous Preview key; alignment and consistency of 53 shared files verified. Android runtime validation in progress.
+-  Desktop/Sargo Gecko 156.0.1; Android GeckoView 156.0. Gaia 2.7.0. The APK delegates calls/SMS to Android; it does not replace Android telephony.
+-  Experimental Preview: Mozilla account/Sync, remote location, Firefox OS add-ons and USB file transfer remain incomplete. Sargo: preview around 16 fps; recorded sound and call audio require separate qualification.
+-  Pixel 3a: Preview 20 r62 RAM-booted, then flashed to boot_a. Matching readback hash; installed startup, eleven Gaia checks and ten native checks passed. Data retained, Wi-Fi connected and LTE registration observed. No new two-way audio call tested.
+
+
+
+## In development — 12 October 2026 — Video and clock settings
+
+-  Camera: WebM VP8/Opus recording with microphone permission, poster and storage. Buffer limited to 32 MiB; recording stops before releasing the sensor.
+-  Camera, Gallery and Video: handle WebM files and their posters.
+-  Settings: original Gaia date/time pickers, Back from built-in keyboard settings and unclipped select labels.
+-  Improve Vulpes: link to project issues. Unavailable services explained in their panels.
+-  Desktop: simulated 1920 × 1080 video decoded; photo, gallery, camera resume and navigation checked.
+-  Sargo r61 in RAM: 480 × 640 video decoded, eight front/rear switches passed. Test media deleted; recorded audio not listened to.
+-  Sargo: preview measured around 16 fps versus 12 in the previous test. 30 fps is not reached yet.
+-  Sargo: automatic NITZ time, persistent setting and guarded manual writes. Sync depends on carrier time; no NTP fallback, timezone selected manually.
+-  Android: assets staged and Java compiled, without a new APK or Android video test at this stage. Mozilla account/Sync, remote location, Firefox OS add-ons and USB file transfer remain unimplemented. No flashing or publication.
+
+
+
+## In development — 12 October 2026 — Calls and Wi-Fi sharing
+
+-  Gaia dialogs: close after opening, complete interrupted transitions and allow retry after rejected validation.
+-  Sargo: longer carrier-service timeout; slow settings separated from voice/SMS commands.
+-  Sargo: call waiting, caller ID and four forwarding rules connected to oFono, with validation and readback.
+-  Sargo: WPA2 or open Wi-Fi hotspot, NetworkManager configuration and compatible firewall. Private password, accessible only to Settings.
+-  r59 in RAM: 69 panels, dialogs and APNs checked on desktop/Pixel. Hotspot saved, started and stopped through Gaia; previous Wi-Fi restored. 44 Python tests and dialog regressions passed.
+-  Internet forwarding to another device untested. Voicemail, call barring, PIN/PUK, Wi-Fi certificates and USB tethering remain open. Android assets synchronized; no new APK, flashing or publication.
+
+
+
+## In development — 12 October 2026 — Network settings
+
+-  Wi-Fi: actual IPv4 address and link speed; no “undefined” label when a measurement is missing.
+-  Sargo: GSM/UMTS/LTE selection and editing of existing Internet/MMS APNs, with validation and readback.
+-  APNs: reject changes to active profiles, protect IMS and passwords, attempt rollback after failure and report its result.
+-  Sargo: bounded queue for slow settings. Call status read in 20 ms during a five-second settings wait.
+-  38 Python tests, 69 desktop panels and APN/network workflows on RAM-booted Pixel checked. Android assets synchronized.
+-  APN creation, deletion and reset unavailable. Manual carrier selection and certificates still open. No new APK, flashing or publication.
+
+
+
+## In development — 12 October 2026 — Settings audit
+
+-  Settings: recover navigation after a controller or dialog fails; consistent module generation.
+-  Accessibility filters and Vulpes support. Original panels retained with explanations for unavailable features.
+-  Desktop: profile statistics and Gecko downloads. Android: private storage statistics.
+-  Sargo: mobile data, roaming, clock and hidden Wi-Fi connected. Legacy network errors in Sound settings fixed.
+-  97 views inventoried; 69 desktop/Pixel panels opened and seven native dialogs checked. Storage, data and timezone read back after writes on r51 in RAM.
+-  Contextual views and unavailable services are not all qualified. Android Java compiled, without a new APK. No flashing, ticket closure or publication.
+
+
+
+## In development — 12 October 2026 — Settings navigation
+
+-  Dropdowns: Gaia selector with confirm/cancel; Light/Dark choice accessible. SIM manager adapted to one slot.
+-  Unavailable panels: explanations and disabled controls instead of unresponsive links.
+-  Sargo: saved Wi-Fi networks separated from slow scans; cache preserved during status reads.
+-  Sargo: SIM information, SMS centre and delivery reports connected to the modem.
+-  r45 in RAM: eight appearance checks, five panels and actual SMS report setting changes passed, then setting restored. Desktop selector checked; APK assets staged.
+-  Enterprise certificates and other carrier options still unavailable. No SMS sent, new APK, flashing or publication.
+
+
+
+## In development — 12 October 2026 — Shared fixes on Pixel 3a
+
+-  Sargo: r39 RAM boot with shared fixes and initial dark mode. r35 kernel and hardware configuration preserved.
+-  Boot, viewport size, persistent settings, battery, call UI and Fira checked. Modem, audio, Wi-Fi and Bluetooth services active.
+-  Seven Gaia appearance checks passed: propagated theme, readable headers, aligned labels and return to light mode. Pixel screenshot inspected.
+-  Tool to update Gaia in a private boot image, with hashes, retained sources and checks for unchanged hardware components.
+-  No new call, SMS or pairing tested. Full theme and Android behavior still need qualification. No new APK, flashing or publication.
+
+
+
+## In development — 11 October 2026 — Appearance and shared keyboard
+
+-  Appearance: Light, Dark or Follow system, shared between apps and retained across restarts. Wallpapers, photos and external websites preserved.
+-  Keyboard: shared editing operations, selection and deletion, protection against late replies.
+-  Desktop: nine appearance checks and Messages input gestures passed, without sending SMS. Visual review of Settings, Messages, Phone and Contacts.
+-  Android: experimental Gaia keyboard option; Android keyboard remains the default, input mode restored on close.
+-  Android: briefly retry the same busy sensor, without retrying after permission denial or closing Camera.
+-  Preview 19 built locally: signature, alignment, parity and bridge/camera tests passed.
+-  Gaia keyboard, Android front camera and full theme still need qualification. Sources ready for Sargo, which remained on r35 at this stage. No upload, flashing or ticket closure.
+
+
+
+## In development — 11 October 2026 — Settings and homescreens
+
+-  Settings: ringtone management, PIN entry/deletion, lock disabling and third-party app permission list.
+-  Home: first initialization of the second launcher and wallpaper previews.
+-  Languages: refresh Vulpes labels after language changes; English fallback outside French. Dismiss camera failure panels and installation banners.
+-  Android: open the Gaia tray by tapping the status bar. Preview 18 built, signed and compared with sources.
+-  Desktop: nine Gaia workflows, five banner checks and twelve activity/camera tests passed. Sargo r35 Bluetooth confirmed by the user.
+-  Compositor hot restart still unstable: files restored and return to r35. Fixes not yet qualified on Sargo at this stage; Android keyboard/theme open. No APK publication or flashing.
+
+
+
+## In development — 9 October 2026 — MMS and Bluetooth
+
+-  Bluetooth: shared Gaia adapter, separate hardware commands and access restricted to authorized apps.
+-  Sargo: power, discovery, explicit pairing, connection and removal from Gaia Bluetooth settings. Bluetooth audio modules included.
+-  Sargo: Qualcomm network services and MMS transport connected. D-Bus disconnect no longer terminates the MMS daemon. Persistent state, retry without duplicates or uncertain automatic resends.
+-  r35 in RAM: image MMS visible and accepted by Orange MMSC; recipient delivery still unconfirmed. Five Bluetooth devices discovered, A2DP to desktop verified.
+-  191 Tundra Python tests and 18 JavaScript tests passed. Smaller ramdisk and synchronized Android assets.
+-  Headset call audio unqualified. No new APK, flashing or push at this stage.
+
+
+
+## In development — 8 October 2026 — Camera and MMS transport
+
+-  Camera: stop the stream before detaching video; await sensor closure, reset the viewfinder and recalculate size after rotation.
+-  MMS: preserve Blob attachments and keep messages pending until transport confirmation.
+-  Sargo: reused WebGL viewfinder without intermediate video capture; screen stays awake during preview.
+-  Desktop and Sargo: Camera/Gallery workflows and eight native switches checked. JPEG, focus and zoom working; rear ~16 fps, front ~25–27 fps in this test.
+-  Sargo: persistent MMS queue, Nuntium codec and oFono transport. Text/photo UI tested with simulated transport.
+-  Mobile data blocked carrier MMS at this stage. Android assets synchronized; no new APK, flashing or push.
+
+
+
+## 8 October 2026 — Call-log redial and error dialogs
+
+-  Gaia dialogs: visible text instead of a grey screen with a lone button.
+-  Call log: redial through the same service as the keypad, without legacy Gecko APIs. Shared desktop/Android/Sargo file.
+-  MMS: clearly report unavailable sending, retain text and attachments, no false airplane-mode message.
+-  Desktop: FR/EN dialogs and preserved composer. RAM-booted Sargo: 11 Gaia checks passed, redial tested with a simulated modem.
+-  No real call or audio validation at this stage. Android assets synchronized, no new APK published.
+
+
+
+## In development — 8 October 2026 — SMS and audio
+
+-  SMS: send in one tap while keeping the keyboard. Paperclip connected to the Gaia image, audio, video or contact menu.
+-  UI: visible volume indicator, lockscreen gestures and notification swiping fixed.
+-  Desktop: keyboard, attachments, Gallery, call UI and volume checked; notification swiped without moving the page. No new APK published.
+-  Sargo: PulseAudio access, Qualcomm relay, D-Bus protocol and calibration devices restored. Conflicting Android audio service removed.
+-  Sargo: 11 Gaia checks, calibration and rear/front/rear previews passed.
+-  Calls failed before ringing with a network error in this test. Two-way audio, Pixel gesture and MMS still unverified. No flashing or publication.
+
+
+
+## In development — 8 October 2026 — Telephony reliability
+
+-  Sargo: radio service survives client closure. No automatic new call or SMS after a lost response. Errors exclude phone numbers and message text.
+-  Network/audio diagnostics without personal identifiers, and pinned carrier settings in the native image.
+-  SMS: wait for the actual Gaia manager and accept uppercase oFono IDs. Conversation checks restored without resending.
+-  Sargo: LTE restored after reboot, SMS confirmed sent by the modem, non-silent microphone and audio playback accepted.
+-  Recipient delivery, two-way call audio and modem stability remain unqualified. Compositor hot restart unstable. No new APK, flashing or push.
## 8 October 2026 — Public source update
-### Shared · Desktop / Android / Tundra
-
-- Bring recent camera, Contacts, SMS and Marketplace fixes, plus Sargo build and recovery tools, into the public sources. Update both READMEs with verified capabilities and limitations.
-- Make the Android parity check create its report directory in a fresh checkout. Verify that native Wi-Fi configuration ships an empty connections directory.
-- Keep tickets, roadmaps, internal documents, personal data and keys outside the repository.
-
-### Packages and qualification
-
-- The public APK remains Preview17; the 8 October fixes require a future build. No new native image or flash in this step.
-- At the latest Sargo check, cellular registration is still searching; two-way call audio, Bluetooth pairing and profiles remain to be qualified.
+-  Published Camera, Contacts, SMS and Marketplace fixes, plus Sargo build and recovery tools.
+-  Updated French and English READMEs with verified features and limitations.
+-  Create the report directory when checking Android asset parity in a fresh checkout.
+-  Checked native Wi-Fi configuration: no bundled connection profiles.
+-  Internal documents, personal data and keys excluded from the repository.
+-  Public APK unchanged (Preview17); October 8 fixes not included. No new native image published.
+-  Sargo: modem still searching for a network; call audio, Bluetooth pairing and profiles await validation.
## 8 October 2026 — Camera and Contacts workflows · in development
-### Shared · Gaia / Desktop / Android / Tundra
-
-- Serialize camera opening, await the previous sensor shutdown and release tracks and video resources after failures. Keep stable front/rear identities on desktop and Android.
-- Preserve binary images across desktop/Tundra transport; explicitly encode contact photos and dates for Android's JSON bridge.
-- Refresh contact details after editing. Restore Contacts → SMS, number picking and prefilled dialer activities, preserving nested requests and Gaia entry points.
-- Reopen Gallery in picker mode when adding a contact photo, even when Gallery is already open for browsing. Selection, cropping and photo persistence verified on desktop.
-- Add camera lifecycle, contact persistence, permissions, binary transport and activity routing tests.
-
-### Desktop
-
-- Verify the original Camera → JPEG capture → Gallery → photo opening → camera workflow.
-- Verify Gaia contact creation, editing, detail refresh, accent-insensitive search and empty results, favourites, SMS recipient, number picker, prefilled keypad, cancelled deletion and confirmed deletion. The test sends no message and makes no call; its temporary contact is removed.
-
-### Tundra · Sargo
-
-- Verify on a RAM-booted Pixel 3a: autofocus, zoom, four front/rear switches with fresh frames, shutter capture, JPEG persistence, Gallery opening and sensor shutdown on Home.
-- Check stored conversations, keyboard and notifications. Wi-Fi scan finds four networks. New SMS transmission/reception and call audio are still being qualified.
-- Add SHA256-pinned BlueZ packages and required libraries. The controller starts, powers on and discovers; pairing through Gaia and Bluetooth profiles are not yet validated.
-- Add native trusted certificates and verify HTTPS without disabling TLS. Test corrupted archives, file collisions and repeatable staging.
-
-- Include the corrected Contacts files in the boot build: contact creation, editing, SMS handoff, picking and dialer prefilling also pass using touch gestures on Sargo.
-
-### Android · APK
-
-- Publish the existing Preview17 APK and verify its download and SHA256. It predates the 8 October fixes.
-- Fix recipient and body handoff from Gaia to the Android SMS app, including replies and a typed recipient that was not yet committed. Bridge tests pass; real Android validation is still required.
-- Repackage corrected shared resources for the next build; no APK containing these new fixes has been published.
-
-**Status:** native qualification builds booted in RAM. No flashing, source push or private documentation publication in this step. Full phone qualification remains open.
+-  Camera: corrected sequential opening, sensor cleanup and front/rear identification.
+-  Contacts: corrected photo and date transport on desktop, Android and Tundra.
+-  Contacts: restored refresh after editing, SMS handoff, number picking and dialer prefilling.
+-  Gallery: open the contact photo picker even when the app is already running.
+-  Sargo: include corrected Contacts files in the boot image.
+-  Android: pass recipients and text to the SMS app, including replies and unconfirmed typed numbers.
+-  Tests for camera lifecycle, contact storage, permissions, binary transport and activities.
+-  Desktop: verified JPEG capture, Gallery, contact photos, creation, editing, search, favourites and deletion. No calls or SMS sent.
+-  Sargo in RAM: verified autofocus, zoom, four camera switches, capture and Gallery; tested Contacts with touch input.
+-  Sargo: checked stored conversations, keyboard and notifications; detected four Wi-Fi networks. New SMS and call audio await validation.
+-  Sargo: BlueZ and dependencies pinned by SHA256. Power-on and discovery tested; pairing and profiles await validation.
+-  Sargo: native certificate store. Tested HTTPS, corrupted archives, file collisions and repeated builds.
+-  Published Preview17 with download and SHA256 checks. These fixes remain in source; Android SMS needs a real-device test. No Sargo flash.
## 6 October 2026 — Embedded Marketplace theme · in development
-### Shared · Gaia / Desktop / Android / Tundra
-
-- Open the catalogue with `mode=app`, retained in navigation, search, filters, app details and language switches.
-- Prepare a dedicated Vulpes web theme with local Fira fonts, compact navigation, touch-friendly controls, readable cards, collapsible filters and scrolling screenshots. Omit website headers in embedded mode while preserving the standard website layout.
-- Keep confirmations and system messages in Gaia. The theme grants catalogue pages no additional privileges.
-- Check 16 French/English pages, 2 standard pages and theme assets. Inspect mobile renders at 320 and 360 pixels. Desktop and Sargo launchers updated; shared sources will be included in the next Android packaging run.
-
-Website files prepared locally, not deployed to the public server. No push or new APK/ROM publication.
+-  Marketplace: embedded theme with local Fira fonts, compact navigation, touch controls, collapsible filters and scrolling screenshots.
+-  Preserve `mode=app` across navigation, search, filters and language changes.
+-  Hide website headers in embedded mode. Gaia handles confirmations; the catalogue receives no extra privileges.
+-  Checked 16 French/English pages, 2 standard pages and mobile layouts at 320 and 360 pixels.
+-  Theme ready locally, not deployed. Desktop and Sargo launchers updated; Android integration pending the next APK.
## 6 October 2026 — Gaia banners · in development
-### Shared · Gaia / Desktop / Android / Tundra
-
-- Dismiss the “Application installed” banner after its normal timeout even if the animation-end event does not reach the Gaia component host. Keep its appearance and tap-to-dismiss behavior.
-- Cancel old timers when a new message replaces the previous one, preventing premature dismissal.
-- Check display, expiry, replacement and click dismissal on desktop; verify actual removal after five seconds on the Pixel. Fix deployed to the Sargo RAM trial and available in shared sources for the next APK.
-
-No flash or publication in this step.
+-  Automatically dismiss the “Application installed” banner even without an animation-end event.
+-  Cancel old timers when a new banner replaces the previous one.
+-  Desktop: checked display, expiry, replacement and click dismissal. Sargo: verified dismissal after five seconds in RAM.
+-  Fix available in shared sources for the next APK. No flash or release.
## 6 October 2026 — Wi-Fi and Settings panels · in development
-### Shared · Gaia / Desktop / Android / Tundra
-
-- Complete Settings panel navigation even when Gecko does not emit an animation-end event. Preserve Gaia transitions and prevent duplicate initialization.
-- Updated shared sources and the desktop server; included in the next Android packaging run. No new APK published in this step.
-
-### Tundra · Sargo
-
-- Fix Wi-Fi signal events: include the associated network and do not emit connection information while disconnected. The incomplete event prevented Gaia from displaying scan results.
-- Check the driver, native service, Gecko bridge and corrected panel on the Pixel: four access points detected, grouped into two networks, with no scan error.
-- Wi-Fi connection confirmed by the user; a real HTTPS request from Gecko to example.org passed (HTTP 200, certificate verification enabled). Fixes applied to the RAM trial; no flash or Git push.
+-  Settings: open panels even without an animation-end event, without duplicate initialization.
+-  Sargo: corrected Wi-Fi events to restore network results and report disconnection properly.
+-  Sargo: detected four access points grouped into two networks. Connection confirmed and HTTPS request passed with certificate verification.
+-  Shared sources, desktop server and Sargo RAM trial updated. Fixes not included in the public APK.
## 6 October 2026 — Marketplace · in development
-### Desktop / Tundra · Sargo
-
-- Keep the catalogue below the status bar and inside its owning application. Hide its web view on Home and in the task switcher; remove it when the app closes.
-- Prevent a cancelled navigation or a previous error page from hiding a retry.
-- Show a local error message with a retry action when the catalogue is unavailable, instead of leaving its error page over Gaia.
-- Desktop navigation, network failure, retry, Home and closing from the task switcher checked. Seven direct checks pass on Sargo in RAM without a debugger window, using a local page and a forced-unlock precondition; the remote catalogue requires an Internet connection on the phone.
-
-### Android · APK
-
-- Rebuild the local Preview17 APK with the shared Marketplace error message. Signature, alignment and asset parity checked; no new Android device regression run in this step.
-
-Nothing published in this step.
+-  Desktop/Sargo: keep Marketplace below the status bar, hide it on Home and remove it when closed.
+-  Cancelled navigation or an old error page no longer hides a retry.
+-  Local error message with a Retry button when the catalogue is unavailable.
+-  Desktop: verified navigation, network errors, retry, Home and closing.
+-  Sargo in RAM: seven checks passed with a local page and forced unlock. Remote catalogue not validated without Internet access.
+-  Rebuilt local Preview17 APK; checked signature, alignment and assets. No new Android device test or release.
## 2.7-preview.17 — APK built · 5 October 2026
-### Shared · Gaia / Desktop
-
-- Restore Marketplace on Home with its original icon and the Vulpes catalogue. Select French for fr languages and English otherwise.
-- Turn catalogue downloads into confirmed installations: validate ZIP archives, add the application to Home and support launch and removal.
-- Store installed applications separately from Gaia sources, on distinct origins and without system privileges inherited from archived manifests.
-- Validate the real desktop workflow with 2048: catalogue, confirmation, installation, launch, persistence and removal. Check damaged archives, invalid paths, limits, cancellation and single-use confirmations.
-
-### Android · APK
-
-- Build and sign Preview17 with the same Marketplace and installation service. Use the existing GeckoView browser for the catalogue and private Android storage for packages.
-- Verify the Install click, confirmation, Home icon and 2048 launch without access to the Vulpes system bridge on an Android 16/API36 emulator. Also verify persistence after force-stop/relaunch and removal.
-
-### Tundra · Sargo
-
-- Prepare a local boot image containing Marketplace, the installer and the added-application server. RAM boot performed on the Pixel on 6 October: Home and Marketplace resources verified, without flashing. Nine of ten system checks pass; the overall Gaia check records a viewport change during unlocking. Package installation on Sargo still needs qualification.
-
-**Limitations:** initial support for packaged web applications. Old privileged APIs, hosted apps and discontinued services are not restored automatically. Nothing published to Forgejo or the website.
+-  Marketplace on Home with its original icon and the Vulpes catalogue in French or English.
+-  Confirmed ZIP app installation, launch from Home and removal.
+-  Installed apps isolated from Gaia sources and system privileges.
+-  Desktop: full 2048 workflow plus archive, path, limit, cancellation and single-use confirmation checks.
+-  Preview17 APK built and signed with Marketplace, GeckoView and private package storage.
+-  Android 16/API 36 emulator: 2048 installation, launch, isolation, persistence after app restart and removal.
+-  Sargo in RAM on October 6: Home and Marketplace assets verified, 9 of 10 system checks passed. Viewport changes on unlock; package installation awaits validation.
+-  Initial support for packaged web apps. Legacy privileged APIs and hosted apps not restored. No release at this stage.
## 2.7-preview.16 — APK built · 5 October 2026
-### Shared · Gaia / Desktop
-
-- Restore Gaia wallpaper and sound pickers, return navigation to Settings and cancellation through Home. Share persisted image and sound transport between desktop and Android.
-- Propagate language changes to Home without granting general settings read access. Check French/English labels and persistence.
-- Restore horizontal page snapping and hide development apps from Home without deleting their sources.
-- Use the supplied Vulpes logo in the splash and adapt the original sound-list components to the current engine.
-- Verify two consecutive wallpaper changes, sound selection and restored Gaia panels on desktop.
-
-### Android · APK
-
-- Build and sign local Preview16; verify shared payload and version. Nothing published in this step.
-- Validate Vulpes as the HOME launcher, restore Gaia locking after sleep and Android Back navigation from the lockscreen camera.
-- Hide Android system bars in immersive mode while retaining temporary swipe access.
-- Android 16/API36 emulator checks: locking, camera/Back, French/English, Home without test apps, wallpaper and sound. Wallpaper retained after force-stop/relaunch with no repeated changes over 15 seconds. Other Android devices not qualified in this step.
-
-### Tundra · Sargo
-
-- Include shared fixes and app policy in a new test boot image. The previous boot reapplied an old file snapshot, undoing hot updates.
-- RAM boot on Pixel passed: six language/Home/picker checks and ten system checks. Image/sound picker results verified without replacing personal selections. Installed boot remains Preview15; no partition flashed in this step.
-
-**Limitations:** third-party FFAA installation remains open. The site depends on old mozApps APIs and a nomodule script; an installer with application and permission isolation still needs implementation. Selecting a Gaia sound does not change the global Android ringtone; actual call audio was not qualified here.
+-  Restored Gaia wallpaper and sound pickers, return to Settings and cancellation through Home.
+-  Apply language changes to Home and preserve them after restart.
+-  Restore horizontal page snapping; hide development apps on Home.
+-  Vulpes startup logo and sound list adapted to the current engine. Media transport shared by desktop and Android.
+-  Android: Gaia locking after sleep and Back from the lockscreen camera. HOME launcher mode validated.
+-  Android: hide system bars in immersive mode, with swipe access. Preview16 APK built and signed.
+-  Desktop and Android 16/API 36 emulator: checked wallpapers, sounds and panels; tested language, locking and wallpaper persistence on Android.
+-  Sargo: updated the boot image to stop restoring outdated files.
+-  Sargo in RAM: 6 UI and 10 system checks passed. Installed build remains Preview15, without flashing.
+-  APK not published. FFAA installation and call audio await validation; Gaia sound selection does not change the Android ringtone.
## In development — 4 October 2026 — Standalone Sargo installation
-### Tundra · Sargo
-
-- Build a fresh ext4 volume directly for userdata, including the system, writable data and vendor/modem hardware images. Startup no longer looks for files in a Droidian installation; installation requires neither an Android session nor ADB.
-- Python Fastboot installer checks the Sargo model, unlock state, partition sizes and checksums, then replaces userdata, dtbo_a, vbmeta_a and boot_a. The bootloader stays unlocked and device-specific calibration is retained.
-- Full offline userdata backup, complete comparison against the phone and recovery archive verification before flashing. Backups remain private; an actual restoration was not performed in this step.
-- Two successful boots from internal storage: eleven Gaia and ten native checks per boot, verified boot-image readback and retained settings. Slot A is marked successful only after validating Gaia startup in the current boot.
-- Generic variant without an SSH server or maintenance keys: actual boot_a flash and boot passed. The development variant was then restored on the Pixel for further work. Generic package images retain their original fresh state.
-- Final checks on the standalone development boot passed for Camera (including lens switches), the Gaia keyboard and notifications.
-- Local Fastboot package assembled with checksums and a qualification report. 44 targeted tests pass. It is not published yet: provenance and redistribution of some hardware components still need final review.
-- Limits: qualified on one Pixel 3a, not every firmware combination; an 8 GiB volume including 1 GiB of writable data, without automatic expansion. Hardware functionality remains experimental.
-
-### Desktop / Android · APK
-
-- No interface changes or new binary in this step. Gaia remains shared by all three platforms.
+-  Sargo: standalone ext4 userdata volume with system, data and vendor/modem components. Installation independent of Droidian and ADB.
+-  Fastboot installer with model, unlock, size and checksum checks. Replaces userdata, dtbo_a, vbmeta_a and boot_a; retains calibration.
+-  Full userdata backup and recovery archive verified before flashing. Actual restoration not tested.
+-  Two installed boots passed: 11 Gaia and 10 native checks per boot, retained settings and matching boot readback.
+-  Variant without SSH or maintenance keys booted after flashing. Development variant reinstalled; camera, keyboard and notifications checked.
+-  Local Fastboot package assembled; 44 targeted tests passed.
+-  Package unpublished: hardware component provenance and redistribution pending. Tested on one Pixel 3a; 8 GiB volume with 1 GiB writable, without automatic expansion.
## In development — 3 October 2026 — Per-device Sargo installation
-### Tundra · Sargo
-
-- Read-only collection of six boot partitions and existing storage, bound to the phone identity and slot A. Backups remain private.
-- Portable device workspace: RAM builds, boot checks, installation and recovery export can use it without the development Pixel’s shared inventory. Workspace-bound builds reject implicit fallback to that inventory.
-- Checked SSH transfers verify identity, storage and checksums and create files without replacing existing data. The transfer tool never formats, flashes or reboots.
-- Build and transfer tested on the Pixel. First RAM boot passed eleven Gaia and ten native checks; keyboard and notifications passed. Camera testing encountered an autofocus timeout, retained in the report.
-- Second boot of the same image retained the Gaia setting and passed eleven Gaia and ten native checks; camera, keyboard and notifications passed. The first autofocus timeout remains documented, not declared fixed.
-- Verify return to the installed build: Gaia starts and the boot partition is unchanged. A repeated transfer correctly refuses to replace existing data.
-- Refine HAL provenance: identify the installed package and download recipe and retrieve historical UBports build recipes. Exact correspondence with the old Android artifact and resolved source manifest remains unverified.
-- Still limited to the qualified kernel and compatible ext4/LVM storage. This does not yet provide direct installation from stock Android.
-
-### Desktop / Android · APK
-
-- No interface changes or new build in this step.
+-  Sargo: portable device workspace for builds, installation and recovery, independent of the development Pixel inventory.
+-  Read-only collection of six boot partitions and storage, bound to the phone identity. Private backups.
+-  SSH transfer with identity, storage and checksum checks, without overwriting, formatting or flashing.
+-  Two RAM boots: 11 Gaia and 10 native checks passed each; setting retained. Autofocus timed out on the first trial; camera worked on the second.
+-  Return to the installed build verified; boot unchanged and data overwrite rejected.
+-  Autofocus timeout not considered fixed. Exact HAL provenance still pending; qualified kernel and compatible ext4/LVM storage required, without direct installation from stock Android.
## In development — 3 October 2026 — Sargo preparation and recovery tools
-### Tundra · Sargo
-
-- Add local package preparation: verify manifest and archive checksums, bound decompression, check ext4 and create a fresh data image for each installation. Existing directories are never overwritten.
-- Add a private recovery kit that can be exported outside the checkout. It retains the original boot_a backup and a standalone tool that checks model, serial number, slot and checksums before explicit restoration. It is not a user-data backup.
-- Existing restoration no longer requires the diagnostic image to remain available. Backup validation is retained.
-- System builds now require an explicit ARM64 hardware reference and reject display-only references or missing required services.
-- Prepare the real system archive and verify the recovery kit locally. No new flash or public ROM release; automated installation on another Sargo still needs completion.
-
-### Shared · Desktop / Android / Tundra
-
-- Retain the shared JSON changelog in future source publications; internal Markdown working documents remain excluded.
-
-### Desktop / Android · APK
-
-- No functional changes or new build in this step.
+-  Sargo: local package preparation with checksum checks, bounded extraction, ext4 validation and fresh data creation without overwriting.
+-  Private recovery kit with boot_a backup and model, serial, slot and checksum checks. User data not included.
+-  Restoration works without the diagnostic image.
+-  System builds require a complete ARM64 hardware reference.
+-  System archive prepared and recovery kit checked locally. Installation on another Sargo pending; no flash or ROM release.
+-  JSON changelog included in future source releases; working documents excluded.
## In development — 3 October 2026 — Sargo modem recovery and fresh installation
-### Tundra · Sargo
-
-- Rebuild the system image with the font-cache fix and creation of oFono’s private state directory on first boot.
-- Limit recovery to the modem subsystem: a controlled restart went offline, recovered and registered on the network in about 22 seconds without rebooting the phone. This does not fix the firmware’s IMS watchdog root cause.
-- Check Gaia startup, hardware rendering, Fira fonts, both camera sensors, focus/zoom, keyboard and visual notifications with a fresh profile. Wi-Fi scanning still works after the modem restart.
-- Two successful boots retained the same Gaia setting in a separate data image. Camera, keyboard and notifications rechecked after the second boot. Installed boot partition and existing data retained.
-- No new native image published: the public installation/recovery package remains unfinished.
-
-### Desktop / Android · APK
-
-- No shared interface changes or new APK build in this step.
+-  Sargo: corrected font cache and first-boot creation of oFono’s private directory in the system image.
+-  Modem recovery without rebooting the phone. Network registration took about 22 seconds in a controlled trial.
+-  Fresh profile: checked Gaia, hardware rendering, Fira, both cameras, focus, zoom, keyboard and notifications. Wi-Fi scan worked after modem restart.
+-  Two boots retained settings in a separate data image. Existing installation preserved.
+-  IMS watchdog root cause unresolved. Public installation and recovery package still incomplete; no image released.
## October 1, 2026 — Sargo distribution preparation — in development
-### Tundra · Sargo
-
-- Rebuild Preview 15 system image with empty profiles, internal working documents excluded, license notices retained and an inventory of 493 runtime packages.
-- Filesystem and content checks completed; these checks do not qualify hardware boot.
-- Read-only storage inventory and mount-table generation from that inventory instead of hardcoded development-phone dimensions. Limited to a single ext4 extent on Sargo userdata; no formatting added.
-- Clean image transferred and verified on the charged Pixel. Temporary RAM boot tests left boot_a unchanged; automatic return to installed v59 and Gaia startup checked.
-- SIM detection, LTE registration and Wi-Fi scanning checked on this image; these checks do not establish end-to-end calling, SMS or mobile data.
-- Fix first-boot font cache ownership by running fc-cache as the session user. Gaia startup, hardware rendering, Fira, both camera sensors, keyboard and visual notifications checked in RAM. Qualification of the rebuilt image remains incomplete.
-- Intermittent reboot traced to modem IMS firmware; targeted recovery policy applied and another boot with LTE, camera, keyboard and notifications passed. Recovery after a new IMS crash has not been exercised. The public image remains non-flashable and unpublished.
-
-### Desktop / APK
-
-- No functional changes or new APK build in this step.
+-  Sargo: rebuilt Preview15 image with empty profiles, licenses and an inventory of 493 packages. Working documents excluded.
+-  Mount table generated from the storage inventory. Support limited to a single ext4 extent on userdata.
+-  First-boot font cache permissions: run fc-cache as the session user.
+-  Filesystem, content and transfer checks passed. RAM trials left boot_a unchanged; return to v59 verified.
+-  SIM, LTE, Wi-Fi scan, Gaia, hardware rendering, Fira, cameras, keyboard and notifications checked in RAM.
+-  Targeted recovery after tracing intermittent reboots to IMS firmware. Second boot passed; recovery from another IMS crash not tested.
+-  Image qualification incomplete; end-to-end calls, SMS and mobile data unvalidated. Image unpublished and non-flashable.
## 2.7-preview.15 — 30 September 2026 — published
+-  First publication of desktop, Android and Tundra sources, with French/English READMEs, licenses and setup scripts. Private files excluded.
+-  SMS: corrected bubble dates and sent/failed states. Improved camera stream cleanup and replacement.
+-  Desktop: stabilized navigation checks, adjusted cold-start timeout and clean shutdown after Gaia times out.
+-  Desktop with Gecko 156.0.1: 42 service tests, startup, navigation, settings, gestures, history, isolation and persistence checked.
+-  Preview15 APK signed with the previous key and GeckoView 156.0; asset parity verified.
+-  Android 16 emulator: validated update, Camera/Gallery, camera resume, ten background returns and renderer recovery.
+-  Sargo: sources included, private v59 retained. No generic ROM; boot depends on storage layout and preinstalled images.
+-  Sargo: preview around 16 fps; flash, video, rotation and deep suspend incomplete. Call audio and the dialog after SMS sending need investigation.
+
[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/v2.7-preview.15)
-### Shared · Gaia / Desktop / Android / Tundra
-
-- First shared source publication, including English/French READMEs, licenses and setup scripts. Internal documents, roadmap, profiles, keys and private images excluded from the public repository.
-- Include the latest SMS fixes (bubble dates, sent/failed states) and camera lifecycle fixes. The two 30 September entries detail the hardware changes.
-
-### Desktop PC
-
-- Published checkout tested with Gecko 156.0.1 and a fresh profile: Gaia startup, navigation, settings, gestures, history and page isolation. 42 service tests pass; service persistence after restart checked.
-- Stabilize navigation checks during normal document replacement and allow more time for cold startup. Close the host cleanly even if Gaia exceeds its preparation timeout.
-
-### Android · APK
-
-- Preview 15 signed with the previous Preview key, GeckoView 156.0; shared resource parity checked. Installed as an update on an Android 16 emulator.
-- Photo/Gallery capture, image display and sensor resume validated. Ten background/resume cycles and recovery after renderer termination validated. This does not qualify every Android phone.
-
-### Tundra · Sargo
-
-- Runtime sources included; private v59 remains the build qualified on the development phone. No generic flashable ROM published: boot still depends on inventoried storage, preinstalled images and private maintenance access.
-- Limits remain: approximately 16 fps camera preview, incomplete flash/video, rotation and deep suspend; call audio awaiting qualification and a large dialog after SMS sending still under investigation.
-
## In development — SMS and camera feedback · 30 September 2026
-### Shared · Gaia / Desktop / Tundra
-
-- Messages: serialize bubble dates as numbers. Fix empty conversations caused by date formatting errors. Composer flow and sent/received bubbles verified on desktop with a simulated modem.
-- Camera: make release idempotent; detach videos, await sensor release and dispose of the WebGL context before switching cameras. Identify each session so an old stream cannot stop its replacement. Repeated rear/front switches produce fresh frames; Home return and sensor shutdown passed on two RAM boots.
-
-### Tundra / Sargo
-
-- Vibration: use the DRV2624 brightness control; the legacy activate command did not physically stop the motor. The user confirmed stopping; single-pulse, 200/200/200 ms pattern and service-stop checks all returned to zero. Add a stop command when the service starts or exits.
-- The user confirms focus, zoom and SMS transport both ways on v54. Five existing conversations render on the Pixel, with message bubbles and no date errors.
-- v59 installed in boot_a after two successful RAM boots; installed startup and readback verified, slot A marked successful. Desktop sources and runtime synchronized. The large dialog after a real send still needs identification; camera smoothness remains separate work.
-
-### Android · APK
-
-- Shared Gaia sources updated; no APK rebuilt or published.
+-  SMS: convert dates to fix empty conversations.
+-  Camera: corrected stream and sensor cleanup; an old session can no longer close its replacement.
+-  Sargo: physical vibration shutdown through DRV2624, with a safeguard at service startup and exit.
+-  Desktop: SMS composition and display checked with a simulated modem. Sargo: camera switching and shutdown tested over two RAM boots.
+-  Sargo v54: user confirmed focus, zoom and two-way SMS. Five conversations displayed; vibration shutdown confirmed.
+-  Sargo v59 installed on boot_a after two RAM boots; startup, readback and slot A verified.
+-  Post-SMS dialog and camera smoothness still pending. Shared sources updated, without a new APK.
## In development — Messages and Camera · 30 September 2026
-### Desktop / Tundra
-
-- Messages now retain the message and conversation after a modem rejection and dispatch Gaia sending, failed and sent events.
-- Distinguish the oFono queue from confirmed transmission; persist terminal states. Modem confirmation is not a recipient delivery receipt.
-- Desktop checks cover failed messages visible in conversations, touch capture, Gallery opening and camera release/resume.
-
-### Tundra / Sargo
-
-- Connect touch focus areas, digital zoom and sensor JPEG capture to the original Gaia Camera. Preview stays at 640 × 480; directly measured JPEG sizes are 4032 × 3024 (12.19 MP) rear and 3264 × 2448 (7.99 MP) front. The front camera has fixed focus.
-- Keep HAL logs out of the binary protocol, bound replies and resume preview after capture. A focus failure must not close the session.
-- Reject SMS submission explicitly when unregistered; retain sent/failed states after the oFono object disappears. Carrier messages triggered Gaia notifications on v49 after LTE registration recovered.
-- Qualification: two v54 RAM boots and the installed boot_a startup passed; image installed on the Pixel and slot marked successful after readback. Persistence, Gaia startup and Camera checks passed. Network registration remains unstable: SMS tests stopped before transmission, with no end-to-end delivery confirmed.
-
-### Android · APK
-
-- No APK rebuilt or published in this pass. The available Preview 14 is unchanged.
+-  Desktop/Tundra: retain SMS after modem rejection and pass sending, failed and sent states to Gaia.
+-  Sargo: persist SMS states after the oFono object disappears; reject sending when the modem is unregistered.
+-  Sargo: touch focus, digital zoom and sensor JPEG capture in Gaia Camera.
+-  Measured photos: 4032 × 3024 rear, 3264 × 2448 front. Preview 640 × 480; fixed front focus.
+-  Native camera: separate binary data from HAL logs, bound responses and resume preview after capture or focus errors.
+-  Desktop: checked visible send failures and Camera/Gallery workflows. Sargo v54: validated two RAM boots, installation, persistence and camera.
+-  Unstable network; no end-to-end SMS delivery confirmed in this pass. Modem confirmation is not a delivery receipt. Public APK unchanged (Preview14).
## 2.7-preview.14 — APK published · 30 September 2026
+-  System information: version alignment and Fira fonts in Shadow DOM components.
+-  Desktop/Tundra: portrait key sizing, cursor placement in the active field and stale input replies.
+-  Desktop/Tundra: Gaia keyboard bridge and numeric keypad for SMS recipients. Android keeps its system keyboard.
+-  Lockscreen: resize without a keyboard window. Honour silent notifications, including after restoration.
+-  Sargo: persistent tray and lockscreen notifications, opening the owning application.
+-  Sargo: cancellable vibrations capped at ten seconds; private persistent Wi-Fi profiles, automatic reconnection and network removal.
+-  Sargo: store incoming SMS before acknowledgement, remove duplicates and create the state directory.
+-  Sargo: earpiece/speaker, microphone mute and call-volume controls. Routing, mute and volume tested outside a call.
+-  Installer: reject flashing diagnostic images with a debugger. Sargo v48 installed after two RAM boots; persistence, readback and slot A verified.
+-  Desktop: Calendar and keyboard checked. Sargo: keyboard, notifications and Wi-Fi reconnection checked; notification sound and real telephony await validation.
+-  Preview14 APK signed and published. Startup, versions and fonts checked on an Android 16 emulator. No Sargo image released.
+
[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-preview.14)
-The Preview 14 APK is published. Desktop is up to date and the private Sargo v48 build is installed and tested; this release does not include a public phone image.
-
-### Shared · PC / APK / Gaia
-
-- About: remove the extra indentation in version rows above “Last updated”; alignment checked on desktop and Android.
-- Fonts: normalize text font names inside Shadow DOM stylesheets, including styles added later; keep icon fonts intact.
-- Calendar: month view opens without JavaScript errors in the desktop check.
-
-### PC / Tundra
-
-- Keyboard: use the screen orientation instead of the short keyboard frame. Keys measure about 60 CSS pixels on Pixel; rotation tested on desktop at DPR 3.
-- SMS: restore the caret to the focused field when Gecko retains the recipient selection; preserve valid selections during editing.
-- Gaia notifications: respect silent, no-sound and no-vibration flags, including restored notifications.
-
-- Gaia keyboard: native input bridge, numeric SMS recipient layout, corrected selection events and protection against stale field replies. Recipient and message typing tested on desktop and Pixel; carrier delivery still requires qualification.
-- Lockscreen: tolerate the absence of a keyboard window during resize.
-- Native notifications: persistent application-owned entries and launch through Gaia's declared Phone entry point. Tray and lockscreen display/removal verified on Sargo; audible sound still needs qualification.
-
-### Tundra / Sargo — v48 build installed
-
-- Vibration: Sargo actuator bridge, patterns limited to ten seconds, non-blocking sequencing and cancellation. Driver activation and stop measured.
-
-- Wi-Fi: private persistent profiles, autoconnect and forget operation; storage and failed-update rollback tests. Connection and reconnection after a RAM reboot verified on Sargo; private profile files (0600).
-- Incoming SMS: persist before acknowledgement and deduplicate retries. Services now create their state directory at startup, fixing a mount failure reproduced on Sargo.
-- Call audio: earpiece/speaker, microphone mute and call volume controls added; earpiece/speaker switching, mute and volume verified without placing a call. Two-way conversation still unverified.
-- Local-debugger images cannot be flashed by the installer. v48/preview.14 written to boot_a after two RAM boots and persistence checks; installed boot verified, image readback matches and slot A marked successful. v43–v48 do not enable a debugger.
-
-### Android · APK
-
-- Preview.14 APK built, signed and published on Forgejo. Startup, version information, alignment and component font selection checked on an Android 16 emulator. Android retains its system keyboard.
-
## 2.7-preview.13 — application workflows · 27 September 2026
-### Shared · Gaia / Desktop
-
-- Calendar: successfully scheduling a reminder no longer rejects event saving; scheduling failures reach the caller.
-- Reminders: ignore deleted events and do not mark denied notifications as delivered. Native Tundra notification support remains incomplete.
-- Notifications: accept empty raw text without requesting an “undefined” translation.
-- Gallery: positive thumbnail size even when starting hidden; cancel preview updates after leaving the editor.
-- SMS: apply correspondent, read state, date range and delivery filters; cover conversation reading and deletion.
-- Deeper review: WebM playback, seeking and deletion; black-and-white photo editing and saved copy; event creation and deletion. Retain original Gaia interfaces.
-
-- Isolated checkout qualification passes startup, desktop tests, persistence and Android packaging without rewriting sources.
-
-### Android · APK
-
-- Signed preview.13 APK built; Camera/Gallery, Calendar, ten background/resume cycles and renderer recovery validated on an Android 16 emulator.
-
-### Tundra · Sargo
-
-- Build v40/preview.13 installed after two RAM boots: Gaia, camera, Wi-Fi, brightness and controls checked; matching boot_a readback and successful slot marking. Remaining limits in `docs/APPLICATION-REVIEW.md`.
+-  Calendar: save events with alarms, report scheduling errors and skip reminders for deleted events.
+-  Notifications: accept empty text; do not mark rejected notifications as delivered. Native Tundra support still incomplete.
+-  Gallery: thumbnail sizing at hidden startup and cancellation of updates after closing the editor.
+-  SMS: apply correspondent, read-state, date and delivery filters.
+-  Desktop: checked WebM playback, photo editing and copying, calendar, conversation reading and deletion.
+-  Isolated checkout: startup, desktop tests, persistence and Android packaging passed.
+-  Preview13 APK built and signed. Camera/Gallery, Calendar, ten background returns and renderer recovery validated on emulated Android 16.
+-  Sargo v40 installed after two RAM boots; Gaia, camera, Wi-Fi, brightness, controls and boot_a readback verified.
## 2.7-preview.12 — application review · 27 September 2026
-### Shared · Gaia / Desktop
-
-- Restore original Gaia 1×–2.25× raster variants for 1,047 CSS image references.
-- Camera switch becomes available after device discovery; unavailable flash stays
- visible and disabled; pinching no longer zooms the whole camera application.
-- Reserve Home gestures for long press and suppress its context menu; retain Gaia's task manager.
-- Relay Gaia touch gestures using unprivileged DOM events, without granting trusted
- user activation to external websites.
-- Bundle the supplied bilingual, local browser welcome page and route links and
- searches into the actual browser.
-- Welcome page follows the Gaia language live: `fr`/`fr-*` select French;
- all other languages select English.
-- SMS: numeric identifiers, migration of older records, Date values, sent events,
- multi-recipient request shape and serialized storage writes.
-- Calendar: restore the current day's accessibility label in 37 locales.
-- Review 14 apps; 26 media/clock/UI checks, contact and SMS checks, plus real Web
- navigation and isolation. Scope and limitations: `docs/APPLICATION-REVIEW.md`.
-
-### Android · APK
-
-- Rebuild `2.7-preview.12` with the same shared UI and local welcome page; check
- payload parity. Android camera capture continues to use getUserMedia.
-
-- Tundra hardware features are not automatically ported to Android: each platform
- retains its adapter and reports its actual capabilities.
-
-### Tundra · Sargo
-
-- Convert NV21 using a shader and transfer binary frames instead of base64:
- approximately 23 fps measured in RAM, versus 8 fps in the intermediate trial.
-- Wait for camera worker exit before opening the other sensor.
-- Manual brightness reaches its target in about 200 ms in the hardware test;
- keep screen sleep/wake fades.
-- Connect Gaia Wi-Fi power settings to actual radio state; support WPA-PSK on
- mixed WPA2/WPA3 networks.
-- Scan, connection and HTTPS over wlan0 validated in RAM; connection profiles are temporary.
-- Wi-Fi profiles are still temporary. Flash, video, light/orientation sensors,
- Bluetooth, data counters and advanced call controls remain incomplete.
-- v38 installed: two RAM boots, installed startup, `boot_a` readback, other boot
- partitions preserved and slot A marked successful.
+-  Gaia icons: restored 1× to 2.25× variants across 1,047 CSS references.
+-  Camera: enable switching after sensor detection and prevent pinch zoom on the UI. Unavailable flash disabled.
+-  Home: restore long press and gesture forwarding without additional privileges for websites.
+-  Browser: local bilingual welcome page following Gaia’s language without restarting.
+-  SMS: corrected identifiers, migration, dates, sending events, multi-recipient requests and concurrent writes.
+-  Calendar: restored the current-day accessibility label in 37 languages.
+-  Reviewed 14 apps; 26 Gallery/Music/Clock/UI checks, Contacts/SMS tests and web isolation checks.
+-  Preview12 APK rebuilt with shared assets; parity verified. Hardware features handled separately on Android and Tundra.
+-  Sargo: shader-based NV21 preview and binary transport, about 23 fps measured versus 8 in the intermediate trial.
+-  Sargo: wait for camera worker shutdown before switching sensors; manual brightness reaches its target in about 200 ms.
+-  Sargo: Wi-Fi power control from Gaia and WPA-PSK connections to mixed WPA2/WPA3 networks.
+-  Sargo: scan, connection and HTTPS validated in RAM. v38 installed after two RAM boots, with readback and slot A checks.
+-  Sargo: temporary Wi-Fi profiles; flash, video, sensors, Bluetooth, data counters and advanced call controls incomplete.
## In development — Sargo feedback and interface · 27 September 2026
-### Shared · Gaia / Desktop
-
-- Shutdown animation: three blue shades replace the orange rings; original Gaia
- dimensions, sequence and timing are preserved.
-
-- Phone: prevent document scrolling on tab changes, keeping Recents and Contacts
- headers in place. Use Gaia’s original call-log artwork at multiple densities up to 2.25×.
-- Select controls: remove the old engine offset that clipped Fira descenders.
- Video: keep the introductory text within the viewport.
-- Brightness: save a number from input/change events; normalize legacy text values
- before Gaia animates the transition. Coalesce stale hardware brightness steps
- while preserving the order of sleep/wake commands.
-- Home: extend wallpaper beneath the transparent gradient, reserve navigation space
- inside applications, and place Home on the right in landscape.
-- Browser: editable local page at `overrides/search.gaiamobile.org/home/index.html`,
- retaining Gaia integration and private mode.
-- Wi-Fi: show a scan failure and retry action instead of endless retries. Network
- discovery and results in the original Gaia panel now verified on Sargo in RAM.
-- Consolidated tracking: `docs/CHANTIER.md` includes earlier and new feedback and limitations.
-
-### Android · APK
-
-- Build and sign `2.7-preview.11` with shared Gaia; payload parity checked.
- Place Home on the right in landscape and fix its wallpaper background.
- Check rotation and non-overlapping app/navigation bounds on Android 16 emulator.
-
-### Tundra · Sargo
-
-- Camera: hardware preview in Gaia, JPEG capture and rear/front switching verified
- on Sargo in RAM; sensors close on returning Home. Capture is currently limited
- to 640 × 480 (480 × 640 portrait); video and flash remain unavailable.
- Desktop and APK keep getUserMedia and share the same Gaia interface.
-
-- v33 (`2.7-preview.11`) installed after two successful RAM boots. Installed boot, `boot_a` readback,
- data preservation and slot A success finalization verified.
-- Backlight measured from Gaia: 35% → 89/255, 80% → 204/255. Wi-Fi results visible
- in the original panel. Blue shutdown rings included.
-- Call audio: support PulseAudio 14 text output and prevent loading audio modules twice.
- Voice-call profile, earpiece selection and media-profile restoration verified on Sargo.
- An actual two-way conversation remains unvalidated.
-- Still open: audible two-way calls, full-resolution camera capture/video and sensor-driven rotation.
- See the tracking document.
+-  Gaia shutdown animation recoloured blue.
+-  Phone: keep headers fixed when changing tabs and use call-log icons up to 2.25× density.
+-  Clipped dropdown text and overflowing Video welcome text.
+-  Brightness: numeric values and coalesced stale commands, preserving sleep/wake order.
+-  Home: wallpaper under the gradient and right-side button in landscape, without covering apps.
+-  Browser: editable local welcome page, retaining private mode.
+-  Wi-Fi: error message and Retry button instead of endless retries.
+-  Preview11 APK built and signed; rotation and Home button placement checked on emulated Android 16.
+-  Sargo: preview, JPEG and camera switching validated in RAM. Capture limited to 640 × 480; flash and video unavailable.
+-  Sargo v33 installed after two RAM boots; startup, data, readback and slot A verified. Brightness and Wi-Fi results checked in Gaia.
+-  Sargo: PulseAudio 14 compatibility and duplicate audio module loading. Call/media profiles and earpiece tested.
+-  Sargo: call audio, full-resolution capture, video and automatic rotation await validation.
## In development — native Wi-Fi stability · 27 September 2026
-### Shared · Gaia / Desktop
-
-- Fix the Wi-Fi loop with no delay: scan every 20 seconds as intended by Gaia.
- Coalesce concurrent scans. Add burst, failure and timer regression tests.
-- Shared development version: `2.7-preview.9`, Gaia `2.7.0`; actual Gecko versions
- remain unchanged.
-
-### Android · APK
-
-- Build `2.7-preview.9` to retain shared Gaia parity. Android still manages its
- own Wi-Fi; this change does not claim to add a hardware driver to the APK.
-
-### Tundra · Sargo
-
-- Identify the v21/v22 failure: unbounded Python Wi-Fi scan clients exhausted
- memory, causing the lowmemorykiller to terminate Gecko. The phoc SIGSEGV occurs
- afterwards during display teardown.
-- Allow one hardware scan at a time, briefly cache results and errors, invalidate
- after connect/disconnect. Application isolation and memory protection remain enabled.
-- Qualify v25 through two RAM boots, then install and verify it on the Pixel:
- 11 Gaia and 10 system checks pass, with no memory-pressure process kills.
- Check the Gaia lockscreen, Fira, battery, date and Wi-Fi panel; exercise
- sleep/wake and volume through injected input events.
-- Verify `boot_a` by readback, preserve the other boot partitions and user data,
- and mark slot A successful. Native camera support and full call validation
- remain unfinished.
+-  Wi-Fi: restore the 20-second scan interval and coalesce concurrent scans.
+-  Sargo: one hardware scan at a time, with a short cache cleared on connect/disconnect. Fixes memory exhaustion in v21/v22.
+-  Wi-Fi burst, failure and timer tests.
+-  Shared Preview9 version, Gaia 2.7.0; new APK with the same sources. Android still manages its own Wi-Fi.
+-  Sargo v25: two RAM boots then installation, 11 Gaia and 10 system checks passed, without low-memory process kills.
+-  Checked locking, fonts, battery, date and Wi-Fi; tested sleep/wake and volume with injected events. Data, readback and slot A verified.
+-  Native camera and full call validation still pending.
## In development — Gaia restoration · 27 September 2026
-### Shared · Gaia / Desktop
-
-- Restore Gaia panels and remove temporary windows/floating controls: Settings,
- Usage, FM and contact/media import. Features without an API remain visible with
- disabled controls, without invented hardware values.
-- Fix legacy style scoping: ordinary `div` selectors no longer become component
- roots. Restore lockscreen icons, retain Fira and use scalable Gaia glyphs for
- Phone tabs.
-- Reserve 44 CSS pixels for Home so it cannot cover Answer/Hang up. The date uses
- its original component; battery updates reach Settings and its labels no longer
- display `{{ level }}`.
-- Re-enable the original Gaia lockscreen; temporary splash uses the supplied logo,
- stable dimensions and a gentle animation. Localized name: Navigateur / Browser.
- Credits appear in Legal Information; original license notices remain.
-- All 26 application checks pass (Gallery, Music, Clock, task management). Further
- desktop checks cover panels, imports, battery, date and navigation; simulated
- calls, history, sleep/wake, Power menu and volume. See `docs/GAIA-RESTORATION.md`.
-
-### Android · APK
-
-- Build and install `2.7-preview.8` on the Android 16 emulator with shared Gaia; read battery state from Android,
- add Android Settings at the bottom and optional Android Home mode. The Android
- application catalogue in Gaia still needs integration.
-- Android checks: ten background/resume cycles and renderer termination recovery;
- shared payload parity verified. No physical Android device test in this pass.
-- Imports use Gaia components; the SMS button opens Android confirmation.
- The internal lockscreen does not replace Android security.
-
-### Tundra · Sargo
-
-- Build v21/v22 candidates with all `overrides/` fixes in the boot payload; connect Wi-Fi
- scan/association to the Gaia panel through NetworkManager.
-- RAM trials blocked: the display service exits with code 139 before qualification.
- No new flash. Restore installed v20 and verify 11 Gaia and 9 system checks with
- preserved data; restore the native profile’s previous unlocked state. Candidate
- Wi-Fi, native camera, full SIM/Bluetooth APIs and CPU suspend remain unqualified.
+-  Restored Gaia Settings, Usage, FM and import panels. Temporary interfaces removed; unavailable features disabled.
+-  Corrected legacy component styles, lockscreen icons and Phone glyphs.
+-  Reserve 44 pixels for Home; restore date and battery without `{{ level }}` placeholders.
+-  Gaia lockscreen re-enabled, splash stabilized, Browser name localized and credits added.
+-  Desktop: 26 app checks passed, plus panels, imports, battery, navigation and simulated calls.
+-  Preview8 APK installed on emulated Android 16: Android battery, settings access and optional HOME mode. Android app catalogue still incomplete.
+-  Emulated Android: ten background returns, renderer recovery and asset parity checked. Gaia locking remains separate from Android security.
+-  Sargo v21/v22: Wi-Fi scan and association connected to Gaia through NetworkManager.
+-  Sargo: RAM trials interrupted by display-service failure (code 139). Return to installed v20 verified, without a new flash.
+-  Candidate Wi-Fi, camera, SIM/Bluetooth APIs and CPU suspend unvalidated.
## In development — 27 September 2026
-### Shared · Gaia / Desktop
-
-- Replace the temporary call overlay with Gaia’s original **Callscreen**:
- incoming/outgoing calls, answer, hang up and automatic close. A compatibility
- adapter supplies `mozTelephony` events to the original modules. Unsupported
- audio/conference controls remain disabled.
-- Persist completed calls in the host and import them into Gaia’s original call
- log, with acknowledgement and duplicate detection after interruption.
-- Connect real battery state to the original status icon; unknown is not empty.
- Handle a missing/malformed Facebook contacts index without blocking local
- number lookup or call history.
-- Compare rendered font faces: home already used Fira on both platforms, but
- desktop system text partly used Noto. Main text rules now select Gaia’s
- bundled Fira face while preserving icon fonts.
-- Desktop Gecko test with a simulated modem covers incoming/outgoing UI,
- answer/hang up, history, screen sleep/wake, power menu, volume and battery.
- No APK rebuilt in this step.
-
-- Earlier v13 step (superseded above): system-owned call screen with dialing, ringing, connected state and Hang up.
- Controls remain available if the service temporarily loses call status.
-- Tested in desktop Gecko with a simulated modem, including hang-up failure and
- retry. Desktop sources synchronized; no APK rebuilt.
-
-### Tundra · Sargo
-
-- Private v20 installed and read back in `boot_a` after two RAM boots and one
- installed boot; settings retained and slot marked successful.
-- Read only Sargo’s physical Power/Volume keys and forward
- their edges to Gaia HardwareButtons. Short press sleeps/wakes the screen;
- long press opens Gaia’s menu. The restricted native broker controls backlight,
- volume, shutdown and reboot.
-- Serialized brightness and power changes: concurrent requests no longer
- leave the backlight disabled while waking the screen.
-- Native volume: expose the session PulseAudio socket read-only to the service;
- otherwise Gaia’s indicator changed while the audio output remained unchanged.
-- Screen sleep does not suspend the kernel yet. Real incoming calls, conversation
- audio and advanced call audio controls remain to be qualified.
-
-- Earlier step: private v13 installed in `boot_a` after two RAM boots; installed boot,
- readback and the A/B success flag verified.
-- oFono call state reporting and Answer/Hang up commands restricted to modem call paths.
-- Successful void D-Bus responses no longer cause a false invalid-response error.
-- A short real outgoing call reached ringing and was automatically hung up.
- Conversation audio and incoming calls remain unqualified.
-- Camera diagnostics no longer mistake a timeout for successful initialization.
- Libhybris detects two sensors, but the preview probe receives no frames.
- Native Gaia camera access remains unavailable.
+-  Gaia Callscreen for incoming/outgoing calls, answer and hangup. Replaces the temporary v13 screen.
+-  Persistent call log with acknowledgement and duplicate detection.
+-  Distinguish unknown battery state from empty battery; tolerate missing or malformed Contacts indexes.
+-  Render system text with Fira Sans while preserving icon fonts.
+-  Desktop with simulated modem: calls, failed hangup and retry, history, sleep/wake, Power menu, volume and battery.
+-  Sargo: Power/Volume buttons connected to Gaia, with native screen, volume, shutdown and reboot controls.
+-  Sargo: serialize brightness and power changes to prevent a black screen on wake; fix PulseAudio access for volume.
+-  Sargo: successful hangup with an empty D-Bus reply no longer produces a false error.
+-  Sargo v13 then v20 installed after RAM trials; startup, readback, settings and slot validation checked.
+-  Real outgoing call reached ringing and was hung up. Incoming calls and conversation audio unvalidated.
+-  Camera diagnostics: a timeout no longer counts as successful initialization. Two sensors detected, without received frames.
+-  Native camera unavailable, CPU suspend and advanced audio controls incomplete. No new APK.
## In development — 26 September 2026
-### Shared · Gaia
-
-- Fonts: use the archived simulator’s Fira Sans OpenType files for both Gaia
- pages and Tundra system text. Fix native sans-serif fallback and add Fontconfig
- validation; icon fonts remain separate.
-- Settings: remove the “Tundra settings” and “Test a notification” diagnostic
- buttons. Release information and the technical details remain available.
-- Hardware panel: pass platform capabilities into the panel. An undefined variable
- used to open a Gecko alert instead of displaying battery information.
-
-### Desktop
-
-- Local adapter synchronized with the shared sources. Gecko remains at 156.0.1.
-
-### Android
-
-- Hardware panel fix is in the shared sources; no new APK has been built.
-
-### Tundra / Sargo
-
-- Explicit A/B boot-success finalization after installation: backed-up and checked
- GPT metadata, verified Qualcomm utility, Fastboot confirmation.
-- Modem rejections distinguished from transport failures; errors shown inside Gaia.
- The unavailable native camera no longer suggests an ineffective permission change.
-- Rendered-font and radio diagnostics without automatic calls or messages. Fira Sans
- confirmed on the home screen and status bar.
-- Installed the v9 font build into `boot_a` on the inventoried Sargo. Written
- image verified by readback; the other five backed-up boot partitions are unchanged.
-- Two boots from device storage pass 10 Gaia and 9 system checks each and retain
- settings. A PC is no longer required to boot. Droidian fallback files retained;
- actual restoration has not been tested.
-- Private persistent candidate: separate 2 GiB ext4 data image, retained Gaia
- settings/profiles and system identities, no ten-minute reboot watchdog.
-- Two consecutive boots of the same v8 image: 9 Gaia and 9 system checks pass on
- each boot. Separate diagnostic: 15 checks using identical shared UI files.
-- Boot-a-only installation and original-boot restoration tools prepared and tested
- locally. No actual flash performed; remaining hardware limits are documented.
-- Normal startup no longer opens Settings/Clock or scrolls the home screen. These
- actions now belong to the explicit `--diagnostics` mode.
-- Enter fullscreen before loading Gaia and hide native window decorations. On the
- tested Pixel: 360 × 740 CSS pixels at scale 3, stable dimensions and an unchanged
- home screen throughout five post-startup samples.
-- v13 RAM trial passes eight startup checks and nine system checks. Reports now
- distinguish startup observation from interactive qualification.
-- Separate v14 diagnostic: thirteen interactive checks pass, including the hardware
- panel; Wi-Fi panel and scanning verified with the same UI files as normal v13.
-- Interactive diagnostic images are rejected as the normal launch target. The
- earlier v13 selection is retained in the launcher history.
-- Modem registered on UMTS during the trial; audio playback completed and microphone
- samples were measured without retaining a recording. In-call audio, camera and
- suspend remain unqualified.
-- Ramdisk dependency discovery now runs `readelf` in the C locale, preserving the
- ARM64 dynamic loader regardless of the build machine's language.
-
-All six A/B boot/DTBO/vbmeta partitions are unchanged. This remains a private RAM
-trial, **not a flashable ROM**. See [Tundra status](tundra/docs/STATUS.md).
+-  Use historical Fira Sans fonts in Gaia and Tundra; correct sans-serif fallback and Fontconfig checks.
+-  Removed diagnostic buttons from Settings.
+-  Hardware panel: pass platform capabilities correctly to display the battery.
+-  Sargo: distinguish modem and transport errors; correct the unavailable-camera message.
+-  Sargo: persistent data on a 2 GiB ext4 image and A/B success validation after installation.
+-  Sargo v8: two boots with persistence, 9 Gaia and 9 system checks per boot. Installation and recovery tools tested locally.
+-  Sargo v9 installed on boot_a: two boots with 10 Gaia and 9 system checks each. Matching readback, five other partitions preserved; actual restoration untested.
+-  Normal startup without automatic diagnostic gestures; fullscreen before Gaia, stable 360 × 740 CSS-pixel layout.
+-  Separate `--diagnostics` mode; diagnostic images excluded from normal launch.
+-  Sargo: v13 tested in RAM (8 startup, 9 system checks), v14 diagnostics (13 interactive checks), including Wi-Fi.
+-  UMTS registration, audio playback and microphone signal checked. Call audio, camera and suspend unvalidated.
+-  Ramdisk builds independent of the desktop language by running readelf in the C locale.
+-  Private trials, without a public ROM. Desktop sources synchronized; shared Android fix available, without a new APK.
## In development — 25 September 2026
-### Shared · Gaia
-
-- **Navigation:** a circular Home button, a black-to-transparent gradient over the wallpaper,
- and a solid blue bar inside apps. Pressing Home now changes the ring instead of leaving
- a blue rectangle over the home screen.
-- **Scrolling:** the gradient disappears at the bottom of the home panel and returns when
- scrolling up. Switching between the Apps and Pages panels is supported.
-- **Typography:** bundled Fira Sans fonts, with multiple weights and italics, instead of
- relying on the host system’s installed fonts.
-- **Device information:** separate product, Gaia, actual engine, platform, model and adapter
- versions. The panel includes support for Tundra and hardware adaptation fields;
- connecting these to the native platform is still pending.
-- **Icons:** loading resumes after an initial zero-width measurement, across desktop,
- Android and Tundra. This fix was previously limited to Android.
-- **Maintenance:** this changelog brings together the three platforms’ progress.
-
-### Desktop · Locally tested
-
-- Tested an icon initially measured at zero width, then made visible.
-- Tested Home / Settings / Home navigation, a transparent bar at the bottom, the gradient
- at the top and permission checks in an isolated profile.
-- Tested the information panel in French and English, without duplicate legacy rows.
-- The engine remains Gecko **156.0.1**; these UI changes do not rebuild Gecko.
-
-### Android · Sources adapted, Java compiled
-
-- The GeckoView / Android bridge implements the same navigation states: an overlay on Home,
- with space reserved for the bar in apps and the browser.
-- Navigation state survives Android activity recreation.
-- Java compilation passed. **No new APK delivered for these changes**;
- visual behaviour still needs testing on Android.
-
-### Tundra / Sargo · Private RAM trials
-
-- Network build v6 is the launcher reference after 13 Gaia and 9 system checks;
- automatic return and unchanged hashes of all six boot partitions verified.
-- Separate Tundra backend reports Pixel 3a, Sargo adaptation and native version,
- without claiming an unimplemented camera or telephony stack.
-- Real battery and charging state with a bilingual Gaia hardware panel.
- The network trial passed 13 Gaia checks, including this panel.
-- Real Wi-Fi tested: scan, connection, IPv4, DNS and HTTPS in Gecko. Added
- `pd-mapper`, disabled uninitialized IPA offload and premature camera probing;
- 9 system checks pass without kernel Oops. Gaia network selection is still
- pending; trial credentials remain in RAM.
-- Service configuration can travel in the small ramdisk, avoiding another 3 GiB
- transfer per boot adjustment. Qualcomm trials use RAM copies of modem NV data.
-
-- A new ext4 system image contains the ARM64 runtime, reference HALs, Phoc, Gecko and Gaia.
- The runtime is no longer loaded from the Droidian working directory, nor the HAL
- from its previous `/var/lib/lxc/android/` location.
-- The second Pixel 3a trial passed **7 Gaia checks and 8 system checks**, with Tundra PID 1,
- its HAL services, compositor and Gaia.
-- A physical display capture confirms home icons, loaded Fira Sans, the circular button
- and gradient. Gecko **156.0.1 ARM64**, with hardware WebRender.
-- Small UI changes travel in the trial ramdisk, avoiding another 3 GiB system transfer
- for each CSS or JavaScript adjustment.
-- Build sources are saved alongside new images and verified by hashes, so qualified
- images remain testable after the checkout changes.
-- The latest trial (`sargo-system-boot-20260925-v5`) passes **10 Gaia checks and 8 system
- checks**, including icons, fonts, pressed Home, bottom-of-page behaviour and app return.
- Physical capture reviewed; local launcher reference updated. No APK published or phone flashed.
-
-**Limitations:** the installed filesystem still carries the system image; writes are lost
-at shutdown; the trial returns to the retained system after ten minutes. No flashing or
-Droidian replacement. A warm compositor restart failed during one trial; cold boot works.
-Networking, modem, audio, camera and suspend still need integration or qualification.
-This is not a public installable ROM or a validated daily-use phone.
+-  Round Home button, transparent wallpaper gradient and blue bar inside apps.
+-  Hide the gradient at the bottom and restore it when scrolling; remove the blue rectangle when pressing Home.
+-  Bundled Fira Sans weights and italics; separate product, Gaia, engine and platform versions.
+-  Resume icon loading after an initial zero-size measurement on all three platforms.
+-  Desktop: icons, navigation, permissions and French/English information panel checked with Gecko 156.0.1.
+-  Android: retain navigation state after activity recreation. Java compilation passed; visuals need retesting, no new APK delivered.
+-  Tundra: ext4 image with ARM64 runtime, HAL, Phoc, Gecko and Gaia; small fixes bundled in the ramdisk.
+-  Sargo: dedicated backend, Pixel 3a information and hardware battery readings. Build sources archived with checksums.
+-  Sargo: hardware WebRender, icons and fonts checked on a physical display capture. v5: 10 Gaia and 8 system checks passed.
+-  Sargo: add pd-mapper, disable uninitialized IPA and premature camera probing. Copy modem NV data to RAM for trials.
+-  Sargo v6: Wi-Fi scan, connection, IPv4, DNS and HTTPS verified; 13 Gaia and 9 system checks passed. Gaia network selection still pending.
+-  RAM trials without flashing, automatic return after ten minutes and changes lost at shutdown. Cold boot passed; warm compositor restart failed.
+-  No public ROM; modem, audio, camera and suspend still need integration or validation.
## 2.7-preview.7 — APK built, local reference
-### Shared / Desktop / Android
-
-- Fixed internal camera allocation permission on desktop and recovery after the first
- CAMERA permission prompt on Android.
-- Photo distinguishes permission denial, a missing sensor and an opening failure.
-- Shared product version **2.7-preview.7**, Gaia **2.7.0**, with separate engine versions.
-- Desktop Gecko **156.0.1**, Android GeckoView **156.0**: the real patch difference is
- reported rather than relabelling either engine.
+-  Desktop camera permission and recovery after the first Android permission prompt.
+-  Separate messages for denied permission, missing sensors and opening failures.
+-  Separate product and engine versions: Vulpes Preview7, Gaia 2.7.0, desktop Gecko 156.0.1 and Android GeckoView 156.0.
## 2.7-preview.6 — APK built
-### Shared / Desktop / Android
-
-- Ported the original Gaia Camera app: webcam preview and JPEG capture on desktop,
- or capture through GeckoView on Android.
-- Photos saved in Gaia Gallery; media imports use the host system’s file picker.
-- Unsupported controls disabled, including flash and video; camera switching depends
- on available sensors.
+-  Gaia Camera on desktop and Android: preview and JPEG capture through webcam or GeckoView.
+-  Save photos in Gallery and import through the system picker.
+-  Flash and video disabled; camera switching depends on available sensors.
## 2.7-preview.5 — APK built
-### Shared
-
-- Centralised versions in `release.json`, separating product, Gaia and engine versions.
-- Shared native integration UI between desktop and Android; packaged common files
- checked inside the APK before distribution.
-
-### Desktop
-
-- Integration with available Linux settings panels, media and vCard imports,
- desktop notifications and a test webcam capture.
-
-### Android
-
-- Same Gaia interface and native commands as desktop, adapted to Android capabilities.
-- Foreground return and recovery after rendering-process termination tested.
+-  Versions centralized in `release.json`; product, Gaia and engine separated.
+-  Shared desktop/Android native interface and APK asset parity checks.
+-  Desktop: Linux settings, media/vCard imports, notifications and test webcam capture.
+-  Android: foreground return and recovery after renderer termination.
## 2.7-156.0.2 — Historical APK built (revision 4)
-This historical name mixed product revision and engine version; it does not mean
-that the APK used Gecko 156.0.2. Its Android engine was Gecko 156.0.
-
-### Android
-
-- Activity and session recovery to reduce white screens when returning to Vulpes.
-- Access to Android settings, volume and window brightness.
-- Media and contact imports, native notifications with permissions.
-- Handoff to Android’s dialer, SMS and clock apps; no silent sending or replacement
- of the system Phone and SMS apps.
-- Capture initially delegated to Android’s camera app, before the Gaia Camera port.
+-  Android: activity and session recovery to reduce blank screens.
+-  Access to Android settings, volume and window brightness.
+-  Media and contact imports; native notifications with permissions.
+-  Handoff to Android Phone, SMS, Clock and Camera. No silent sending or replacement of default apps.
+-  Historical numbering: Android engine Gecko 156.0, despite the 2.7-156.0.2 name.
## 2.7-156.0.1 — APK published (revision 3)
-- **Vulpes OS - Preview** name and project icon.
-- Wallpaper flickering fix included.
-- Historical numbering retained; it does not identify the Android engine’s patch version.
+-  “Vulpes OS - Preview” name and project icon.
+-  Wallpaper flickering corrected.
+-  Historical numbering, separate from the Android engine version.
[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-156.0.1)
@@ -808,7 +549,7 @@ that the APK used Gecko 156.0.2. Its Android engine was Gecko 156.0.
## 2.7-preview.2 — APK published
-- Fixed wallpaper flickering in the Android Preview.
+-  Wallpaper flickering in the Android Preview.
[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-preview.2)
@@ -816,8 +557,8 @@ that the APK used Gecko 156.0.2. Its Android engine was Gecko 156.0.
## 2.7-preview.1 — APK published
-- First Android Preview bundling Gaia and GeckoView.
-- Archived version; known wallpaper flickering issue.
+-  First Android Preview with Gaia and GeckoView.
+-  Archived version; known wallpaper flickering issue.
[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-preview.1)
@@ -825,26 +566,12 @@ that the APK used Gecko 156.0.2. Its Android engine was Gecko 156.0.
## Earlier milestones — technical retrospective
-These milestones are not newly dated public releases.
-
-### Gaia / Desktop
-
-- Vulpes assembled from historical Gaia: branding, wallpaper, translations and
- work on system information.
-- Experimental Gecko 45 → 52 port, followed by separation of Gaia, services and
- the engine for the current Gecko host.
-- Reimplementation of required APIs: settings, application registry, local contacts,
- messages, alarms, storage and inter-app communication.
-- Adaptation of old UI components, simulated touch, browser and applications;
- testing of archived Firefox OS apps.
-- Gecko update tools with checks, candidate engines and explicit rollback.
- They detect incompatibilities without automatically repairing them.
-- Gaia launch and updates without rebuilding the whole engine.
-
-### Tundra / Sargo
-
-- Pixel 3a inventory, boot partition backups and USB diagnostic trials.
-- QEMU trials, then ARM64 Gecko inside Droidian and under a dedicated compositor.
-- Separate root, selected HAL services and Tundra PID 1; temporary graphical
- Fastboot trials with rescue SSH and automatic return.
-- Reference kernel **4.9-124-google-sargo**, **Android 9 / API 28** adaptation.
+-  Historical Gaia base with Vulpes branding, wallpaper, translations and system information.
+-  Experimental Gecko 45 to 52 port, followed by separation of Gaia, services and engine.
+-  APIs for settings, apps, contacts, messages, alarms, storage and inter-app communication.
+-  Adapt Gaia components, touch gestures, browser and apps to recent engines.
+-  Gecko updates with checks and rollback; Gaia updates without rebuilding the engine.
+-  Archived Firefox OS apps, QEMU, then ARM64 Gecko inside Droidian and under a dedicated compositor.
+-  Sargo: inventory, boot backups, separate root, HAL services and Tundra PID 1.
+-  Temporary graphical Fastboot trials with SSH recovery and automatic return.
+-  Kernel 4.9-124-google-sargo, Android 9/API 28 adaptation. Historical milestones, without newly dated releases.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c797ca7c..ac32180c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,828 +1,547 @@
-# Journal des évolutions — Vulpes OS
+# Journal des modifications — Vulpes OS
[English](CHANGELOG.en.md)
-Une base Gaia, trois plateformes : **PC**, **Android (APK)** et **Tundra / Sargo**.
-Ce journal décrit les changements visibles et les étapes techniques importantes.
-Les rapports détaillés restent dans la documentation de chaque plateforme.
+PC, Android (APK) et Tundra/Sargo. Les statuts indiquent des sources en développement, un APK construit ou une version publiée. Les changements des sources nécessitent une mise à jour du paquet installé.
-**Statuts :** « En développement » concerne les sources locales, « APK construit »
-un paquet de test, et « Publié » une release accessible dans le dépôt. Un correctif
-commun n’apparaît dans un APK installé ou une image téléphone qu’après leur mise à jour.
+
-
-Paramètres de Gaia · capture de la version pour ordinateur.
+## 2.7-preview.20 — APK construit · 13 octobre 2026
+
+-  Preview 20 réunit les corrections communes depuis l’APK Preview 17 : paramètres Gaia, apparence, sélecteurs, clavier, contacts, Marketplace et médias.
+-  Photo : enregistrement WebM avec miniature et lecture dans Galerie/Vidéo. Autorisation micro demandée ; commandes adaptées aux capacités du matériel.
+-  Caméra : changement de capteur, arrêt et reprise fiabilisés. PC et Android utilisent leurs API caméra ; Sargo conserve son adaptateur HAL.
+-  Sargo : mises à jour Wi-Fi, Bluetooth, appels, SMS/MMS et réglages réseau incluses dans les sources. Les fonctions non raccordées restent indiquées dans les panneaux.
+-  Changelog : reprise des textes simplifiés FR/EN, badges conservés, filtres PC/APK/Sargo indépendants des titres. Markdown, site et notes de release issus du même JSON.
+-  APK signé avec la clé des Preview précédentes ; alignement et cohérence des 53 fichiers partagés vérifiés. Validation d’exécution Android en cours.
+-  Gecko PC/Sargo 156.0.1 ; GeckoView Android 156.0. Gaia 2.7.0. L’APK délègue appels/SMS à Android : il ne remplace pas le système téléphonique Android.
+-  Preview expérimentale : Compte Mozilla/Sync, localisation distante, extensions Firefox OS et transfert USB restent incomplets. Sargo : aperçu autour de 16 FPS ; son des vidéos et audio téléphonique à qualifier séparément.
+-  Pixel 3a : Preview 20 r62 démarrée en RAM puis flashée sur boot_a. Empreinte relue identique ; démarrage installé, onze contrôles Gaia et dix contrôles natifs réussis. Données conservées, Wi-Fi connecté et enregistrement LTE observé. Pas de nouvelle conversation audio testée.
+
+
+
+## En développement — 12 octobre 2026 — Vidéo et réglages de l’heure
+
+-  Photo : enregistrement WebM VP8/Opus avec autorisation micro, miniature et stockage. Tampon limité à 32 Mio, arrêt avant libération du capteur.
+-  Photo, Galerie et Vidéo : prise en charge des fichiers WebM et de leurs miniatures.
+-  Paramètres : véritables sélecteurs Date/Heure Gaia, retour du clavier intégré et lettres non tronquées dans les listes.
+-  Améliorer Vulpes : lien vers les tickets du projet. Services non raccordés expliqués dans leurs panneaux.
+-  PC : vidéo simulée 1920 × 1080 relue ; photo, galerie, reprise caméra et navigation vérifiées.
+-  Sargo r61 en RAM : vidéo 480 × 640 relue, huit bascules avant/arrière réussies. Médias de test supprimés ; son enregistré non écouté.
+-  Sargo : aperçu mesuré autour de 16 FPS contre 12 dans le test précédent. 30 FPS restent à atteindre.
+-  Sargo : heure automatique par NITZ, réglage persistant et protection des écritures manuelles. Synchronisation dépendante du réseau ; pas de NTP de secours, fuseau choisi manuellement.
+-  Android : ressources préparées et Java compilé, sans nouvel APK ni test vidéo Android à cette étape. Compte Mozilla/Sync, localisation distante, extensions Firefox OS et transfert USB restent à implémenter. Aucun flash ni publication.
+
+
+
+## En développement — 12 octobre 2026 — Appels et partage Wi-Fi
+
+-  Dialogues Gaia : fermeture après ouverture, transitions interrompues et nouvelle saisie après validation refusée.
+-  Sargo : délai des services opérateur augmenté ; réglages lents séparés des commandes voix/SMS.
+-  Sargo : double appel, présentation du numéro et quatre règles de renvoi raccordés à oFono, avec validation et relecture.
+-  Sargo : point d’accès Wi-Fi WPA2 ou ouvert, configuration NetworkManager et pare-feu adapté. Mot de passe privé, accessible uniquement à Paramètres.
+-  r59 en RAM : 69 panneaux, dialogues et APN vérifiés sur PC/Pixel. Point d’accès enregistré, activé puis arrêté depuis Gaia ; Wi-Fi précédent restauré. 44 tests Python et régressions des dialogues réussis.
+-  Transit Internet vers un autre appareil non testé. Messagerie vocale, blocage d’appels, PIN/PUK, certificats Wi-Fi et partage USB restent ouverts. Ressources Android synchronisées ; aucun nouvel APK, flash ou publication.
+
+
+
+## En développement — 12 octobre 2026 — Paramètres réseau
+
+-  Wi-Fi : adresse IPv4 et débit réels ; ne plus afficher « undefined » lorsqu’une mesure manque.
+-  Sargo : choix GSM/UMTS/LTE et modification des APN Internet/MMS existants, avec validation et relecture.
+-  APN : refuser les changements d’un profil actif, protéger IMS et les mots de passe, tenter la restauration après échec et signaler son résultat.
+-  Sargo : file bornée pour les réglages lents. Lecture des appels en 20 ms pendant une attente de réglages de 5 secondes.
+-  38 tests Python, 69 panneaux PC et parcours APN/réseau sur Pixel en RAM vérifiés. Ressources Android synchronisées.
+-  Ajout, suppression et réinitialisation des APN indisponibles. Sélection manuelle d’opérateur et certificats encore ouverts. Aucun nouvel APK, flash ou publication.
+
+
+
+## En développement — 12 octobre 2026 — Audit des paramètres
+
+-  Paramètres : navigation récupérable après échec d’un contrôleur ou d’un dialogue ; génération cohérente des modules.
+-  Filtres d’accessibilité et assistance Vulpes. Panneaux d’origine conservés avec explication des fonctions indisponibles.
+-  PC : statistiques du profil et téléchargements Gecko. Android : statistiques du stockage privé.
+-  Sargo : données mobiles, itinérance, horloge et Wi-Fi masqué raccordés. Erreurs des anciennes interfaces réseau du panneau Son corrigées.
+-  97 vues inventoriées ; 69 panneaux PC/Pixel ouverts et sept dialogues natifs vérifiés. Stockage, données et fuseau relus après écriture sur r51 en RAM.
+-  Les vues contextuelles et services indisponibles ne sont pas tous qualifiés. Java Android compilé, sans nouvel APK. Aucun flash, ticket fermé ni publication.
+
+
+
+## En développement — 12 octobre 2026 — Navigation des paramètres
+
+-  Listes déroulantes : sélecteur Gaia avec confirmation/annulation ; choix Clair/Sombre accessible. Gestionnaire SIM adapté à un emplacement.
+-  Panneaux indisponibles : explication et commandes désactivées au lieu de liens sans effet.
+-  Sargo : réseaux Wi-Fi mémorisés séparés des scans lents ; cache conservé lors des lectures d’état.
+-  Sargo : informations SIM, centre SMS et accusés de réception reliés au modem.
+-  r45 en RAM : huit contrôles d’apparence, cinq panneaux et changement réel des accusés SMS validés, puis réglage restauré. Sélecteur PC vérifié ; ressources APK préparées.
+-  Certificats Enterprise et autres options opérateur encore indisponibles. Aucun SMS émis, nouvel APK, flash ou publication.
+
+
+
+## En développement — 12 octobre 2026 — Correctifs communs sur Pixel 3a
+
+-  Sargo : boot r39 en RAM avec correctifs partagés et premier mode sombre. Noyau et configuration matérielle du r35 conservés.
+-  Démarrage, taille d’écran, réglages persistants, batterie, interface d’appel et Fira vérifiés. Services modem, audio, Wi-Fi et Bluetooth actifs.
+-  Sept contrôles d’apparence réussis dans Gaia : thème propagé, en-têtes lisibles, libellés placés et retour au clair. Capture Pixel inspectée.
+-  Outil de mise à jour Gaia dans un boot privé, avec empreintes, sources conservées et vérification des composants matériels inchangés.
+-  Aucun nouvel appel, SMS ou appairage testé. Thème complet et comportement Android encore à qualifier. Aucun nouvel APK, flash ou publication.
+
+
+
+## En développement — 11 octobre 2026 — Apparence et clavier partagé
+
+-  Apparence : Clair, Sombre ou Selon le système, partagé entre les applications et conservé au redémarrage. Fonds d’écran, photos et sites externes préservés.
+-  Clavier : opérations d’édition mutualisées, sélection et effacement, protection contre les réponses tardives.
+-  PC : neuf contrôles d’apparence et gestes de saisie dans Messages réussis, sans envoi de SMS. Revue visuelle de Paramètres, Messages, Téléphone et Contacts.
+-  Android : option expérimentale de clavier Gaia ; clavier Android conservé par défaut et mode de saisie restauré à la fermeture.
+-  Android : réessayer brièvement le même capteur encore occupé, sans réessayer après refus de permission ou fermeture de Photo.
+-  Preview 19 construit localement : signature, alignement, parité et tests du pont/caméra réussis.
+-  Clavier Gaia, caméra avant Android et thème complet encore à qualifier. Sources prêtes pour Sargo, resté sur r35 à cette étape. Aucun upload, flash ni ticket fermé.
+
+
+
+## En développement — 11 octobre 2026 — Paramètres et accueil
+
+-  Paramètres : gestion des sonneries, saisie/effacement du code PIN, désactivation du verrouillage et liste des permissions des applications tierces.
+-  Accueil : première initialisation du second lanceur et aperçus des fonds d’écran.
+-  Langues : actualiser les libellés Vulpes au changement de langue ; anglais de secours hors français. Fermer le panneau d’échec caméra et les bandeaux d’installation.
+-  Android : ouvrir le volet Gaia en touchant la barre de statut. Preview 18 construit, signé et comparé aux sources.
+-  PC : neuf parcours Gaia, cinq contrôles de bandeaux et douze tests activités/caméra réussis. Bluetooth Sargo r35 confirmé par l’utilisateur.
+-  Redémarrage à chaud du compositeur encore instable : fichiers restaurés et retour à r35. Corrections non qualifiées sur Sargo à cette étape ; clavier/thème Android ouverts. Aucun APK publié ni flash.
+
+
+
+## En développement — 9 octobre 2026 — MMS et Bluetooth
+
+-  Bluetooth : adaptateur Gaia partagé, commandes matérielles séparées et accès limité aux applications autorisées.
+-  Sargo : activation, découverte, appairage explicite, connexion et suppression depuis le panneau Bluetooth Gaia. Modules audio Bluetooth intégrés.
+-  Sargo : services réseau Qualcomm et transport MMS raccordés. Une déconnexion D-Bus ne termine plus le daemon MMS. État persistant, reprise sans doublon ni renvoi automatique incertain.
+-  r35 en RAM : MMS avec image visible et accepté par le MMSC Orange ; réception destinataire à confirmer. Cinq appareils Bluetooth détectés, A2DP vers PC validé.
+-  191 tests Python Tundra et 18 tests JavaScript réussis. Ramdisk allégé et ressources Android synchronisées.
+-  Audio d’appel via casque non qualifié. Aucun nouvel APK, flash ni push à cette étape.
+
+
+
+## En développement — 8 octobre 2026 — Caméra et transport MMS
+
+-  Caméra : arrêter le flux avant de détacher la vidéo ; attendre la fermeture du capteur, réinitialiser le viseur et recalculer sa taille après rotation.
+-  MMS : préserver les pièces jointes Blob et garder les messages en attente jusqu’à confirmation du transport.
+-  Sargo : viseur WebGL réutilisé sans recapture vidéo intermédiaire ; écran maintenu éveillé pendant l’aperçu.
+-  PC et Sargo : parcours Photo/Galerie et huit bascules natives vérifiés. JPEG, focus et zoom fonctionnels ; arrière ~16 FPS, avant ~25–27 FPS dans cet essai.
+-  Sargo : file MMS persistante, codec Nuntium et transport oFono. Interface texte/photo testée avec transport simulé.
+-  Données mobiles bloquantes pour les MMS opérateur à cette étape. Ressources Android synchronisées ; aucun nouvel APK, flash ou push.
+
+
+
+## 8 octobre 2026 — Rappel et messages d’erreur
+
+-  Dialogues Gaia : texte visible au lieu d’un écran gris avec un bouton seul.
+-  Journal d’appel : rappel via le même service que le pavé numérique, sans anciennes API Gecko. Fichier partagé PC/Android/Sargo.
+-  MMS : signaler clairement l’envoi indisponible, garder texte et pièces jointes, ne plus annoncer un faux mode avion.
+-  PC : dialogues FR/EN et composeur préservé. Sargo en RAM : 11 contrôles Gaia réussis, rappel testé avec modem simulé.
+-  Aucun appel réel ni validation audio dans cette étape. Ressources Android synchronisées, aucun nouvel APK publié.
+
+
+
+## En développement — 8 octobre 2026 — SMS et audio
+
+-  SMS : envoyer en un appui en conservant le clavier. Trombone raccordé au menu Gaia : image, audio, vidéo ou contact.
+-  Interface : indicateur de volume visible, gestes du verrouillage et balayage des notifications corrigés.
+-  PC : clavier, pièces jointes, Galerie, interface d’appel et volume vérifiés ; notification balayée sans déplacer la page. Aucun nouvel APK publié.
+-  Sargo : accès à PulseAudio, relais Qualcomm, protocole D-Bus et périphériques de calibration rétablis. Service audio Android concurrent retiré.
+-  Sargo : 11 contrôles Gaia, calibration et prévisualisations arrière/avant/arrière réussis.
+-  Appels échouant avant sonnerie avec une erreur réseau dans cet essai. Audio bidirectionnel, geste Pixel et MMS encore non validés. Aucun flash ni publication.
+
+
+
+## En développement — 8 octobre 2026 — Fiabilité téléphonie
+
+-  Sargo : service radio maintenu après fermeture d’un client. Jamais de nouvel appel ou SMS automatique après une réponse perdue. Erreurs sans numéro ni texte du message.
+-  Diagnostic réseau/audio sans identifiants personnels et paramètres opérateur épinglés dans l’image native.
+-  SMS : attendre le vrai gestionnaire Gaia et accepter les identifiants oFono avec majuscules. Contrôle des conversations rétabli sans réémission.
+-  Sargo : LTE retrouvé après redémarrage, SMS confirmé envoyé par le modem, microphone non silencieux et lecture audio acceptée.
+-  Réception destinataire, audio d’appel bidirectionnel et stabilité modem restent à qualifier. Compositeur instable au redémarrage à chaud. Aucun nouvel APK, flash ou push.
## 8 octobre 2026 — Mise à jour des sources publiques
-### Commun · PC / Android / Tundra
-
-- Regrouper les correctifs récents de caméra, Contacts, SMS, Marketplace et les outils de construction et de restauration Sargo dans les sources publiques. Mettre à jour les README français et anglais avec les capacités et limites vérifiées.
-- Corriger le contrôle de parité Android pour créer son dossier de rapports sur une copie neuve du dépôt. Vérifier que la configuration Wi-Fi native embarque un répertoire de connexions vide.
-- Conserver les tickets, feuilles de route, documents internes, données personnelles et clés hors du dépôt.
-
-### Paquets et qualification
-
-- L’APK public reste Preview17 : les correctifs du 8 octobre nécessitent une prochaine compilation. Aucune nouvelle image native publiée ni flash dans cette étape.
-- Sur Sargo, l’enregistrement cellulaire reste en recherche lors du dernier contrôle ; audio bidirectionnel, appairage Bluetooth et profils restent à qualifier.
+-  Publication des correctifs Caméra, Contacts, SMS et Marketplace, et des outils Sargo de construction et de restauration.
+-  README français et anglais actualisés avec les fonctions et limites vérifiées.
+-  Création du dossier de rapports lors du contrôle de parité Android sur un dépôt neuf.
+-  Configuration Wi-Fi native vérifiée : aucun profil de connexion embarqué.
+-  Documents internes, données personnelles et clés exclus du dépôt.
+-  APK public inchangé (Preview17) ; correctifs du 8 octobre non inclus. Aucune nouvelle image native publiée.
+-  Sargo : modem encore en recherche réseau ; audio des appels, appairage et profils Bluetooth à valider.
## 8 octobre 2026 — Caméra et parcours Contacts · en développement
-### Commun · Gaia / PC / Android / Tundra
-
-- Sérialiser les ouvertures de caméra, attendre la fermeture du capteur précédent et libérer les pistes et ressources vidéo même après un échec. Stabiliser l’identification avant/arrière sur PC et Android.
-- Conserver les images binaires lors des échanges PC/Tundra ; encoder explicitement les photos et dates des contacts pour le pont JSON Android.
-- Corriger l’actualisation de la fiche après modification d’un contact. Rétablir les activités Contacts → SMS, sélection d’un numéro et ouverture du pavé numérique, en préservant les demandes imbriquées et les points d’entrée de Gaia.
-- Réouvrir Galerie en mode sélection pour ajouter une photo à un contact, même lorsque Galerie est déjà ouverte en consultation. Sélection, recadrage et enregistrement de la photo validés sur PC.
-- Ajouter des tests de caméra, de stockage des contacts, de permissions, de transport binaire et de routage des activités.
-
-### PC
-
-- Vérifier le parcours original Appareil photo → capture JPEG → Galerie → ouverture de la photo → retour caméra.
-- Vérifier dans Gaia : création et modification d’un contact, actualisation de la fiche, recherche sans accents et sans résultats, favoris, destinataire SMS, sélection d’un numéro, pavé numérique prérempli, annulation puis confirmation de suppression. Le test n’émet ni appel ni SMS et supprime son contact temporaire.
-
-### Tundra · Sargo
-
-- Vérifier en RAM sur Pixel 3a : autofocus, zoom, quatre alternances avant/arrière avec nouvelles trames, capture JPEG depuis Gaia, stockage, ouverture dans Galerie et arrêt du capteur à l’accueil.
-- Vérifier les conversations déjà stockées, le clavier et les notifications. Recherche Wi-Fi : quatre réseaux détectés. Nouvelle émission/réception SMS et audio des appels encore en qualification.
-- Ajouter les paquets BlueZ et les bibliothèques nécessaires, verrouillés par SHA256. Le contrôleur Bluetooth démarre, s’active et lance une recherche ; l’appairage depuis Gaia et les profils ne sont pas encore validés.
-- Ajouter le magasin de certificats natif : HTTPS vérifié sans désactivation de TLS. Tester les archives altérées, collisions de fichiers et reconstructions répétées.
-
-- Corriger les fichiers Contacts embarqués dans le build de démarrage : la création, l’édition, le passage vers SMS, le sélecteur et le pavé numérique passent aussi avec les gestes tactiles sur Sargo.
-
-### Android · APK
-
-- Publier l’APK Preview17 existant, avec téléchargement et SHA256 vérifiés. Il précède les correctifs du 8 octobre.
-- Corriger la transmission des destinataires et du texte de Gaia vers l’application SMS Android, y compris la réponse à une conversation et le numéro saisi sans validation préalable. Tests du pont réussis ; essai Android réel encore nécessaire.
-- Réempaqueter les ressources communes corrigées pour la prochaine compilation ; aucun nouvel APK incluant ces correctifs n’est publié.
-
-**État :** builds natifs de qualification démarrés en RAM. Aucun flash, push de code ni publication de documentation privée dans cette étape. La qualification complète des fonctions de téléphone reste ouverte.
+-  Caméra : ouvertures successives, libération du capteur et identification avant/arrière corrigées.
+-  Contacts : transport des photos et des dates corrigé sur PC, Android et Tundra.
+-  Contacts : actualisation après modification, ouverture de SMS, sélection d’un numéro et préremplissage du pavé numérique rétablis.
+-  Galerie : ouverture en mode sélection pour la photo d’un contact, même si l’application est déjà ouverte.
+-  Sargo : fichiers Contacts corrigés inclus dans l’image de démarrage.
+-  Android : transmission du destinataire et du texte à l’application SMS, y compris les réponses et les numéros non encore validés.
+-  Tests de caméra, stockage des contacts, permissions, transport binaire et activités.
+-  PC : capture JPEG, Galerie, photo de contact, création, édition, recherche, favoris et suppression vérifiés. Aucun appel ni SMS émis.
+-  Sargo en RAM : autofocus, zoom, quatre bascules de caméra, capture et Galerie vérifiés ; parcours Contacts testés au toucher.
+-  Sargo : conversations stockées, clavier et notifications vérifiés ; quatre réseaux Wi-Fi détectés. Nouveaux SMS et audio d’appel à valider.
+-  Sargo : BlueZ et ses dépendances avec empreintes SHA256. Activation et recherche Bluetooth testées ; appairage et profils à valider.
+-  Sargo : magasin de certificats natif. HTTPS, archives altérées, collisions de fichiers et reconstructions testés.
+-  Preview17 publié avec téléchargement et SHA256 vérifiés. Les correctifs ci-dessus restent dans les sources ; essai SMS Android réel nécessaire. Aucun flash Sargo.
## 6 octobre 2026 — Thème Marketplace intégré · en développement
-### Commun · Gaia / PC / Android / Tundra
-
-- Ouvrir le catalogue avec `mode=app`, conservé dans les liens, la recherche, les filtres, les fiches et le changement de langue.
-- Préparer un thème web Vulpes dédié : Fira locale, navigation compacte, boutons tactiles, cartes lisibles, filtres repliables et captures défilantes. Retirer les en-têtes du site dans le mode intégré, tout en conservant l’interface du site classique.
-- Conserver les confirmations et messages système côté Gaia. Le thème ne donne aucun privilège supplémentaire aux pages du catalogue.
-- Vérifier 16 pages FR/EN, 2 pages en mode classique et les ressources du thème. Rendus mobiles de 320 et 360 pixels inspectés. Lanceurs PC et Sargo mis à jour ; les sources seront reprises au prochain empaquetage Android.
-
-Fichiers du site prêts localement, non déployés sur le serveur public. Aucun push ni nouvelle publication APK/ROM.
+-  Marketplace : thème intégré avec Fira locale, navigation compacte, boutons tactiles, filtres repliables et captures défilantes.
+-  Conservation de `mode=app` dans la navigation, la recherche, les filtres et le changement de langue.
+-  En-têtes du site masqués en mode intégré. Confirmations gérées par Gaia, sans privilèges supplémentaires pour le catalogue.
+-  16 pages FR/EN, 2 pages classiques et affichages mobiles à 320 et 360 pixels vérifiés.
+-  Thème prêt localement, non déployé. Lanceurs PC et Sargo mis à jour ; intégration Android au prochain APK.
## 6 octobre 2026 — Bandeaux Gaia · en développement
-### Commun · Gaia / PC / Android / Tundra
-
-- Faire disparaître le bandeau « Application installée » après son délai normal, même si la fin d’animation ne remonte pas du composant Gaia. Conserver son apparence et permettre sa fermeture au toucher.
-- Annuler les anciens délais lorsqu’un nouveau message remplace le précédent pour éviter une disparition prématurée.
-- Vérifier sur PC l’affichage, l’expiration, le remplacement et la fermeture au clic ; vérifier sur le Pixel le retrait effectif du bandeau après cinq secondes. Correctif déployé sur l’essai Sargo en RAM et disponible dans les sources communes pour le prochain APK.
-
-Aucun flash ni publication dans cette étape.
+-  Fermeture automatique du bandeau « Application installée », même sans événement de fin d’animation.
+-  Annulation des anciens délais lorsqu’un nouveau bandeau remplace le précédent.
+-  PC : affichage, expiration, remplacement et fermeture au clic vérifiés. Sargo : disparition après cinq secondes vérifiée en RAM.
+-  Correctif disponible dans les sources communes pour le prochain APK. Aucun flash ni publication.
## 6 octobre 2026 — Wi-Fi et panneaux Paramètres · en développement
-### Commun · Gaia / PC / Android / Tundra
-
-- Terminer l’ouverture d’un panneau Paramètres même si Gecko ne produit pas l’événement de fin d’animation. Conserver les transitions Gaia et éviter les doubles initialisations.
-- Correctif dans les sources communes et le serveur PC ; inclus au prochain empaquetage Android. Aucun nouvel APK publié dans cette étape.
-
-### Tundra · Sargo
-
-- Corriger les événements de signal Wi-Fi : transmettre le réseau associé et ne pas annoncer de signal de connexion lorsque le téléphone est déconnecté. L’événement incomplet faisait échouer l’affichage des réseaux dans Gaia.
-- Sur le Pixel, vérifier le pilote, le service natif, le passage par Gecko et l’affichage du panneau corrigé : quatre points d’accès détectés, regroupés en deux réseaux, sans erreur de recherche.
-- Connexion Wi-Fi confirmée par l’utilisateur ; requête HTTPS réelle réussie depuis Gecko vers example.org (HTTP 200, vérification des certificats active). Correctifs appliqués à l’essai en RAM ; aucun flash ni push.
+-  Paramètres : ouverture des panneaux même sans événement de fin d’animation, sans double initialisation.
+-  Sargo : événements Wi-Fi corrigés pour rétablir l’affichage des réseaux et signaler correctement la déconnexion.
+-  Sargo : quatre points d’accès détectés, regroupés en deux réseaux. Connexion confirmée et requête HTTPS réussie avec certificats vérifiés.
+-  Sources communes, serveur PC et essai Sargo en RAM mis à jour. Correctifs non inclus dans l’APK public.
## 6 octobre 2026 — Marketplace · en développement
-### PC / Tundra · Sargo
-
-- Maintenir le catalogue sous la barre de statut et dans les limites de son application. Masquer sa vue web à l’accueil et dans le multitâche ; la retirer à la fermeture de l’application.
-- Éviter qu’une navigation annulée ou l’ancienne page d’erreur masque une nouvelle tentative.
-- Afficher un message local et proposer une nouvelle tentative lorsque le catalogue est inaccessible, au lieu de laisser sa page d’erreur recouvrir Gaia.
-- Sur PC : navigation, erreur réseau, nouvelle tentative, retour à l’accueil et fermeture depuis le multitâche vérifiés. Sept contrôles directs passent sur Sargo en RAM, sans fenêtre de débogage, avec une page locale et une précondition de déverrouillage forcée ; le catalogue distant nécessite une connexion Internet sur le téléphone.
-
-### Android · APK
-
-- Reconstruire l’APK local Preview17 avec le message d’erreur Marketplace commun. Signature, alignement et parité des ressources vérifiés ; nouvelle régression sur appareil Android non effectuée dans cette étape.
-
-Aucune publication dans cette étape.
+-  PC/Sargo : catalogue Marketplace contenu sous la barre de statut, masqué à l’accueil et retiré à la fermeture.
+-  Une navigation annulée ou une ancienne page d’erreur ne masque plus la nouvelle tentative.
+-  Message d’erreur local avec bouton Réessayer lorsque le catalogue est inaccessible.
+-  PC : navigation, erreur réseau, nouvelle tentative, accueil et fermeture vérifiés.
+-  Sargo en RAM : sept contrôles réussis avec une page locale et déverrouillage forcé. Catalogue distant non validé sans connexion Internet.
+-  APK Preview17 local reconstruit ; signature, alignement et ressources vérifiés. Aucun nouveau test sur appareil Android ni publication.
## 2.7-preview.17 — APK construit · 5 octobre 2026
-### Commun · Gaia / PC
-
-- Remettre Marketplace sur l’accueil avec son icône originale et le catalogue Vulpes. Ouvrir sa version française pour les langues fr, anglaise sinon.
-- Adapter les téléchargements du catalogue en installations confirmées : vérifier les archives ZIP, ajouter l’application à l’accueil et permettre son lancement et sa désinstallation.
-- Conserver les applications installées séparément des sources Gaia, sur des origines distinctes et sans privilèges système hérités des anciens manifestes.
-- Valider sur PC le parcours réel avec 2048 : catalogue, confirmation, installation, lancement, persistance et désinstallation. Contrôler les archives corrompues, chemins invalides, limites, annulation et confirmations à usage unique.
-
-### Android · APK
-
-- Construire et signer Preview17 avec le même Marketplace et le même service d’installation. Utiliser la vue GeckoView existante pour le catalogue et le stockage privé Android pour les paquets.
-- Vérifier sur émulateur Android 16/API36 le clic Installer, la confirmation, l’apparition sur l’accueil et le lancement de 2048 sans accès au pont système Vulpes. Vérifier aussi la conservation après arrêt forcé et relance, puis la désinstallation.
-
-### Tundra · Sargo
-
-- Préparer une image de boot locale incluant Marketplace, l’installateur et le serveur des applications ajoutées. Démarrage en RAM effectué sur le Pixel le 6 octobre : accueil et ressources Marketplace vérifiés, sans flash. Neuf contrôles système sur dix passent ; le contrôle global Gaia relève une variation du viewport au déverrouillage. Installation de paquets sur Sargo encore à qualifier.
-
-**Limites :** compatibilité initiale avec les applications web empaquetées. Les anciennes API privilégiées, applications hébergées et services disparus ne sont pas restaurés automatiquement. Rien publié sur Forgejo ni sur le site.
+-  Marketplace sur l’accueil, avec son icône originale et le catalogue Vulpes en français ou en anglais.
+-  Installation confirmée des applications ZIP, lancement depuis l’accueil et désinstallation.
+-  Applications installées isolées des sources Gaia et des privilèges système.
+-  PC : parcours complet avec 2048 et contrôles des archives, chemins, limites, annulations et confirmations à usage unique.
+-  APK Preview17 construit et signé avec Marketplace, GeckoView et stockage privé des paquets.
+-  Émulateur Android 16/API 36 : installation, lancement, isolation, conservation après redémarrage de l’application et désinstallation de 2048.
+-  Sargo en RAM le 6 octobre : accueil et ressources Marketplace vérifiés, 9 contrôles système sur 10 réussis. Variation du viewport au déverrouillage ; installation de paquets à valider.
+-  Prise en charge initiale des applications web empaquetées. Anciennes API privilégiées et applications hébergées non restaurées. Aucune publication à cette étape.
## 2.7-preview.16 — APK construit · 5 octobre 2026
-### Commun · Gaia / PC
-
-- Rétablir les sélecteurs Gaia de fond d’écran et de sons, leur retour vers Paramètres et leur annulation par Accueil. Partager le transport des images et sons enregistrés entre PC et Android.
-- Propager les changements de langue à l’accueil, y compris sans permission de lecture générale des réglages. Vérifier les libellés FR/EN et la persistance.
-- Rétablir l’arrêt du balayage horizontal sur une page et masquer les applications de développement sur l’accueil, sans les supprimer des sources.
-- Utiliser le logo Vulpes fourni dans le splash et adapter les composants originaux de la liste des sons au moteur actuel.
-- Vérifier sur PC deux changements consécutifs de fond, le choix d’un son et les panneaux Gaia restaurés.
-
-### Android · APK
-
-- APK local Preview16 construit et signé ; contenu commun et version vérifiés. Aucune publication dans cette étape.
-- Valider Vulpes comme lanceur HOME, afficher le verrouillage Gaia après veille et rétablir le retour Android depuis la caméra ouverte au verrouillage.
-- Masquer les barres système Android en mode immersif, en conservant leur accès temporaire par balayage.
-- Tests sur émulateur Android 16/API36 : verrouillage, caméra/retour, FR/EN, accueil sans applications de test, fond d’écran et son. Fond conservé après arrêt forcé et relance, sans changement répété pendant 15 secondes. Autres appareils Android non qualifiés dans cette étape.
-
-### Tundra · Sargo
-
-- Inclure les mêmes correctifs et la politique des applications dans une nouvelle image de boot de test. Le boot précédent réappliquait une copie ancienne des fichiers et annulait les modifications à chaud.
-- Démarrage en RAM sur le Pixel réussi : six contrôles de langues/accueil/sélecteurs et dix contrôles système passent. Sélecteurs image/son vérifiés sans écraser les choix personnels. Le boot installé reste Preview15 ; aucune partition flashée dans cette étape.
-
-**Limites :** l’installation tierce FFAA reste ouverte. Le site dépend d’anciennes API mozApps et d’un script nomodule ; un installateur isolant les applications et leurs permissions reste à construire. Le choix d’un son Gaia ne modifie pas la sonnerie globale Android ; l’audio d’appel réel n’a pas été qualifié ici.
+-  Sélecteurs Gaia de fond d’écran et de sons rétablis, avec retour vers Paramètres et annulation par Accueil.
+-  Changement de langue appliqué à l’accueil et conservé après redémarrage.
+-  Arrêt du balayage horizontal sur chaque page ; applications de développement masquées sur l’accueil.
+-  Logo Vulpes au démarrage et liste des sons adaptée au moteur actuel. Transport des médias partagé entre PC et Android.
+-  Android : verrouillage Gaia après veille et retour depuis la caméra du verrouillage. Mode lanceur HOME validé.
+-  Android : barres système masquées en mode immersif, accessibles par balayage. APK Preview16 construit et signé.
+-  PC et émulateur Android 16/API 36 : fonds d’écran, sons et panneaux vérifiés ; langue, verrouillage et persistance du fond testés sur Android.
+-  Sargo : image de démarrage mise à jour pour ne plus restaurer d’anciens fichiers.
+-  Sargo en RAM : 6 contrôles d’interface et 10 contrôles système réussis. Installation conservée en Preview15, sans flash.
+-  APK non publié. Installation FFAA et audio d’appel à valider ; le choix d’un son Gaia ne change pas la sonnerie Android.
## En développement — 4 octobre 2026 — Installation autonome Sargo
-### Tundra · Sargo
-
-- Construction d’un volume ext4 neuf directement destiné à userdata : système, données et composants matériels vendor/modem embarqués. Le démarrage ne cherche plus de fichiers dans une installation Droidian ; aucune session Android ni connexion ADB n’est nécessaire à l’installation.
-- Installateur Fastboot en Python : contrôle du modèle Sargo, du déverrouillage, des tailles et des empreintes, puis remplacement de userdata, dtbo_a, vbmeta_a et boot_a. Le bootloader reste déverrouillé ; les calibrations propres au téléphone sont conservées.
-- Sauvegarde complète de userdata hors ligne, comparaison intégrale avec le téléphone et vérification de l’archive de restauration avant le flash. Les sauvegardes restent privées ; une restauration réelle n’a pas été effectuée dans cette étape.
-- Deux démarrages depuis le stockage interne réussis : onze contrôles Gaia et dix contrôles natifs par démarrage, image de boot relue et vérifiée, réglages conservés. Le slot A est marqué réussi uniquement après validation du démarrage Gaia courant.
-- Variante générique sans serveur SSH ni clés de maintenance : flash de boot_a et démarrage réel réussis. La variante de développement est ensuite remise sur le Pixel pour poursuivre les travaux. Les images du paquet générique conservent leur état initial vierge.
-- Contrôles finaux sur le démarrage autonome de développement : Photo (dont bascules de capteur), clavier Gaia et notifications réussis.
-- Paquet Fastboot local assemblé avec empreintes et rapport de qualification. 44 tests ciblés passent. Il n’est pas encore publié : la provenance et la redistribution de certains composants matériels restent à finaliser.
-- Limites : qualification sur un Pixel 3a, pas sur toutes les combinaisons de firmware ; volume de 8 Gio comprenant 1 Gio de données modifiables, sans extension automatique. Les fonctionnalités matérielles restent expérimentales.
-
-### PC / Android · APK
-
-- Aucun changement d’interface ni nouveau binaire dans cette étape. Gaia reste partagé entre les trois plateformes.
+-  Sargo : volume ext4 autonome pour userdata, avec système, données et composants vendor/modem. Installation indépendante de Droidian et d’ADB.
+-  Installateur Fastboot avec contrôles du modèle, du déverrouillage, des tailles et des empreintes. Remplacement de userdata, dtbo_a, vbmeta_a et boot_a ; calibrations conservées.
+-  Sauvegarde complète de userdata et archive de restauration vérifiées avant le flash. Restauration réelle non testée.
+-  Deux démarrages installés réussis : 11 contrôles Gaia et 10 contrôles natifs par démarrage, réglages conservés et boot relu conforme.
+-  Variante sans SSH ni clés de maintenance démarrée après flash. Variante de développement réinstallée ; caméra, clavier et notifications vérifiés.
+-  Paquet Fastboot local assemblé ; 44 tests ciblés réussis.
+-  Paquet non publié : provenance et redistribution de composants matériels à finaliser. Essais sur un seul Pixel 3a ; volume de 8 Gio dont 1 Gio modifiable, sans extension automatique.
## En développement — 3 octobre 2026 — Installation Sargo par appareil
-### Tundra · Sargo
-
-- Collecte des six partitions de démarrage en lecture seule et du stockage existant, liés à l’identité du téléphone et au slot A. Les sauvegardes restent privées.
-- Dossier appareil portable : construction RAM, contrôle du boot, installation et export du secours peuvent l’utiliser sans l’inventaire partagé du Pixel de développement. Un build lié à un dossier appareil refuse le repli implicite vers cet inventaire.
-- Transfert SSH contrôlé : vérification de l’identité et du stockage, contrôle des empreintes et création des fichiers sans remplacer les données existantes. Aucun formatage, flash ou redémarrage dans l’outil de transfert.
-- Construction et transfert testés sur le Pixel. Premier boot RAM : onze contrôles Gaia et dix contrôles natifs réussis ; clavier et notifications réussis. Le contrôle caméra a rencontré un délai d’autofocus dépassé, conservé dans le rapport.
-- Second boot de la même image : réglage Gaia conservé, onze contrôles Gaia et dix contrôles natifs réussis ; caméra, clavier et notifications réussis. Le timeout autofocus du premier essai reste documenté, pas déclaré corrigé.
-- Retour au build installé vérifié : démarrage Gaia réussi et partition de boot inchangée. Un nouveau transfert refuse bien de remplacer les données déjà utilisées.
-- Provenance du HAL précisée : paquet et recette de téléchargement identifiés, recettes UBports historiques récupérées. La correspondance exacte avec l’ancien artefact Android et son manifeste de sources reste à établir.
-- Prise en charge toujours limitée au noyau qualifié et au stockage ext4/LVM compatible. Ce travail ne fournit pas encore une installation directe depuis Android d’origine.
-
-### PC / Android · APK
-
-- Aucun changement d’interface ni nouvelle compilation dans cette étape.
+-  Sargo : dossier appareil portable pour les builds, l’installation et le secours, indépendant de l’inventaire du Pixel de développement.
+-  Collecte en lecture seule des six partitions de démarrage et du stockage, liée à l’identité du téléphone. Sauvegardes privées.
+-  Transfert SSH avec contrôle d’identité, du stockage et des empreintes, sans écrasement, formatage ni flash.
+-  Deux boots RAM : 11 contrôles Gaia et 10 contrôles natifs réussis chacun ; réglage conservé. Timeout autofocus au premier essai, caméra fonctionnelle au second.
+-  Retour au build installé vérifié ; boot inchangé et écrasement des données refusé.
+-  Timeout autofocus non déclaré corrigé. Provenance exacte du HAL encore à établir ; noyau et stockage ext4/LVM compatibles requis, sans installation directe depuis Android d’origine.
## En développement — 3 octobre 2026 — Préparation et secours Sargo
-### Tundra · Sargo
-
-- Ajout d’un outil de préparation locale : contrôle du manifeste et de l’archive, décompression bornée, vérification ext4 et création d’un espace de données vierge propre à l’installation. Les dossiers existants ne sont pas écrasés.
-- Ajout d’un kit de restauration privé exportable hors du dépôt. Il conserve la sauvegarde originale de boot_a et un outil autonome qui vérifie le modèle, le numéro de série, le slot et les empreintes avant toute restauration explicite. Il ne constitue pas une sauvegarde des données utilisateur.
-- La restauration existante ne dépend plus de la présence de l’image de diagnostic. Les contrôles de la sauvegarde sont conservés.
-- Construction système : référence matérielle ARM64 désormais explicite, avec refus d’une référence limitée à l’affichage ou dépourvue des services requis.
-- Préparation de l’archive système réelle et vérification locale du kit de secours effectuées. Aucun nouveau flash ni nouvelle ROM publiée ; l’installation automatique sur un autre Sargo reste à finaliser.
-
-### Commun · PC / Android / Tundra
-
-- Le journal JSON partagé est conservé dans les futures publications de sources ; les documents de travail Markdown restent exclus.
-
-### PC / Android · APK
-
-- Aucune modification fonctionnelle ni nouvelle compilation dans cette étape.
+-  Sargo : préparation locale des paquets avec contrôles d’empreintes, décompression bornée, vérification ext4 et création de données vierges sans écrasement.
+-  Kit de secours privé avec sauvegarde de boot_a et contrôles du modèle, du numéro de série, du slot et des empreintes. Données utilisateur non incluses.
+-  Restauration possible sans l’image de diagnostic.
+-  Construction système : référence matérielle ARM64 complète exigée.
+-  Archive système préparée et kit de secours vérifié localement. Installation sur un autre Sargo à finaliser ; aucun flash ni ROM publiée.
+-  Journal JSON inclus dans les prochaines publications de sources ; documents de travail exclus.
## En développement — 3 octobre 2026 — Sargo : récupération modem et installation vierge
-### Tundra · Sargo
-
-- Reconstruction de l’image système avec le correctif du cache des polices et la création du répertoire privé d’oFono au premier démarrage.
-- Récupération limitée au sous-système modem : test contrôlé avec passage hors ligne, remise en service et réenregistrement réseau en environ 22 secondes, sans redémarrage du téléphone. Cela ne corrige pas la cause du watchdog IMS du firmware.
-- Démarrage Gaia, rendu matériel, polices Fira, deux capteurs photo, focus/zoom, clavier et notifications visuelles contrôlés sur le profil neuf. Scan Wi-Fi encore fonctionnel après le redémarrage du modem.
-- Deux démarrages réussis avec conservation du même réglage Gaia sur un espace de données séparé. Caméra, clavier et notifications revérifiés au second démarrage. La partition de boot installée et les données existantes sont conservées.
-- Aucune nouvelle image native publiée : le paquet public d’installation/restauration reste à terminer.
-
-### PC / Android · APK
-
-- Aucun changement de l’interface commune ni nouvelle compilation APK dans cette étape.
+-  Sargo : cache des polices et création du dossier privé oFono au premier démarrage corrigés dans l’image système.
+-  Récupération du modem sans redémarrer le téléphone. Réenregistrement réseau mesuré à environ 22 secondes lors d’un essai contrôlé.
+-  Profil neuf : Gaia, rendu matériel, Fira, deux caméras, focus, zoom, clavier et notifications vérifiés. Scan Wi-Fi fonctionnel après redémarrage du modem.
+-  Deux démarrages avec conservation des réglages dans un espace de données séparé. Installation existante préservée.
+-  Cause du watchdog IMS non corrigée. Paquet public d’installation et de restauration encore incomplet ; aucune image publiée.
## 1er octobre 2026 — Sargo : préparation de la distribution — en développement
-### Tundra · Sargo
-
-- Image système Preview 15 reconstruite avec profils vierges, exclusion des documents de travail et conservation des licences ; inventaire des 493 paquets embarqués.
-- Vérification ext4 et contrôle du contenu effectués. Ces contrôles ne constituent pas une validation de démarrage.
-- Inventaire du stockage en lecture seule et génération de la table de montage depuis cet inventaire, au lieu de reprendre systématiquement les dimensions du Pixel de développement. Prise en charge limitée au volume ext4 à une seule étendue sur userdata Sargo ; aucun formatage ajouté.
-- Image nettoyée transférée et vérifiée sur le Pixel chargé. Essais temporaires en RAM, sans écriture de boot_a ; retour automatique vers le build v59 et redémarrage de Gaia contrôlés.
-- SIM reconnue, enregistrement LTE et scan Wi-Fi contrôlés sur cette image ; ces contrôles ne valident pas les appels, SMS ou données mobiles de bout en bout.
-- Correction des droits du cache des polices au premier démarrage : fc-cache utilise le compte de session. Démarrage Gaia, rendu matériel, Fira, deux capteurs photo, clavier et notifications visuelles contrôlés en RAM. La qualification du fichier reconstruit reste incomplète.
-- Redémarrage intermittent identifié dans le firmware IMS du modem ; politique de récupération ciblée appliquée et second démarrage avec LTE, caméra, clavier et notifications réussi. La récupération après un nouveau crash IMS reste non testée. L’image publique reste non flashable et non publiée.
-
-### PC / APK
-
-- Aucun changement fonctionnel ni nouvelle compilation APK dans cette étape.
+-  Sargo : image Preview15 reconstruite avec profils vierges, licences et inventaire des 493 paquets. Documents de travail exclus.
+-  Table de montage générée depuis l’inventaire du stockage. Prise en charge limitée à une seule étendue ext4 sur userdata.
+-  Droits du cache des polices au premier démarrage : fc-cache exécuté avec le compte de session.
+-  Contrôles ext4, contenu et transfert réussis. Essais RAM sans écriture de boot_a ; retour au build v59 vérifié.
+-  SIM, LTE, scan Wi-Fi, Gaia, rendu matériel, Fira, caméras, clavier et notifications vérifiés en RAM.
+-  Récupération ciblée après identification d’un redémarrage intermittent du firmware IMS. Second démarrage réussi ; nouveau crash IMS non testé.
+-  Qualification de l’image incomplète, sans validation des appels, SMS ou données mobiles de bout en bout. Image non publiée et non flashable.
## 2.7-preview.15 — 30 septembre 2026 — publiée
+-  Première publication des sources PC, Android et Tundra, avec README FR/EN, licences et scripts de préparation. Fichiers privés exclus.
+-  SMS : dates des bulles et états d’envoi/échec corrigés. Libération et remplacement des flux caméra fiabilisés.
+-  PC : tests de navigation stabilisés, délai de démarrage à froid ajusté et fermeture propre après dépassement du délai Gaia.
+-  PC avec Gecko 156.0.1 : 42 tests de services, démarrage, navigation, réglages, gestes, historique, isolation et persistance vérifiés.
+-  APK Preview15 signé avec la clé précédente et GeckoView 156.0 ; parité des ressources vérifiée.
+-  Émulateur Android 16 : mise à jour, Photo/Galerie, reprise caméra, dix retours d’arrière-plan et récupération du rendu validés.
+-  Sargo : sources incluses, build privé v59 conservé. Aucune ROM générique ; démarrage lié au stockage et aux images préinstallées.
+-  Sargo : aperçu proche de 16 i/s ; flash, vidéo, rotation et veille profonde incomplets. Audio d’appel et dialogue après envoi SMS à examiner.
+
[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/v2.7-preview.15)
-### Commun · Gaia / PC / Android / Tundra
-
-- Première publication des sources communes, avec README français et anglais, licences et scripts de préparation. Documentation interne, roadmap, profils, clés et images privées exclus du dépôt public.
-- Intégration des derniers correctifs SMS (dates des bulles, états d’envoi/échec) et du cycle de vie caméra. Les deux entrées du 30 septembre détaillent les correctifs matériels.
-
-### PC
-
-- Copie de publication testée avec moteur Gecko 156.0.1 et profil neuf : démarrage Gaia, navigation, réglages, gestes, historique et isolation des pages. 42 tests de services passants ; persistance des services après redémarrage vérifiée.
-- Stabilisation du test de navigation lors du remplacement normal d’un document et allongement de l’attente de démarrage à froid. Fermeture propre de l’hôte même si Gaia dépasse le délai de préparation.
-
-### Android · APK
-
-- Preview 15 signée avec la clé des précédentes Preview, GeckoView 156.0 ; parité des ressources communes vérifiée. Mise à jour installée sur émulateur Android 16.
-- Photo et Galerie : capture, affichage et reprise du capteur validés. Dix passages en arrière-plan et récupération après arrêt du processus d’affichage validés. Cette qualification ne couvre pas tous les téléphones Android.
-
-### Tundra · Sargo
-
-- Sources du runtime incluses ; l’image privée v59 reste celle qualifiée sur le téléphone de développement. Aucune ROM générique flashable publiée : démarrage encore lié au stockage inventorié, aux images préinstallées et aux accès de maintenance privés.
-- Limites conservées : aperçu caméra proche de 16 i/s, flash/vidéo, rotation et veille profonde incomplets ; audio d’appel à qualifier et grand dialogue après envoi SMS à identifier.
-
## En développement — retours SMS et caméra · 30 septembre 2026
-### Commun · Gaia / PC / Tundra
-
-- SMS : convertir les dates en nombres avant de construire les bulles. Corrige les conversations vides dues à une erreur de formatage de date. Parcours du compositeur et affichage des messages envoyés/reçus vérifiés sur PC avec un modem simulé.
-- Photo : fermeture du flux rendue idempotente; détacher les vidéos, attendre la libération du capteur et libérer le contexte WebGL avant de changer de caméra. Identifier chaque session pour qu’un ancien flux ne puisse pas arrêter son remplaçant. Allers-retours arrière/avant vérifiés avec de nouvelles trames à chaque bascule; retour Accueil et arrêt du capteur validés lors de deux démarrages RAM.
-
-### Tundra / Sargo
-
-- Vibreur : utiliser le contrôle brightness du pilote DRV2624; la commande legacy activate ne coupait pas physiquement le moteur. Coupure confirmée par l'utilisateur; impulsion, motif 200/200/200 ms et arrêt du service vérifiés, avec retour à zéro. Ajouter une coupure de secours au démarrage et à la sortie du service.
-- L'utilisateur confirme focus, zoom et transport SMS dans les deux sens sur v54. Cinq conversations existantes affichées sur le Pixel, avec leurs bulles et sans erreur de date.
-- v59 installé sur boot_a après deux démarrages RAM réussis; démarrage installé et relecture vérifiés, slot A marqué réussi. Sources et runtime PC synchronisés. Le grand dialogue après un envoi réel reste à identifier; la fluidité caméra reste un chantier distinct.
-
-### Android · APK
-
-- Sources Gaia communes corrigées; aucun nouvel APK construit ou publié.
+-  SMS : conversion des dates pour corriger les conversations vides.
+-  Caméra : fermeture des flux et libération du capteur corrigées ; une ancienne session ne peut plus fermer la suivante.
+-  Sargo : arrêt physique du vibreur via DRV2624, avec coupure de secours au démarrage et à l’arrêt du service.
+-  PC : composition et affichage des SMS vérifiés avec modem simulé. Sargo : bascules caméra et arrêt du capteur testés sur deux boots RAM.
+-  Sargo v54 : focus, zoom et SMS dans les deux sens confirmés par l’utilisateur. Cinq conversations affichées ; arrêt du vibreur confirmé.
+-  Sargo v59 installé sur boot_a après deux boots RAM ; démarrage, relecture et slot A vérifiés.
+-  Dialogue après envoi SMS et fluidité caméra encore à traiter. Sources communes mises à jour, sans nouvel APK.
## En développement — SMS et Photo · 30 septembre 2026
-### PC / Tundra
-
-- SMS : conserver le message et sa conversation après un refus du modem; transmettre les événements Gaia « envoi », « échec » et « envoyé ».
-- SMS : distinguer mise en file oFono et confirmation d'envoi, avec suivi persistant des états terminaux. La confirmation modem n'est pas un accusé de réception du destinataire.
-- PC : tests de l'échec d'envoi visible dans la liste des conversations; Photo et Galerie testées avec capture tactile, ouverture, fermeture et reprise de la caméra.
-
-### Tundra / Sargo
-
-- Photo : raccorder les zones de focus tactile, le zoom numérique et la capture JPEG du capteur à l'interface Gaia originale. Aperçu conservé en 640 × 480; JPEG arrière 4032 × 3024 (12,19 Mpx) et avant 3264 × 2448 (7,99 Mpx) mesurés directement. Focus fixe à l'avant.
-- Protocole caméra : séparer les traces du HAL des données binaires, borner les réponses et relancer l'aperçu après une capture. Une erreur de focus ne doit plus fermer la session.
-- SMS : refuser explicitement l'envoi si le modem n'est pas enregistré; conserver les états sent/failed même après disparition de l'objet oFono. La réception a produit des notifications opérateur sur v49 après retour du réseau LTE.
-- Qualification : deux démarrages v54 en RAM puis démarrage depuis boot_a validés; image installée sur le Pixel et slot marqué réussi après relecture. Persistance, interface Gaia et tests caméra passants. L'enregistrement réseau reste instable : essais SMS arrêtés avant émission, aucune livraison de bout en bout confirmée.
-
-### Android · APK
-
-- Aucun nouvel APK construit ou publié dans cette passe. La Preview 14 disponible reste inchangée.
+-  PC/Tundra : conservation des SMS après refus du modem et transmission des états envoi, échec et envoyé à Gaia.
+-  Sargo : états SMS persistants après disparition de l’objet oFono ; envoi refusé si le modem n’est pas enregistré.
+-  Sargo : focus tactile, zoom numérique et capture JPEG du capteur dans Photo Gaia.
+-  Photos mesurées : 4032 × 3024 à l’arrière, 3264 × 2448 à l’avant. Aperçu 640 × 480 ; focus avant fixe.
+-  Caméra native : données binaires séparées des traces HAL, réponses bornées et reprise de l’aperçu après capture ou erreur de focus.
+-  PC : échec d’envoi visible et parcours Photo/Galerie vérifiés. Sargo v54 : deux boots RAM puis installation, persistance et caméra validées.
+-  Réseau instable ; aucune livraison SMS de bout en bout confirmée dans cette passe. Confirmation modem distincte de l’accusé de réception. APK public inchangé (Preview14).
## 2.7-preview.14 — APK publié · 30 septembre 2026
+-  Informations système : alignement des versions et polices Fira dans les composants Shadow DOM.
+-  PC/Tundra : taille des touches en portrait, curseur dans le champ actif et réponses de saisie obsolètes.
+-  PC/Tundra : pont clavier Gaia et pavé numérique pour les destinataires SMS. Clavier Android inchangé.
+-  Verrouillage : redimensionnement sans fenêtre clavier. Notifications silencieuses respectées, y compris après restauration.
+-  Sargo : notifications persistantes dans le volet et sur le verrouillage, avec ouverture de l’application concernée.
+-  Sargo : vibrations annulables, limitées à dix secondes ; profils Wi-Fi privés persistants, reconnexion automatique et oubli du réseau.
+-  Sargo : stockage des SMS entrants avant acquittement, dédoublonnage et création du dossier d’état.
+-  Sargo : commandes écouteur/haut-parleur, micro muet et volume d’appel. Bascule, sourdine et volume testés hors appel.
+-  Installation : flash des images de diagnostic avec débogueur refusé. Sargo v48 installé après deux boots RAM ; persistance, relecture et slot A vérifiés.
+-  PC : Calendrier et clavier vérifiés. Sargo : clavier, notifications et reconnexion Wi-Fi vérifiés ; son des notifications et téléphonie réelle à valider.
+-  APK Preview14 signé et publié. Démarrage, versions et polices vérifiés sur émulateur Android 16. Aucune image Sargo publiée.
+
[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-preview.14)
-L’APK Preview 14 est publié. La version PC est à jour et le build privé Sargo v48 est installé et testé ; aucune image téléphone n’est publiée avec cette release.
-
-### Commun · PC / APK / Gaia
-
-- Informations : suppression du retrait supplémentaire des lignes de version avant « Dernière mise à jour » ; alignement contrôlé sur PC et Android.
-- Polices : normalisation des noms de fontes dans les feuilles des composants Shadow DOM, y compris les styles ajoutés après leur création. Les polices d’icônes restent distinctes.
-- Calendrier : ouverture du mois et absence d’erreur JavaScript vérifiées sur PC.
-
-### PC / Tundra
-
-- Clavier : calculer le mode portrait depuis l’écran, et non depuis la petite fenêtre du clavier. Touches de 60 px CSS environ sur Pixel ; rotation et retour au portrait testés sur PC à densité 3.
-- SMS : replacer le curseur dans le champ actif lorsque Gecko conserve la sélection du destinataire ; conserver les sélections valides pour l’édition.
-- Notifications Gaia : respecter les indicateurs silencieux, sans son et sans vibration, y compris à la restauration.
-
-- Clavier Gaia : pont de saisie natif, pavé numérique pour les destinataires SMS, événements de sélection corrigés et protection contre les réponses d’un champ devenu inactif. Saisie destinataire et message testée sur PC et Pixel ; l’envoi opérateur reste à qualifier.
-- Verrouillage : accepter l’absence de fenêtre clavier lors d’un redimensionnement.
-- Notifications natives : stockage, propriété par application et ouverture du point d’entrée Téléphone déclaré par Gaia. Présentation et suppression dans le volet et sur le verrouillage validées sur Sargo ; écoute du son à qualifier.
-
-### Tundra / Sargo — build v48 installé
-
-- Vibrations : pont vers le moteur Sargo, motifs bornés à dix secondes, séquencement sans bloquer les boutons physiques et annulation. Activation et arrêt mesurés sur le pilote.
-
-- Wi-Fi : profils privés conservés avec reconnexion automatique et commande d’oubli ; tests du stockage et de la restauration en cas d’échec. Connexion et reconnexion après redémarrage RAM vérifiées sur Sargo ; fichiers de profil privés (0600).
-- SMS entrants : stockage persistant avant acquittement, dédoublonnage des reprises et services créant leur dossier d’état au démarrage. Défaut de montage du dossier corrigé après reproduction sur Sargo.
-- Audio : commandes écouteur/haut-parleur, micro et volume d’appel ajoutées au pont ; bascule écouteur/haut-parleur, sourdine et volume vérifiés sans appel. Conversation dans les deux sens encore à vérifier.
-- Images de diagnostic avec débogueur local interdites au flash par l’outil d’installation. v48/preview.14 écrite sur boot_a après deux démarrages RAM et contrôle de persistance ; démarrage installé validé, image relue conforme et slot A marqué réussi. v43 à v48 n’activent pas de débogueur.
-
-### Android · APK
-
-- APK preview.14 construit, signé et publié sur Forgejo. Démarrage, informations de version, alignement et sélection des fontes des composants contrôlés sur Android 16 émulé. Le clavier Android reste celui du système.
-
## 2.7-preview.13 — parcours dans les applications · 27 septembre 2026
-### Commun · Gaia / PC
-
-- Calendrier : une alarme planifiée avec succès ne provoque plus un rejet lors de la sauvegarde de l’événement; les erreurs de planification sont transmises au demandeur.
-- Rappels : ignorer ceux d’événements supprimés; ne plus marquer une notification refusée comme livrée. La prise en charge des notifications natives Tundra reste à terminer.
-- Notifications : accepter un texte brut vide sans chercher une traduction « undefined ».
-- Galerie : vignettes de taille positive même au démarrage masqué; annulation des mises à jour d’aperçu après fermeture de l’éditeur.
-- SMS : appliquer les filtres correspondant, lu/non lu, période et livraison; tests de lecture et suppression des conversations.
-- Revue approfondie : lecture, déplacement et suppression d’une vidéo WebM; édition noir et blanc et copie d’une photo; création et suppression d’un événement. Interfaces Gaia originales conservées.
-
-- Qualification depuis une copie isolée : démarrage, tests bureau, persistance et empaquetage Android réussis sans réécriture des sources.
-
-### Android · APK
-
-- APK preview.13 construit et signé; Photo/Galerie, Calendrier, dix retours d’arrière-plan et récupération du rendu validés sur Android 16 émulé.
-
-### Tundra · Sargo
-
-- Build v40/preview.13 installé après deux démarrages RAM : Gaia, caméra, Wi-Fi, luminosité et commandes contrôlés; relecture de boot_a conforme et slot validé. Limites restantes dans `docs/APPLICATION-REVIEW.md`.
+-  Calendrier : sauvegarde des événements avec alarme, remontée des erreurs et exclusion des rappels d’événements supprimés.
+-  Notifications : texte vide accepté ; notification refusée non marquée comme livrée. Support natif Tundra encore incomplet.
+-  Galerie : taille des vignettes au démarrage masqué et arrêt des mises à jour après fermeture de l’éditeur.
+-  SMS : filtres par correspondant, lecture, période et livraison appliqués.
+-  PC : vidéo WebM, édition et copie de photo, calendrier, lecture et suppression des conversations vérifiés.
+-  Copie isolée : démarrage, tests PC, persistance et empaquetage Android réussis.
+-  APK Preview13 construit et signé. Photo/Galerie, Calendrier, dix reprises d’arrière-plan et récupération du rendu validés sur Android 16 émulé.
+-  Sargo v40 installé après deux boots RAM ; Gaia, caméra, Wi-Fi, luminosité, commandes et relecture de boot_a vérifiés.
## 2.7-preview.12 — revue des applications · 27 septembre 2026
-### Commun · Gaia / PC
-
-- Icônes internes : variantes raster Gaia originales de 1× à 2,25× rétablies dans
- 1 047 références CSS. Les icônes vectorielles et les polices restent originales.
-- Photo : bouton de changement de capteur réactivé après découverte des caméras;
- flash indisponible visible et grisé; le pincement ne zoome plus le document Gaia.
-- Accueil : gestes natifs réservés au bouton pendant l’appui long; menu contextuel
- désactivé sur ce bouton. Le gestionnaire des tâches Gaia reste utilisé.
-- Transmission des gestes entre System et les applications restaurée avec des
- événements DOM non privilégiés, sans autoriser de faux gestes sur les sites Web.
-- Navigateur : intégration de la page `browser-welcome` fournie, locale, bilingue,
- sans suivi; recherches et liens transmis au véritable navigateur.
-- Page d’accueil : suit automatiquement la langue Gaia, `fr`/`fr-*` en français,
- les autres en anglais; changement appliqué sans redémarrage.
-- SMS : identifiants compatibles avec le registre numérique Gaia, migration des
- anciens messages, dates de l’API et événement d’envoi corrigés; écritures sérialisées.
-- Calendrier : libellé d’accessibilité du jour courant retrouvé dans 37 langues.
-- Revue de 14 applications; 26 contrôles fonctionnels Galerie/Musique/Horloge/UI,
- tests Contacts et SMS, et navigation Web isolée. Voir `docs/APPLICATION-REVIEW.md`.
-
-### Android · APK
-
-- APK `2.7-preview.12` : même interface, correctifs et page locale; reconstruction
- et parité contrôlées. L’accès caméra reste celui d’Android via getUserMedia.
-- Aucun portage automatique des fonctions matérielles Tundra vers Android : chaque
- plateforme conserve son adaptateur et annonce ses capacités réelles.
-
-### Tundra · Sargo
-
-- Aperçu Photo : conversion NV21 par shader, transport binaire au lieu de base64;
- environ 23 images/s mesurées en RAM contre environ 8 dans l’essai intermédiaire.
-- Fermeture du worker attendue avant de rouvrir un autre capteur.
-- Luminosité manuelle : retrait des animations successives, environ 200 ms pour
- atteindre la valeur demandée lors du test matériel; transitions veille/réveil conservées.
-- Wi-Fi : état radio réel et activation/désactivation raccordés aux réglages Gaia;
- réseau mixte WPA2/WPA3 compatible avec le formulaire WPA-PSK.
-- Scan par Gaia, connexion et navigation HTTPS par wlan0 validés en RAM. Les
- profils de connexion restent temporaires; leur persistance est encore à réaliser.
-- Image v38 installée : deux démarrages RAM, démarrage installé, relecture de
- `boot_a`, préservation des autres partitions et validation du slot A réussis.
-- Flash photo, vidéo, capteur de luminosité/rotation, Bluetooth, comptage de données
- et fonctions avancées d’appel restent incomplets. Un écran qui s’ouvre ne vaut
- pas validation de tout son matériel.
+-  Icônes Gaia : variantes de 1× à 2,25× restaurées dans 1 047 références CSS.
+-  Photo : changement de capteur après détection et désactivation du zoom de l’interface au pincement. Flash indisponible grisé.
+-  Accueil : appui long et transmission des gestes rétablis, sans privilèges supplémentaires pour les sites web.
+-  Navigateur : page d’accueil locale bilingue, suivant la langue Gaia sans redémarrage.
+-  SMS : identifiants, migration, dates, événements d’envoi, requêtes multi-destinataires et écritures concurrentes corrigés.
+-  Calendrier : libellé d’accessibilité du jour courant rétabli dans 37 langues.
+-  Revue de 14 applications ; 26 contrôles Galerie/Musique/Horloge/UI, tests Contacts/SMS et isolation web.
+-  APK Preview12 reconstruit avec les mêmes ressources ; parité vérifiée. Fonctions matérielles adaptées séparément sur Android et Tundra.
+-  Sargo : aperçu caméra NV21 par shader et transport binaire, environ 23 i/s mesurées contre 8 dans l’essai intermédiaire.
+-  Sargo : fermeture du worker caméra attendue avant changement de capteur ; luminosité manuelle réglée en environ 200 ms.
+-  Sargo : activation Wi-Fi depuis Gaia et connexion WPA-PSK aux réseaux mixtes WPA2/WPA3.
+-  Sargo : scan, connexion et HTTPS validés en RAM. v38 installé après deux boots RAM, relecture et slot A vérifiés.
+-  Sargo : profils Wi-Fi temporaires ; flash, vidéo, capteurs, Bluetooth, comptage de données et appels avancés incomplets.
## En développement — retours Sargo et interface · 27 septembre 2026
-### Commun · Gaia / PC
-
-- Animation d’arrêt : trois nuances bleues remplacent les ronds orange; dimensions,
- séquence et durées de l’animation Gaia sont conservées.
-
-- Téléphone : empêcher le défilement du document lors du changement d’onglet;
- les en-têtes Journal et Contacts restent en place. Les icônes du journal utilisent
- les images Gaia originales en plusieurs densités, jusqu’à 2,25×.
-- Menus déroulants : retrait du décalage prévu pour les anciens moteurs, qui coupait
- les jambages de Fira. Vidéo : texte de présentation contenu dans la fenêtre.
-- Luminosité : le curseur enregistre un nombre et répond aux événements input/change;
- les anciennes valeurs textuelles sont converties avant la transition Gaia. La file
- matérielle regroupe les pas obsolètes de luminosité sans réordonner veille/réveil.
-- Accueil : le fond d’écran continue sous le dégradé transparent. L’espace de navigation
- reste réservé dans les applications; en paysage, le bouton se place à droite.
-- Navigateur : page locale éditable dans `overrides/search.gaiamobile.org/home/index.html`.
- Les branchements de Gaia et le mode privé restent en place.
-- Wi-Fi : un échec affiche une explication et le bouton de reprise au lieu de relancer
- indéfiniment le scanner. La recherche et l’affichage dans le panneau Gaia ont
- maintenant été vérifiés sur Sargo en RAM.
-- Suivi consolidé : `docs/CHANTIER.md`, avec anciens et nouveaux retours, preuves et limites.
-
-### Android · APK
-
-- APK `2.7-preview.11` construit et signé avec la même base Gaia; parité vérifiée.
- Barre Accueil à droite en paysage et correction du fond en mode Accueil.
- Rotation et absence de recouvrement vérifiées sur émulateur Android 16.
-
-### Tundra · Sargo
-
-- Caméra : aperçu matériel dans Photo Gaia, JPEG et bascule arrière/avant validés
- en RAM sur Sargo; les capteurs se ferment au retour à l’accueil. Capture initiale
- limitée à 640 × 480 (480 × 640 en portrait); vidéo et flash non disponibles.
- L’interface reste commune au PC et à l’APK, qui conservent getUserMedia.
-
-- v33 (`2.7-preview.11`) installé après deux démarrages RAM réussis. Démarrage installé, relecture de
- `boot_a`, conservation des données et finalisation du slot A vérifiés.
-- Luminosité mesurée depuis Gaia : 35 % → 89/255, 80 % → 204/255. Résultats Wi-Fi
- visibles dans le panneau original. Animation d’arrêt bleue incluse.
-- Routage audio : prise en charge du format de PulseAudio 14; suppression du double
- chargement des modules audio. Passage au profil téléphonique et à l’écouteur, puis
- restauration du profil média, vérifiés sur Sargo. Conversation réelle encore à valider.
-- Restent ouverts : conversation audible dans les deux sens, capture pleine résolution/vidéo et
- raccordement des capteurs pour la rotation automatique. Voir la liste de suivi.
+-  Animation d’arrêt Gaia recolorée en bleu.
+-  Téléphone : en-têtes fixes au changement d’onglet et icônes du journal adaptées aux densités jusqu’à 2,25×.
+-  Texte coupé dans les menus déroulants et débordement du texte d’accueil Vidéo.
+-  Luminosité : valeurs numériques et regroupement des commandes obsolètes, avec ordre veille/réveil préservé.
+-  Accueil : fond sous le dégradé et bouton à droite en paysage, sans recouvrir les applications.
+-  Navigateur : page d’accueil locale modifiable, avec mode privé conservé.
+-  Wi-Fi : message d’erreur et bouton Réessayer à la place des relances sans fin.
+-  APK Preview11 construit et signé ; rotation et position du bouton Accueil vérifiées sur Android 16 émulé.
+-  Sargo : aperçu, JPEG et bascules caméra validés en RAM. Capture limitée à 640 × 480 ; flash et vidéo indisponibles.
+-  Sargo v33 installé après deux boots RAM ; démarrage, données, relecture et slot A vérifiés. Luminosité et résultats Wi-Fi contrôlés dans Gaia.
+-  Sargo : compatibilité PulseAudio 14 et suppression du double chargement des modules audio. Profils appel/média et écouteur testés.
+-  Sargo : conversation audio, capture pleine résolution, vidéo et rotation automatique à valider.
## En développement — stabilité Wi-Fi native · 27 septembre 2026
-### Commun · Gaia / PC
-
-- Correction de la boucle Wi-Fi sans délai : recherche toutes les 20 secondes,
- conformément à l’intervalle prévu par Gaia. Regroupement des scans simultanés.
-- Version de développement commune : `2.7-preview.9`, Gaia `2.7.0`; versions
- réelles de Gecko inchangées. Tests de rafales, erreurs et temporisation ajoutés.
-
-### Android · APK
-
-- Nouveau paquet `2.7-preview.9` pour conserver la même base Gaia. Le Wi-Fi Android
- reste géré par Android; ce correctif ne prétend pas ajouter un pilote à l’APK.
-
-### Tundra · Sargo
-
-- Cause du blocage v21/v22 identifiée : multiplication des clients Python de
- recherche Wi-Fi, épuisement mémoire et arrêt de Gecko par le lowmemorykiller.
- Le SIGSEGV de phoc survient ensuite pendant sa fermeture.
-- Un seul scan matériel en cours, cache court des résultats et des erreurs,
- invalidation après connexion/déconnexion. Aucun affaiblissement de l’isolation
- des applications, ni désactivation du mécanisme de protection mémoire.
-- Candidat v25 qualifié sur deux démarrages RAM, puis installé et vérifié sur le
- Pixel : 11 contrôles Gaia et 10 contrôles système passent, sans destruction de
- processus par manque de mémoire. Verrouillage Gaia, Fira, batterie, date et
- panneau Wi-Fi contrôlés; veille/réveil et volume testés par événements injectés.
-- Relecture de `boot_a` conforme, autres partitions de démarrage inchangées,
- données conservées et slot A marqué comme démarré avec succès. La caméra native
- et la validation complète des appels restent à terminer.
+-  Wi-Fi : intervalle de recherche rétabli à 20 secondes et regroupement des scans simultanés.
+-  Sargo : un seul scan matériel à la fois, avec cache court et invalidation à la connexion/déconnexion. Corrige l’épuisement mémoire des builds v21/v22.
+-  Tests de rafales, erreurs et temporisation Wi-Fi.
+-  Version commune Preview9, Gaia 2.7.0 ; nouvel APK avec les mêmes sources. Wi-Fi Android toujours géré par Android.
+-  Sargo v25 : deux boots RAM puis installation, 11 contrôles Gaia et 10 système réussis, sans arrêt de processus par manque de mémoire.
+-  Verrouillage, polices, batterie, date et Wi-Fi vérifiés ; veille/réveil et volume testés par événements injectés. Données, relecture et slot A contrôlés.
+-  Caméra native et validation complète des appels encore à terminer.
## En développement — restauration Gaia · 27 septembre 2026
-### Commun · Gaia / PC
-
-- Restauration des panneaux Gaia et suppression des fenêtres/boutons provisoires :
- Paramètres, Suivi conso, FM et import de contacts/médias. Les fonctions sans API
- restent visibles avec commandes désactivées, sans valeurs matérielles inventées.
-- Correction de la portée des anciens styles : les sélecteurs `div` ne sont plus
- transformés en racines de composants. Retour des icônes du verrouillage,
- conservation de Fira et glyphes Gaia vectoriels pour les onglets Téléphone.
-- Accueil dispose de 44 pixels CSS réservés et ne recouvre plus Décrocher/Raccrocher.
- La date utilise le composant historique; la batterie est actualisée dans Paramètres
- et ses libellés n’affichent plus `{{ level }}`.
-- Écran de verrouillage Gaia réactivé; splash provisoire avec le logo fourni,
- dimensions stables et animation douce. Nom localisé : Navigateur / Browser.
- Remerciements dans Informations légales, avis de licences conservés.
-- Tests PC : 26 contrôles applicatifs passent (Galerie, Musique, Horloge, tâches),
- panneaux, imports, batterie, date et navigation; appels simulés,
- journal, veille/réveil, menu Power et volume. Voir `docs/GAIA-RESTORATION.md`.
-
-### Android · APK
-
-- APK `2.7-preview.8` construit et installé sur l’émulateur Android 16, avec la même base Gaia; batterie lue depuis Android,
- Paramètres Android en bas de liste et mode Accueil Android optionnel. Le catalogue
- des applications Android dans Gaia reste à raccorder.
-- Validation Android : dix reprises après changement d’application et récupération
- après destruction du renderer. Parité du contenu partagé vérifiée; pas de test
- physique Android dans cette passe.
-- Les imports utilisent les composants Gaia; le bouton SMS ouvre la confirmation
- Android. Le verrouillage interne ne remplace pas la sécurité Android.
-
-### Tundra · Sargo
-
-- Candidats v21/v22 construits avec tous les correctifs `overrides/` dans le paquet de boot;
- raccordement scan/association Wi-Fi au panneau Gaia via NetworkManager.
-- Essais RAM bloqués : service graphique arrêté avec le code 139 avant qualification.
- Aucun nouveau flash. Retour sur v20 installé validé (11 contrôles Gaia, 9 système,
- données conservées); verrouillage du profil natif remis à son état précédent.
- Wi-Fi du candidat, caméra native, API SIM/Bluetooth et suspension CPU non qualifiés.
+-  Panneaux Gaia restaurés : Paramètres, Suivi conso, FM et imports. Interfaces provisoires retirées ; fonctions indisponibles désactivées.
+-  Styles des anciens composants, icônes du verrouillage et glyphes Téléphone corrigés.
+-  Espace de 44 pixels réservé au bouton Accueil ; date et batterie rétablies, sans texte `{{ level }}`.
+-  Verrouillage Gaia réactivé, splash stabilisé, nom Navigateur/Browser localisé et crédits ajoutés.
+-  PC : 26 contrôles applicatifs réussis, plus panneaux, imports, batterie, navigation et appels simulés.
+-  APK Preview8 installé sur Android 16 émulé : batterie Android, accès aux paramètres et mode HOME optionnel. Catalogue Android encore incomplet.
+-  Android émulé : dix reprises d’arrière-plan, récupération du rendu et parité des ressources vérifiées. Verrouillage Gaia distinct de la sécurité Android.
+-  Sargo v21/v22 : scan et association Wi-Fi raccordés à Gaia via NetworkManager.
+-  Sargo : essais RAM interrompus par l’arrêt du service graphique (code 139). Retour au v20 installé vérifié, sans nouveau flash.
+-  Wi-Fi du candidat, caméra, API SIM/Bluetooth et suspension CPU non validés.
## En développement — 27 septembre 2026
-### Commun · Gaia / PC
-
-- Remplacement de l’écran d’appel provisoire par **Callscreen**, l’interface
- originale de Gaia : appel entrant/sortant, réponse, raccrochage et fermeture.
- L’adaptateur fournit les événements `mozTelephony` aux modules historiques.
- Les contrôles audio/conférence non encore raccordés restent grisés.
-- Journal d’appels persistant côté hôte, puis import dans le vrai journal Gaia,
- avec acquittement et détection des doublons après interruption.
-- Batterie : pont vers l’icône historique ; une batterie inconnue n’est pas
- présentée comme vide. Correction de l’index de contacts Facebook absent ou
- mal formé, qui bloquait aussi la recherche de numéro et l’historique local.
-- Polices : comparaison des glyphes réellement rendus. L’accueil utilisait
- déjà Fira Sans des deux côtés, mais certains textes système utilisaient Noto
- sur PC. Les règles de texte principales sélectionnent désormais la Fira de
- Gaia ; les polices d’icônes sont conservées.
-- Test Gecko avec modem simulé : appels entrants/sortants, boutons Répondre et
- Raccrocher, journal, veille/réveil, menu Power, volume et batterie.
- Aucun nouvel APK compilé dans cette étape.
-
-- Étape v13 (remplacée par Callscreen ci-dessus) : écran d’appel géré par l’application système : numérotation, sonnerie,
- communication et bouton Raccrocher. Les contrôles restent présents si le
- service perd momentanément l’état de l’appel.
-- Tests sur le vrai Gecko bureau avec modem simulé, y compris échec puis nouvel
- essai de raccrochage. Sources PC synchronisées ; aucun APK reconstruit.
-
-### Tundra · Sargo
-
-- Build privé v20 installé et relu dans `boot_a`, après deux démarrages RAM
- et un démarrage depuis le stockage ; réglages conservés, slot marqué réussi.
-- Lecture des seules touches Power/Volume du Sargo et
- transmission à `HardwareButtons`. Appui court : écran éteint/rallumé ; appui
- long : menu Gaia. Le broker natif commande rétroéclairage, volume, arrêt et
- redémarrage avec une liste d’opérations fixe.
-- Sérialisation des changements de luminosité et d’alimentation : deux
- requêtes simultanées ne peuvent plus laisser le rétroéclairage éteint au réveil.
-- Volume natif : accès en lecture au socket PulseAudio de la session, sans
- lequel l’indicateur Gaia changeait mais la sortie audio restait inchangée.
-- La veille écran ne suspend pas encore le noyau. Réception réelle des appels,
- audio de conversation et commandes audio avancées restent à qualifier.
-
-- Étape précédente : build privé v13 installé dans `boot_a` après deux démarrages RAM ; démarrage
- depuis le stockage, relecture et indicateur de réussite A/B vérifiés.
-- Transmission des appels et de leurs états depuis oFono ; commandes Répondre et
- Raccrocher limitées aux chemins d’appel du modem.
-- Correction des réponses D-Bus sans contenu : un raccrochage réussi ne provoque
- plus une fausse erreur de réponse invalide.
-- Un appel réel court atteint la sonnerie puis est raccroché automatiquement.
- Cela ne qualifie pas encore l’audio en conversation ni les appels entrants.
-- Caméra : diagnostic corrigé, sans annoncer une initialisation à partir d’un
- simple délai d’attente. Deux capteurs sont détectés via libhybris, mais le test
- de flux reçoit zéro image. La caméra Gaia native reste indisponible ; voir la
- [note technique](tundra/docs/CAMERA-SARGO.md).
+-  Callscreen Gaia pour les appels entrants/sortants, avec réponse et raccrochage. Remplace l’écran provisoire v13.
+-  Journal d’appels persistant avec acquittement et dédoublonnage.
+-  Batterie inconnue distincte d’une batterie vide ; index Contacts absent ou mal formé toléré.
+-  Textes système affichés avec Fira Sans, sans modifier les polices d’icônes.
+-  PC avec modem simulé : appels, raccrochage en échec puis nouvel essai, journal, veille/réveil, menu Power, volume et batterie.
+-  Sargo : boutons Power/Volume raccordés à Gaia et commandes natives d’écran, volume, arrêt et redémarrage.
+-  Sargo : luminosité et alimentation sérialisées pour éviter l’écran noir au réveil ; accès PulseAudio corrigé pour le volume.
+-  Sargo : un raccrochage réussi avec réponse D-Bus vide ne produit plus de fausse erreur.
+-  Sargo v13 puis v20 installés après essais RAM ; démarrage, relecture, réglages et validation du slot contrôlés.
+-  Appel sortant réel arrivé à la sonnerie puis raccroché. Appels entrants et audio de conversation non validés.
+-  Diagnostic caméra : un timeout n’est plus présenté comme une initialisation réussie. Deux capteurs détectés, sans trames reçues.
+-  Caméra native indisponible, suspension CPU et commandes audio avancées incomplètes. Aucun nouvel APK.
## En développement — 26 septembre 2026
-### Commun · Gaia
-
-- Polices : Fira Sans OpenType du simulateur historique, avec les mêmes fichiers
- pour les pages Gaia et le système Tundra. Correction du repli `sans-serif` natif
- et vérification Fontconfig ajoutée ; les polices d’icônes restent distinctes.
-- Paramètres : retrait des boutons de diagnostic « Paramètres Tundra » et
- « Tester une notification ». Le résumé des versions et les informations
- techniques restent disponibles.
-- Panneau matériel : transmission des capacités de la plateforme au panneau.
- Une variable absente provoquait une alerte Gecko au lieu d’afficher la batterie.
-
-### PC
-
-- Adaptateur local synchronisé avec les sources communes. Gecko reste en 156.0.1.
-
-### Android
-
-- Correctif du panneau présent dans les sources communes ; aucun nouvel APK construit.
-
-### Tundra / Sargo
-
-- Validation explicite du succès A/B après installation : sauvegarde et contrôle
- des métadonnées GPT, outil Qualcomm vérifié, confirmation Fastboot.
-- Erreurs du modem distinguées des erreurs de transport ; boîte d’erreur intégrée
- à Gaia. La caméra native indisponible ne suggère plus une autorisation inutile.
-- Diagnostic des polices réellement utilisées par Gecko et du réseau radio, sans
- appel ni SMS automatique. Fira Sans confirmée sur l’accueil et la barre d’état.
-- Installation réelle du v9 polices dans `boot_a` sur le Sargo inventorié.
- Contenu écrit relu et conforme ; cinq autres partitions de boot sauvegardées intactes.
-- Deux démarrages depuis le stockage : 10 contrôles Gaia et 9 contrôles système
- réussis par boot, réglages conservés. Le PC n’est plus nécessaire pour démarrer.
- Fichiers de secours Droidian conservés ; restauration réelle non testée.
-- Candidat persistant privé : image de données ext4 2 Gio, profils et réglages Gaia
- conservés, identité/comptes système préservés et suppression du watchdog de dix minutes.
-- Deux démarrages successifs de la même image v8 : 9 contrôles Gaia et 9 contrôles
- système réussis par boot. Diagnostic séparé : 15 contrôles avec la même interface.
-- Installation limitée à `boot_a` et restauration de l’original préparées et testées
- localement ; aucun flash effectué. Les limites matérielles restent documentées.
-- Démarrage normal sans ouverture automatique de Paramètres/Horloge ni défilement
- de l’accueil. Ces gestes appartiennent désormais au mode explicite `--diagnostics`.
-- Plein écran établi avant le chargement de Gaia ; décorations natives désactivées.
- Sur le Pixel testé : 360 × 740 pixels CSS, facteur 3, dimensions stables et accueil
- inchangé pendant les cinq relevés après démarrage.
-- v13 testé en RAM : huit contrôles de démarrage et neuf contrôles système réussis.
- Les rapports distinguent cette vérification du démarrage des tests interactifs.
-- Diagnostic v14 séparé : treize contrôles interactifs réussis, dont le panneau
- matériel ; ouverture et scan Wi-Fi vérifiés. Même interface que le v13 normal.
-- Les images de diagnostic interactif ne peuvent pas devenir la sélection normale
- du lanceur. L’ancienne sélection v13 est conservée dans son historique.
-- Modem enregistré en UMTS pendant l’essai ; lecture audio réussie et signal du
- microphone mesuré sans conserver d’enregistrement. Cela ne qualifie pas encore
- l’audio en appel, la caméra ou la veille.
-- Construction du ramdisk indépendante de la langue du PC : analyse `readelf` en
- locale C pour conserver le chargeur dynamique ARM64 et ses dépendances.
-
-Les six partitions boot/DTBO/vbmeta A/B sont inchangées. Le build reste un essai
-privé en RAM, **pas une ROM flashable**. Détails : [état de Tundra](tundra/docs/STATUS.md).
+-  Polices Fira Sans historiques utilisées dans Gaia et Tundra ; repli sans-serif et contrôle Fontconfig corrigés.
+-  Boutons de diagnostic retirés des Paramètres.
+-  Panneau matériel : capacités de la plateforme transmises correctement pour afficher la batterie.
+-  Sargo : erreurs modem et transport distinguées ; message caméra indisponible corrigé.
+-  Sargo : données persistantes sur ext4 2 Gio et validation du succès A/B après installation.
+-  Sargo v8 : deux boots avec persistance, 9 contrôles Gaia et 9 système par boot. Outils d’installation et de secours testés localement.
+-  Sargo v9 installé sur boot_a : deux démarrages avec 10 contrôles Gaia et 9 système chacun. Relecture conforme, cinq autres partitions préservées ; restauration réelle non testée.
+-  Démarrage normal sans gestes de diagnostic automatiques ; plein écran établi avant Gaia, dimensions stables à 360 × 740 pixels CSS.
+-  Mode `--diagnostics` séparé ; images de diagnostic exclues du lancement normal.
+-  Sargo : v13 testé en RAM (8 contrôles de démarrage, 9 système), v14 en diagnostic (13 contrôles interactifs), Wi-Fi inclus.
+-  Enregistrement UMTS, lecture audio et signal du micro vérifiés. Audio d’appel, caméra et veille non validés.
+-  Construction du ramdisk indépendante de la langue du PC grâce à readelf en locale C.
+-  Essais privés, sans ROM publique. Sources PC synchronisées ; correctif commun disponible pour Android, sans nouvel APK.
## En développement — 25 septembre 2026
-### Commun · Gaia
-
-- **Navigation :** bouton Accueil rond ; dégradé noir vers transparent au-dessus du
- fond d’écran, fond bleu uni dans les applications. L’appui modifie le cercle
- plutôt que de laisser un rectangle bleu sur l’accueil.
-- **Défilement :** le dégradé disparaît au bas du panneau de l’accueil ; il revient
- quand on remonte. Le changement de panneau Applications / Pages est pris en compte.
-- **Typographie :** polices Fira Sans embarquées, avec plusieurs graisses et italiques,
- au lieu de dépendre des polices installées sur le système hôte.
-- **Informations :** affichage séparé de Vulpes, Gaia, du moteur réellement exécuté,
- de la plateforme, du modèle et de l’adaptateur. Le panneau prévoit Tundra et
- l’adaptation matérielle, maintenant raccordés au backend natif Tundra.
-- **Icônes :** reprise du chargement après une première mesure de taille nulle,
- partagée entre PC, Android et Tundra. Le correctif était auparavant limité à Android.
-- **Maintenance :** ce journal centralise les évolutions des trois plateformes.
-
-### PC · Validé localement
-
-- Chargement d’une icône initialisée à taille nulle puis rendue visible : testé.
-- Navigation Accueil / Paramètres / retour Accueil, fond transparent en bas de page,
- dégradé en haut et contrôle des permissions testés dans un profil isolé.
-- Panneau Informations testé en français et en anglais, sans anciennes lignes en double.
-- Le moteur demeure Gecko **156.0.1** ; ces changements d’interface ne recompilent pas Gecko.
-
-### Android · Sources adaptées, Java compilé
-
-- Même comportement de navigation prévu par le pont GeckoView / Android : barre
- superposée sur l’accueil, place réservée dans les applications et le navigateur.
-- État de navigation conservé lors de la recréation de l’activité Android.
-- Compilation Java réussie. **Aucun nouvel APK livré pour ces changements** et
- comportement visuel encore à retester sur Android.
-
-### Tundra / Sargo · Essais privés en RAM
-
-- Build réseau v6 devenu référence du lanceur après 13 contrôles Gaia et 9
- contrôles système ; retour automatique et six partitions inchangées vérifiés.
-- Backend Tundra distinct du PC : modèle Pixel 3a, adaptation Sargo et version
- native affichés sans annoncer une caméra ou une téléphonie non raccordées.
-- Batterie et charge lues sur le matériel ; panneau d’état bilingue dans Gaia.
- 13 contrôles Gaia réussis sur l’essai réseau, dont ce nouveau panneau.
-- Wi-Fi réel testé : scan, connexion, IPv4, DNS et page HTTPS sous Gecko.
- Ajout de `pd-mapper`, désactivation d’IPA non initialisé et du sondage caméra
- prématuré ; 9 contrôles système passent, sans Oops noyau. La sélection du réseau
- dans Gaia reste à raccorder ; les identifiants du test restent en RAM.
-- Configuration des services embarquable dans le petit ramdisk : pas de nouvelle
- copie des 3 Gio pour chaque ajustement de démarrage. Partitions modem protégées
- en utilisant des copies NV en RAM pour les essais Qualcomm.
-
-- Nouvelle image système ext4 contenant le runtime ARM64, les HAL de référence,
- Phoc, Gecko et Gaia. Le runtime n’est plus chargé depuis l’arborescence de travail
- Droidian, ni le HAL depuis son ancien fichier `/var/lib/lxc/android/`.
-- Démarrage du second essai vérifié sur Pixel 3a : PID 1 Tundra, services HAL,
- compositeur et Gaia ; **7 contrôles Gaia et 8 contrôles système réussis**.
-- Capture physique de l’accueil contrôlée : icônes présentes, Fira Sans chargée,
- bouton rond et dégradé visibles. Gecko **156.0.1 ARM64**, WebRender matériel.
-- Petits changements d’interface embarqués dans le ramdisk d’essai : inutile de
- retransférer les 3 Gio du système pour chaque correction CSS/JavaScript.
-- Copie des sources de construction conservée avec les nouvelles images, vérifiée
- par empreintes, pour permettre de les retester après l’évolution du code courant.
-- Le dernier essai (`sargo-system-boot-20260925-v5`) valide **10 contrôles Gaia et
- 8 contrôles système**, dont icônes, police chargée, fond à l’appui, bas de page
- et retour depuis une application. Capture physique contrôlée ; référence locale
- du lanceur mise à jour. Aucun APK publié ni téléphone flashé.
-
-**Limites :** stockage installé encore utilisé comme support du fichier système ;
-modifications perdues à l’arrêt ; retour automatique au système conservé après dix
-minutes. Aucun flash ni remplacement de Droidian. Le redémarrage à chaud du
-compositeur a échoué lors d’un essai ; le démarrage à froid fonctionne. Réseau,
-modem, audio, caméra et veille restent à intégrer ou qualifier. Ce n’est pas une
-ROM publique installable ni un téléphone quotidien validé.
-
-Détails : [image système](tundra/docs/SYSTEM-IMAGE-SARGO.md),
-[état Tundra](tundra/docs/STATUS.md), [parité des plateformes](docs/PARITE-BUREAU-ANDROID.md).
+-  Bouton Accueil rond, dégradé transparent sur le fond d’écran et barre bleue dans les applications.
+-  Dégradé masqué en bas de page et rétabli au défilement ; plus de rectangle bleu à l’appui sur Accueil.
+-  Fira Sans embarquée avec graisses et italiques ; versions produit, Gaia, moteur et plateforme affichées séparément.
+-  Reprise du chargement des icônes après une première mesure de taille nulle sur les trois plateformes.
+-  PC : icônes, navigation, permissions et panneau Informations FR/EN vérifiés avec Gecko 156.0.1.
+-  Android : état de navigation conservé après recréation de l’activité. Compilation Java réussie ; rendu à retester, aucun nouvel APK livré.
+-  Tundra : image ext4 avec runtime ARM64, HAL, Phoc, Gecko et Gaia ; petits correctifs embarqués dans le ramdisk.
+-  Sargo : backend dédié, informations Pixel 3a et lecture matérielle de la batterie. Sources de construction archivées avec empreintes.
+-  Sargo : WebRender matériel, icônes et polices contrôlés sur capture physique. v5 : 10 contrôles Gaia et 8 système réussis.
+-  Sargo : ajout de pd-mapper, désactivation d’IPA non initialisé et du sondage caméra prématuré. Données NV du modem copiées en RAM pour les essais.
+-  Sargo v6 : scan Wi-Fi, connexion, IPv4, DNS et HTTPS vérifiés ; 13 contrôles Gaia et 9 système réussis. Sélection du réseau dans Gaia encore à raccorder.
+-  Essais RAM sans flash, retour automatique après dix minutes et modifications perdues à l’arrêt. Démarrage à froid réussi ; redémarrage à chaud du compositeur en échec.
+-  Pas de ROM publique ; modem, audio, caméra et veille encore à intégrer ou valider.
## 2.7-preview.7 — APK construit, référence locale
-### Commun / PC / Android
-
-- Correction de l’autorisation interne d’ouverture du capteur sur PC et de la
- reprise après la première demande de permission CAMERA sur Android.
-- Messages Photo distincts pour un refus, un capteur absent ou un échec d’ouverture.
-- Version produit commune **2.7-preview.7**, Gaia **2.7.0** ; numéros du moteur séparés.
-- Gecko PC **156.0.1**, GeckoView Android **156.0** : l’écart réel de correctif est
- affiché, sans renommer artificiellement l’un des moteurs.
-
-Détails : [Photo et Galerie](docs/CAMERA-GALERIE.md), [README Android](android/README.md).
+-  Permission caméra sur PC et reprise après la première autorisation Android.
+-  Messages distincts pour une permission refusée, un capteur absent et un échec d’ouverture.
+-  Versions produit et moteur séparées : Vulpes Preview7, Gaia 2.7.0, Gecko PC 156.0.1 et GeckoView Android 156.0.
## 2.7-preview.6 — APK construit
-### Commun / PC / Android
-
-- Portage de la véritable application Photo Gaia : aperçu et capture JPEG via
- webcam sur PC ou capteur via GeckoView sur Android.
-- Photos enregistrées dans Galerie Gaia ; import de médias par le sélecteur du système.
-- Commandes non prises en charge désactivées : notamment flash et vidéo ; changement
- de caméra selon les capteurs disponibles.
+-  Photo Gaia sur PC et Android : aperçu et capture JPEG via webcam ou GeckoView.
+-  Enregistrement dans Galerie et import par le sélecteur du système.
+-  Flash et vidéo désactivés ; changement de caméra selon les capteurs disponibles.
## 2.7-preview.5 — APK construit
-### Commun
-
-- Versions centralisées dans `release.json`, séparation produit / Gaia / moteur.
-- Interface des fonctions natives partagée entre PC et Android ; contrôle du contenu
- commun réellement embarqué dans l’APK avant distribution.
-
-### PC
-
-- Intégration des panneaux de réglages Linux disponibles, import de médias et vCard,
- notifications du bureau et capture webcam de test.
-
-### Android
-
-- Même interface Gaia et commandes natives que le PC, adaptées aux capacités Android.
-- Tests de retour au premier plan et de récupération après arrêt du processus de rendu.
+-  Versions centralisées dans `release.json` ; produit, Gaia et moteur séparés.
+-  Interface native commune PC/Android et contrôle de parité des ressources de l’APK.
+-  PC : réglages Linux, imports médias/vCard, notifications et capture webcam de test.
+-  Android : retour au premier plan et récupération après arrêt du processus de rendu.
## 2.7-156.0.2 — APK historique construit (révision 4)
-Ce nom historique mélangeait révision du produit et version du moteur ; il ne
-signifie pas que l’APK utilisait Gecko 156.0.2. Le moteur Android était Gecko 156.0.
-
-### Android
-
-- Reprise de l’activité et des sessions pour limiter les écrans blancs au retour dans Vulpes.
-- Accès aux réglages Android, au volume et à la luminosité de la fenêtre.
-- Import de médias et contacts, notifications natives avec permissions.
-- Transfert au composeur, à SMS et à Horloge Android ; aucun envoi silencieux ni
- remplacement des applications système Téléphone/SMS.
-- Capture initialement déléguée à l’application photo Android, avant le portage de Photo Gaia.
+-  Android : reprise de l’activité et des sessions pour réduire les écrans blancs.
+-  Accès aux réglages Android, volume et luminosité de la fenêtre.
+-  Import de médias et contacts ; notifications natives avec permissions.
+-  Transfert vers Téléphone, SMS, Horloge et Appareil photo Android. Aucun envoi silencieux ni remplacement des applications par défaut.
+-  Numérotation historique : moteur Android Gecko 156.0, malgré le nom 2.7-156.0.2.
## 2.7-156.0.1 — APK publié (révision 3)
-- Nom **Vulpes OS - Preview** et icône du projet.
-- Correctif du fond d’écran clignotant inclus.
-- Numérotation historique conservée ; ne pas en déduire le patch du moteur Android.
+-  Nom « Vulpes OS - Preview » et icône du projet.
+-  Clignotement du fond d’écran corrigé.
+-  Numérotation historique, distincte de la version du moteur Android.
[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-156.0.1)
@@ -830,7 +549,7 @@ signifie pas que l’APK utilisait Gecko 156.0.2. Le moteur Android était Gecko
## 2.7-preview.2 — APK publié
-- Correction du fond d’écran clignotant dans la Preview Android.
+-  Clignotement du fond d’écran dans la Preview Android.
[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-preview.2)
@@ -838,8 +557,8 @@ signifie pas que l’APK utilisait Gecko 156.0.2. Le moteur Android était Gecko
## 2.7-preview.1 — APK publié
-- Première Preview Android embarquant Gaia et GeckoView.
-- Version archivée ; clignotement du fond d’écran connu.
+-  Première Preview Android avec Gaia et GeckoView.
+-  Version archivée ; clignotement du fond d’écran connu.
[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-preview.1)
@@ -847,26 +566,12 @@ signifie pas que l’APK utilisait Gecko 156.0.2. Le moteur Android était Gecko
## Étapes antérieures — rétrospective technique
-Ces étapes ne correspondent pas à de nouvelles releases publiques datées.
-
-### Gaia / PC
-
-- Constitution de Vulpes à partir de Gaia historique ; identité visuelle, fond
- d’écran, traductions et travail sur les informations système.
-- Portage expérimental Gecko 45 → 52, puis séparation de Gaia, des services et du
- moteur pour l’hôte Gecko récent.
-- Réimplémentation des API nécessaires : réglages, registre d’applications,
- contacts locaux, messages, alarmes, stockage et communications entre applications.
-- Adaptation des composants visuels anciens, des interactions tactiles simulées,
- du navigateur et des applications ; essais d’applications Firefox OS archivées.
-- Outillage de mise à jour Gecko avec vérifications, moteur candidat et retour
- arrière explicite. Il détecte les incompatibilités, sans les réparer automatiquement.
-- Lancement et mise à jour de Gaia sans recompilation complète du moteur.
-
-### Tundra / Sargo
-
-- Inventaire du Pixel 3a, sauvegardes des partitions de boot et tests USB de diagnostic.
-- Tests QEMU, puis Gecko ARM64 dans Droidian et sous un compositeur dédié.
-- Racine distincte, services HAL sélectionnés et PID 1 Tundra ; essais graphiques
- temporaires par Fastboot avec secours SSH et retour automatique.
-- Noyau de référence **4.9-124-google-sargo**, adaptation **Android 9 / API 28**.
+-  Base Gaia historique avec identité Vulpes, fond d’écran, traductions et informations système.
+-  Portage expérimental Gecko 45 vers 52, puis séparation de Gaia, des services et du moteur.
+-  API de réglages, applications, contacts, messages, alarmes, stockage et communications entre applications.
+-  Adaptation des composants Gaia, gestes tactiles, navigateur et applications aux moteurs récents.
+-  Mise à jour Gecko avec contrôles et retour arrière ; mise à jour Gaia sans recompilation du moteur.
+-  Applications Firefox OS archivées, QEMU, puis Gecko ARM64 dans Droidian et sous compositeur dédié.
+-  Sargo : inventaire, sauvegardes de boot, racine distincte, services HAL et PID 1 Tundra.
+-  Essais graphiques Fastboot temporaires avec secours SSH et retour automatique.
+-  Noyau 4.9-124-google-sargo, adaptation Android 9/API 28. Étapes historiques, sans nouvelles releases datées.
diff --git a/README.fr.md b/README.fr.md
index fe75f913..92c85dc5 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -4,7 +4,7 @@
Firefox OS fork · Open source
-
+
@@ -38,6 +38,12 @@ Je développe Vulpes principalement seul, sous le nom d’OverSu, avec l’aide
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.
+### Preview 20 · 13 octobre 2026
+
+[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.
+
+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
Prérequis : Linux x86_64, Git, Python 3 et les bibliothèques système nécessaires à Firefox.
diff --git a/README.md b/README.md
index a3779e29..72bee2ba 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
Firefox OS fork · Open source
-
+
@@ -38,6 +38,12 @@ I’m OverSu, and I develop Vulpes mostly on my own, with help from people who t
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.
+### Preview 20 · 13 October 2026
+
+[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.
+
+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
You’ll need Linux x86_64, Git, Python 3 and the system libraries required by Firefox.
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 1d1d8db4..fae6411a 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -1,6 +1,7 @@
+
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 f52c11c4..64020f99 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
@@ -102,6 +102,18 @@ 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(() -> {
@@ -122,6 +134,15 @@ 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/MainActivity.java b/android/app/src/main/java/org/vulpes_os/preview/MainActivity.java
index 5b4dcba7..640a5578 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
@@ -550,12 +550,17 @@ public final class MainActivity extends Activity {
"http".equals(origin.getScheme()) &&
"camera.localhost".equals(origin.getHost()) &&
origin.getPort() == 18765;
- if (!camera || (audio != null && audio.length > 0) || video == null || video.length == 0) {
+ boolean hasVideo = video != null && video.length > 0;
+ boolean hasAudio = audio != null && audio.length > 0;
+ if (!camera || (!hasVideo && !hasAudio)) {
callback.reject();
return;
}
- permission(new String[] { android.Manifest.permission.CAMERA }, granted -> {
- if (granted) callback.grant(video[0], null);
+ 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);
else callback.reject();
});
}
diff --git a/android/extension/background.js b/android/extension/background.js
index e189b1d5..4baab916 100644
--- a/android/extension/background.js
+++ b/android/extension/background.js
@@ -1,3 +1,4 @@
+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';
@@ -10,6 +11,8 @@ 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;
@@ -31,7 +34,7 @@ function grants(app) {
function broadcast(type, data, target) {
for (const peer of peers) {
if (target && peer.app.id !== target) continue;
- if (type === 'settingchange' && data.key !== 'language.current' && !grants(peer.app).includes('settings.read')) continue;
+ if (type === 'settingchange' && !['language.current', 'vulpes.theme'].includes(data.key) && !grants(peer.app).includes('settings.read')) continue;
if (
type === 'contactchange' &&
!peer.app.manifest.permissions?.contacts?.access?.includes('read')
@@ -90,6 +93,7 @@ const ready = (async () => {
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(
@@ -100,7 +104,12 @@ 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 });
+ for (const [key, value] of entries) {
+ broadcast('settingchange', { key, value });
+ if(key==='keyboard.gaia.enabled') {
+ input.hide();broadcast('input-mode',{enabled:value===true});
+ }
+ }
});
writes = write.catch(() => {});
return write;
@@ -302,6 +311,9 @@ 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');
@@ -357,6 +369,7 @@ async function handle(peer, request) {
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);
@@ -412,6 +425,7 @@ 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');
}
@@ -449,6 +463,15 @@ 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);
});
@@ -456,6 +479,16 @@ 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' } };
@@ -469,6 +502,9 @@ browser.runtime.onConnect.addListener((port) => {
attached
.then(() => {
peers.delete(peer);
+ 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);
diff --git a/android/extension/content.js b/android/extension/content.js
index 17bbcc85..4c1b7d5c 100644
--- a/android/extension/content.js
+++ b/android/extension/content.js
@@ -25,7 +25,23 @@
});
}
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') {
+ gaiaKeyboard=message.event.data.enabled===true;
+ if(!gaiaKeyboard) restoreMode();
+ win.VulpesNativeInput=gaiaKeyboard;
+ win.dispatchEvent(new window.Event('vulpes-input-configured'));
+ }
win.dispatchEvent(
new window.CustomEvent('vulpes-service-event', { detail: cloneInto(message.event, win) }),
);
@@ -50,6 +66,45 @@
}, 1000);
}
connect();
+ const editor = new InputEditor(window);
+ let gaiaKeyboard = false;
+ 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);
+ }
+ request({version:1,id:0,method:'input.config',params:{}}).then(reply => {
+ gaiaKeyboard = reply.result === true;
+ win.VulpesNativeInput = gaiaKeyboard;
+ win.dispatchEvent(new window.Event('vulpes-input-configured'));
+ }).catch(error => console.error('Gaia input configuration:', error));
+ for (const type of ['focusin', 'pointerdown', 'click']) addEventListener(type, event => {
+ if (!gaiaKeyboard || editor.editing || location.hostname === 'keyboard.localhost') return;
+ let element = event.target;
+ if (element?.isContentEditable) element=element.closest('[contenteditable=true]') || element;
+ const editable = element?.isContentEditable ||
+ (['input','textarea'].includes(element?.localName) &&
+ !['button','submit','range','checkbox','radio','date','time','datetime-local','file'].includes(element.type));
+ if (!editable || element.disabled || element.readOnly) {
+ if (type === 'click' && !element?.closest?.('[data-vulpes-keep-input]')) port.postMessage({inputFocus:null});
+ return;
+ }
+ 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});
+ }, true);
+ addEventListener('visibilitychange', () => {
+ if (document.hidden && gaiaKeyboard) { restoreMode(); port.postMessage({inputFocus:null}); }
+ });
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 3220ceb0..740241d3 100644
--- a/android/extension/manifest.json
+++ b/android/extension/manifest.json
@@ -25,6 +25,7 @@
"http://*.localhost/*"
],
"js": [
+ "input-editor.js",
"content.js"
],
"all_frames": true,
diff --git a/android/tools/check-camera.py b/android/tools/check-camera.py
index 636d32ce..a6da92f7 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["unavailable_controls"] = ev(
+result["capability_controls"] = ev(
"camera",
- '[".js-flash",".mode-switch"].every(s=>document.querySelector(s).getAttribute("aria-disabled")==="true")',
+ 'document.querySelector(".js-flash").getAttribute("aria-disabled")==="true" && (document.querySelector(".mode-switch").getAttribute("aria-disabled")!=="true") === !!Object.keys(app.camera.mozCamera.capabilities.recorderProfiles).length',
)
-assert result["unavailable_controls"]
+assert result["capability_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 e6c9e1ed..5f004300 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 build_info, validate_assets, release_info, application_manifest
+from project import SOURCE_OVERRIDES, build_info, validate_assets, release_info, application_manifest
validate_assets()
OUT = ROOT / "android/app/src/main/assets"
@@ -46,17 +46,8 @@ 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 = {}
@@ -82,7 +73,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()
@@ -120,7 +111,7 @@ 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-services.js", "gaia-styles.js", "gaia-restoration.js", "gaia-wifi.js", "desktop.css", "platform-ui.js", "gaia-camera.js"]:
+ 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"]:
text = (
(ROOT / "host" / name)
.read_text()
@@ -145,6 +136,7 @@ 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))
diff --git a/assets/webapps/camera.gaiamobile.org/js/main.js b/assets/webapps/camera.gaiamobile.org/js/main.js
index 6ed05434..1d04d37a 100644
--- a/assets/webapps/camera.gaiamobile.org/js/main.js
+++ b/assets/webapps/camera.gaiamobile.org/js/main.js
@@ -5447,6 +5447,9 @@ 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 = {
@@ -5697,6 +5700,7 @@ 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/settings.gaiamobile.org/js/panels/app_permissions_list/panel.js b/assets/webapps/settings.gaiamobile.org/js/panels/app_permissions_list/panel.js
index 86119b44..773df448 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){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;}
+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;}
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.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&&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);}
+define('panels/screen_lock_passcode/panel',['require','modules/settings_utils','modules/dialog_panel','shared/passcode_helper'],function(require){'use strict';var SettingsUtils=require('modules/settings_utils');var DialogPanel=require('modules/dialog_panel');var PasscodeHelper=require('shared/passcode_helper');return function ctor_screenlockPasscode(){var mozSettings=window.navigator.mozSettings;return DialogPanel({_elements:{},_mode:'create',_leftApp:false,_passcodeBuffer:'',_checkingLength:{'create':8,'new':8,'edit':4,'confirm':4,'confirmLock':4},_headerL10nIdMapping:{'create':'new-passcode','edit':'current-passcode','new':'new-passcode','confirm':'enter-passcode','confirmLock':'enter-passcode'},_submitButtonL10nIdMapping:{'new':'change','create':'create','edit':'','confirm':'','confirmLock':''},onInit:function(panel){this._elements.panel=panel;this._elements.gaiaHeader=panel.querySelector('gaia-header');this._elements.header=panel.querySelector('gaia-header h1');this._elements.passcodeInput=panel.querySelector('.passcode-input');this._elements.passcodeDigits=panel.querySelectorAll('.passcode-digit');this._elements.passcodeContainer=panel.querySelector('.passcode-container');this._elements.submitButton=panel.querySelector('button[type="submit"]');this._elements.submitButtonText=this._elements.submitButton.querySelector('span');this._bindEvents();},_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.passcodeContainer.onclick=this._onPasscodeContainerClick.bind(this);},_onPasscodeContainerClick:function(evt){evt.preventDefault();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();
+ }
+ this._passcodeBuffer = value;
+ 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(){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 e56f7a77..7aa6a9b6 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 0a581104..51e131ed 100644
--- a/assets/webapps/wallpaper.gaiamobile.org/style/wallpaper.css
+++ b/assets/webapps/wallpaper.gaiamobile.org/style/wallpaper.css
@@ -70,3 +70,6 @@ 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
index b2ab60b7..96d2f307 100644
--- a/changelog.json
+++ b/changelog.json
@@ -1,239 +1,873 @@
{
"schema": 1,
"preamble": {
- "fr": "# Journal des évolutions! — Vulpes OS\n\n[English](CHANGELOG.en.md)\n\nUne base Gaia, trois plateformes : **PC**, **Android (APK)** et **Tundra / Sargo**.\nCe journal décrit les changements visibles et les étapes techniques importantes.\nLes rapports détaillés restent dans la documentation de chaque plateforme.\n\n**Statuts :** « En développement » concerne les sources locales, « APK construit »\nun paquet de test, et « Publié » une release accessible dans le dépôt. Un correctif\ncommun n’apparaît dans un APK installé ou une image téléphone qu’après leur mise à jour.\n\n
\nParamètres de Gaia · capture de la version pour ordinateur.
",
- "en": "# Changelog — Vulpes OS\n\n[Français](CHANGELOG.md)\n\nOne Gaia base, three platforms: **desktop**, **Android (APK)** and **Tundra / Sargo**.\nThis journal covers visible changes and major technical milestones.\n\n**Status:** “In development” refers to local sources, “APK built” to a test package,\nand “Published” to a public repository release. Shared source changes reach installed\nAPKs and phone images only after those packages are updated.\n\n
\nGaia Settings · screenshot from the desktop version.
"
+ "fr": "# Journal des modifications — Vulpes OS\n\n[English](CHANGELOG.en.md)\n\nPC, Android (APK) et Tundra/Sargo. Les statuts indiquent des sources en développement, un APK construit ou une version publiée. Les changements des sources nécessitent une mise à jour du paquet installé.\n",
+ "en": "# Changelog — Vulpes OS\n\n[Français](CHANGELOG.md)\n\nDesktop, Android (APK) and Tundra/Sargo. Status indicates development sources, a built APK or a published release. Source changes require an update to the installed package.\n"
},
"entries": [
+ {
+ "id": "2.7-preview.20",
+ "text": {
+ "fr": "## 2.7-preview.20 — APK construit · 13 octobre 2026\n\n-  Preview 20 réunit les corrections communes depuis l’APK Preview 17 : paramètres Gaia, apparence, sélecteurs, clavier, contacts, Marketplace et médias.\n-  Photo : enregistrement WebM avec miniature et lecture dans Galerie/Vidéo. Autorisation micro demandée ; commandes adaptées aux capacités du matériel.\n-  Caméra : changement de capteur, arrêt et reprise fiabilisés. PC et Android utilisent leurs API caméra ; Sargo conserve son adaptateur HAL.\n-  Sargo : mises à jour Wi-Fi, Bluetooth, appels, SMS/MMS et réglages réseau incluses dans les sources. Les fonctions non raccordées restent indiquées dans les panneaux.\n-  Changelog : reprise des textes simplifiés FR/EN, badges conservés, filtres PC/APK/Sargo indépendants des titres. Markdown, site et notes de release issus du même JSON.\n-  APK signé avec la clé des Preview précédentes ; alignement et cohérence des 53 fichiers partagés vérifiés. Validation d’exécution Android en cours.\n-  Gecko PC/Sargo 156.0.1 ; GeckoView Android 156.0. Gaia 2.7.0. L’APK délègue appels/SMS à Android : il ne remplace pas le système téléphonique Android.\n-  Preview expérimentale : Compte Mozilla/Sync, localisation distante, extensions Firefox OS et transfert USB restent incomplets. Sargo : aperçu autour de 16 FPS ; son des vidéos et audio téléphonique à qualifier séparément.\n-  Pixel 3a : Preview 20 r62 démarrée en RAM puis flashée sur boot_a. Empreinte relue identique ; démarrage installé, onze contrôles Gaia et dix contrôles natifs réussis. Données conservées, Wi-Fi connecté et enregistrement LTE observé. Pas de nouvelle conversation audio testée.\n",
+ "en": "## 2.7-preview.20 — APK built · 13 October 2026\n\n-  Preview 20 brings together shared fixes since APK Preview 17: Gaia settings, appearance, selectors, keyboard, contacts, Marketplace and media.\n-  Camera: WebM recording with posters and Gallery/Video playback. Microphone permission requested; controls follow hardware capabilities.\n-  Camera: more reliable sensor switching, stopping and resuming. Desktop and Android use their camera APIs; Sargo retains its HAL adapter.\n-  Sargo: Wi-Fi, Bluetooth, calls, SMS/MMS and network settings updates included in the sources. Unimplemented functions remain identified in their panels.\n-  Changelog: simplified French/English text and badges retained; desktop/APK/Sargo filters independent of headings. Markdown, website and release notes generated from one JSON.\n-  APK signed with the previous Preview key; alignment and consistency of 53 shared files verified. Android runtime validation in progress.\n-  Desktop/Sargo Gecko 156.0.1; Android GeckoView 156.0. Gaia 2.7.0. The APK delegates calls/SMS to Android; it does not replace Android telephony.\n-  Experimental Preview: Mozilla account/Sync, remote location, Firefox OS add-ons and USB file transfer remain incomplete. Sargo: preview around 16 fps; recorded sound and call audio require separate qualification.\n-  Pixel 3a: Preview 20 r62 RAM-booted, then flashed to boot_a. Matching readback hash; installed startup, eleven Gaia checks and ten native checks passed. Data retained, Wi-Fi connected and LTE registration observed. No new two-way audio call tested.\n"
+ },
+ "metadata": {
+ "kind": "version",
+ "status": "built",
+ "platforms": [
+ "pc",
+ "apk",
+ "models"
+ ],
+ "models": [
+ "sargo"
+ ]
+ }
+ },
+ {
+ "id": "development-2026-10-12-video-clock",
+ "text": {
+ "fr": "## En développement — 12 octobre 2026 — Vidéo et réglages de l’heure\n\n-  Photo : enregistrement WebM VP8/Opus avec autorisation micro, miniature et stockage. Tampon limité à 32 Mio, arrêt avant libération du capteur.\n-  Photo, Galerie et Vidéo : prise en charge des fichiers WebM et de leurs miniatures.\n-  Paramètres : véritables sélecteurs Date/Heure Gaia, retour du clavier intégré et lettres non tronquées dans les listes.\n-  Améliorer Vulpes : lien vers les tickets du projet. Services non raccordés expliqués dans leurs panneaux.\n-  PC : vidéo simulée 1920 × 1080 relue ; photo, galerie, reprise caméra et navigation vérifiées.\n-  Sargo r61 en RAM : vidéo 480 × 640 relue, huit bascules avant/arrière réussies. Médias de test supprimés ; son enregistré non écouté.\n-  Sargo : aperçu mesuré autour de 16 FPS contre 12 dans le test précédent. 30 FPS restent à atteindre.\n-  Sargo : heure automatique par NITZ, réglage persistant et protection des écritures manuelles. Synchronisation dépendante du réseau ; pas de NTP de secours, fuseau choisi manuellement.\n-  Android : ressources préparées et Java compilé, sans nouvel APK ni test vidéo Android à cette étape. Compte Mozilla/Sync, localisation distante, extensions Firefox OS et transfert USB restent à implémenter. Aucun flash ni publication.\n",
+ "en": "## In development — 12 October 2026 — Video and clock settings\n\n-  Camera: WebM VP8/Opus recording with microphone permission, poster and storage. Buffer limited to 32 MiB; recording stops before releasing the sensor.\n-  Camera, Gallery and Video: handle WebM files and their posters.\n-  Settings: original Gaia date/time pickers, Back from built-in keyboard settings and unclipped select labels.\n-  Improve Vulpes: link to project issues. Unavailable services explained in their panels.\n-  Desktop: simulated 1920 × 1080 video decoded; photo, gallery, camera resume and navigation checked.\n-  Sargo r61 in RAM: 480 × 640 video decoded, eight front/rear switches passed. Test media deleted; recorded audio not listened to.\n-  Sargo: preview measured around 16 fps versus 12 in the previous test. 30 fps is not reached yet.\n-  Sargo: automatic NITZ time, persistent setting and guarded manual writes. Sync depends on carrier time; no NTP fallback, timezone selected manually.\n-  Android: assets staged and Java compiled, without a new APK or Android video test at this stage. Mozilla account/Sync, remote location, Firefox OS add-ons and USB file transfer remain unimplemented. No flashing or publication.\n"
+ },
+ "metadata": {
+ "kind": "development",
+ "status": "development",
+ "platforms": [
+ "pc",
+ "apk",
+ "models"
+ ],
+ "models": [
+ "sargo"
+ ]
+ }
+ },
+ {
+ "id": "development-2026-10-12-settings-calls-hotspot",
+ "text": {
+ "fr": "## En développement — 12 octobre 2026 — Appels et partage Wi-Fi\n\n-  Dialogues Gaia : fermeture après ouverture, transitions interrompues et nouvelle saisie après validation refusée.\n-  Sargo : délai des services opérateur augmenté ; réglages lents séparés des commandes voix/SMS.\n-  Sargo : double appel, présentation du numéro et quatre règles de renvoi raccordés à oFono, avec validation et relecture.\n-  Sargo : point d’accès Wi-Fi WPA2 ou ouvert, configuration NetworkManager et pare-feu adapté. Mot de passe privé, accessible uniquement à Paramètres.\n-  r59 en RAM : 69 panneaux, dialogues et APN vérifiés sur PC/Pixel. Point d’accès enregistré, activé puis arrêté depuis Gaia ; Wi-Fi précédent restauré. 44 tests Python et régressions des dialogues réussis.\n-  Transit Internet vers un autre appareil non testé. Messagerie vocale, blocage d’appels, PIN/PUK, certificats Wi-Fi et partage USB restent ouverts. Ressources Android synchronisées ; aucun nouvel APK, flash ou publication.\n",
+ "en": "## In development — 12 October 2026 — Calls and Wi-Fi sharing\n\n-  Gaia dialogs: close after opening, complete interrupted transitions and allow retry after rejected validation.\n-  Sargo: longer carrier-service timeout; slow settings separated from voice/SMS commands.\n-  Sargo: call waiting, caller ID and four forwarding rules connected to oFono, with validation and readback.\n-  Sargo: WPA2 or open Wi-Fi hotspot, NetworkManager configuration and compatible firewall. Private password, accessible only to Settings.\n-  r59 in RAM: 69 panels, dialogs and APNs checked on desktop/Pixel. Hotspot saved, started and stopped through Gaia; previous Wi-Fi restored. 44 Python tests and dialog regressions passed.\n-  Internet forwarding to another device untested. Voicemail, call barring, PIN/PUK, Wi-Fi certificates and USB tethering remain open. Android assets synchronized; no new APK, flashing or publication.\n"
+ },
+ "metadata": {
+ "kind": "development",
+ "status": "development",
+ "platforms": [
+ "pc",
+ "apk",
+ "models"
+ ],
+ "models": [
+ "sargo"
+ ]
+ }
+ },
+ {
+ "id": "development-2026-10-12-settings-network",
+ "text": {
+ "fr": "## En développement — 12 octobre 2026 — Paramètres réseau\n\n-  Wi-Fi : adresse IPv4 et débit réels ; ne plus afficher « undefined » lorsqu’une mesure manque.\n-  Sargo : choix GSM/UMTS/LTE et modification des APN Internet/MMS existants, avec validation et relecture.\n-  APN : refuser les changements d’un profil actif, protéger IMS et les mots de passe, tenter la restauration après échec et signaler son résultat.\n-  Sargo : file bornée pour les réglages lents. Lecture des appels en 20 ms pendant une attente de réglages de 5 secondes.\n-  38 tests Python, 69 panneaux PC et parcours APN/réseau sur Pixel en RAM vérifiés. Ressources Android synchronisées.\n-  Ajout, suppression et réinitialisation des APN indisponibles. Sélection manuelle d’opérateur et certificats encore ouverts. Aucun nouvel APK, flash ou publication.\n",
+ "en": "## In development — 12 October 2026 — Network settings\n\n-  Wi-Fi: actual IPv4 address and link speed; no “undefined” label when a measurement is missing.\n-  Sargo: GSM/UMTS/LTE selection and editing of existing Internet/MMS APNs, with validation and readback.\n-  APNs: reject changes to active profiles, protect IMS and passwords, attempt rollback after failure and report its result.\n-  Sargo: bounded queue for slow settings. Call status read in 20 ms during a five-second settings wait.\n-  38 Python tests, 69 desktop panels and APN/network workflows on RAM-booted Pixel checked. Android assets synchronized.\n-  APN creation, deletion and reset unavailable. Manual carrier selection and certificates still open. No new APK, flashing or publication.\n"
+ },
+ "metadata": {
+ "kind": "development",
+ "status": "development",
+ "platforms": [
+ "pc",
+ "apk",
+ "models"
+ ],
+ "models": [
+ "sargo"
+ ]
+ }
+ },
+ {
+ "id": "development-2026-10-12-settings-audit",
+ "text": {
+ "fr": "## En développement — 12 octobre 2026 — Audit des paramètres\n\n-  Paramètres : navigation récupérable après échec d’un contrôleur ou d’un dialogue ; génération cohérente des modules.\n-  Filtres d’accessibilité et assistance Vulpes. Panneaux d’origine conservés avec explication des fonctions indisponibles.\n-  PC : statistiques du profil et téléchargements Gecko. Android : statistiques du stockage privé.\n-  Sargo : données mobiles, itinérance, horloge et Wi-Fi masqué raccordés. Erreurs des anciennes interfaces réseau du panneau Son corrigées.\n-  97 vues inventoriées ; 69 panneaux PC/Pixel ouverts et sept dialogues natifs vérifiés. Stockage, données et fuseau relus après écriture sur r51 en RAM.\n-  Les vues contextuelles et services indisponibles ne sont pas tous qualifiés. Java Android compilé, sans nouvel APK. Aucun flash, ticket fermé ni publication.\n",
+ "en": "## In development — 12 October 2026 — Settings audit\n\n-  Settings: recover navigation after a controller or dialog fails; consistent module generation.\n-  Accessibility filters and Vulpes support. Original panels retained with explanations for unavailable features.\n-  Desktop: profile statistics and Gecko downloads. Android: private storage statistics.\n-  Sargo: mobile data, roaming, clock and hidden Wi-Fi connected. Legacy network errors in Sound settings fixed.\n-  97 views inventoried; 69 desktop/Pixel panels opened and seven native dialogs checked. Storage, data and timezone read back after writes on r51 in RAM.\n-  Contextual views and unavailable services are not all qualified. Android Java compiled, without a new APK. No flashing, ticket closure or publication.\n"
+ },
+ "metadata": {
+ "kind": "development",
+ "status": "development",
+ "platforms": [
+ "pc",
+ "apk",
+ "models"
+ ],
+ "models": [
+ "sargo"
+ ]
+ }
+ },
+ {
+ "id": "development-2026-10-12-settings-navigation",
+ "text": {
+ "fr": "## En développement — 12 octobre 2026 — Navigation des paramètres\n\n-  Listes déroulantes : sélecteur Gaia avec confirmation/annulation ; choix Clair/Sombre accessible. Gestionnaire SIM adapté à un emplacement.\n-  Panneaux indisponibles : explication et commandes désactivées au lieu de liens sans effet.\n-  Sargo : réseaux Wi-Fi mémorisés séparés des scans lents ; cache conservé lors des lectures d’état.\n-  Sargo : informations SIM, centre SMS et accusés de réception reliés au modem.\n-  r45 en RAM : huit contrôles d’apparence, cinq panneaux et changement réel des accusés SMS validés, puis réglage restauré. Sélecteur PC vérifié ; ressources APK préparées.\n-  Certificats Enterprise et autres options opérateur encore indisponibles. Aucun SMS émis, nouvel APK, flash ou publication.\n",
+ "en": "## In development — 12 October 2026 — Settings navigation\n\n-  Dropdowns: Gaia selector with confirm/cancel; Light/Dark choice accessible. SIM manager adapted to one slot.\n-  Unavailable panels: explanations and disabled controls instead of unresponsive links.\n-  Sargo: saved Wi-Fi networks separated from slow scans; cache preserved during status reads.\n-  Sargo: SIM information, SMS centre and delivery reports connected to the modem.\n-  r45 in RAM: eight appearance checks, five panels and actual SMS report setting changes passed, then setting restored. Desktop selector checked; APK assets staged.\n-  Enterprise certificates and other carrier options still unavailable. No SMS sent, new APK, flashing or publication.\n"
+ },
+ "metadata": {
+ "kind": "development",
+ "status": "development",
+ "platforms": [
+ "pc",
+ "apk",
+ "models"
+ ],
+ "models": [
+ "sargo"
+ ]
+ }
+ },
+ {
+ "id": "development-2026-10-12-sargo-appearance",
+ "text": {
+ "fr": "## En développement — 12 octobre 2026 — Correctifs communs sur Pixel 3a\n\n-  Sargo : boot r39 en RAM avec correctifs partagés et premier mode sombre. Noyau et configuration matérielle du r35 conservés.\n-  Démarrage, taille d’écran, réglages persistants, batterie, interface d’appel et Fira vérifiés. Services modem, audio, Wi-Fi et Bluetooth actifs.\n-  Sept contrôles d’apparence réussis dans Gaia : thème propagé, en-têtes lisibles, libellés placés et retour au clair. Capture Pixel inspectée.\n-  Outil de mise à jour Gaia dans un boot privé, avec empreintes, sources conservées et vérification des composants matériels inchangés.\n-  Aucun nouvel appel, SMS ou appairage testé. Thème complet et comportement Android encore à qualifier. Aucun nouvel APK, flash ou publication.\n",
+ "en": "## In development — 12 October 2026 — Shared fixes on Pixel 3a\n\n-  Sargo: r39 RAM boot with shared fixes and initial dark mode. r35 kernel and hardware configuration preserved.\n-  Boot, viewport size, persistent settings, battery, call UI and Fira checked. Modem, audio, Wi-Fi and Bluetooth services active.\n-  Seven Gaia appearance checks passed: propagated theme, readable headers, aligned labels and return to light mode. Pixel screenshot inspected.\n-  Tool to update Gaia in a private boot image, with hashes, retained sources and checks for unchanged hardware components.\n-  No new call, SMS or pairing tested. Full theme and Android behavior still need qualification. No new APK, flashing or publication.\n"
+ },
+ "metadata": {
+ "kind": "development",
+ "status": "development",
+ "platforms": [
+ "pc",
+ "apk",
+ "models"
+ ],
+ "models": [
+ "sargo"
+ ]
+ }
+ },
+ {
+ "id": "development-2026-10-11-appearance-input",
+ "text": {
+ "fr": "## En développement — 11 octobre 2026 — Apparence et clavier partagé\n\n-  Apparence : Clair, Sombre ou Selon le système, partagé entre les applications et conservé au redémarrage. Fonds d’écran, photos et sites externes préservés.\n-  Clavier : opérations d’édition mutualisées, sélection et effacement, protection contre les réponses tardives.\n-  PC : neuf contrôles d’apparence et gestes de saisie dans Messages réussis, sans envoi de SMS. Revue visuelle de Paramètres, Messages, Téléphone et Contacts.\n-  Android : option expérimentale de clavier Gaia ; clavier Android conservé par défaut et mode de saisie restauré à la fermeture.\n-  Android : réessayer brièvement le même capteur encore occupé, sans réessayer après refus de permission ou fermeture de Photo.\n-  Preview 19 construit localement : signature, alignement, parité et tests du pont/caméra réussis.\n-  Clavier Gaia, caméra avant Android et thème complet encore à qualifier. Sources prêtes pour Sargo, resté sur r35 à cette étape. Aucun upload, flash ni ticket fermé.\n",
+ "en": "## In development — 11 October 2026 — Appearance and shared keyboard\n\n-  Appearance: Light, Dark or Follow system, shared between apps and retained across restarts. Wallpapers, photos and external websites preserved.\n-  Keyboard: shared editing operations, selection and deletion, protection against late replies.\n-  Desktop: nine appearance checks and Messages input gestures passed, without sending SMS. Visual review of Settings, Messages, Phone and Contacts.\n-  Android: experimental Gaia keyboard option; Android keyboard remains the default, input mode restored on close.\n-  Android: briefly retry the same busy sensor, without retrying after permission denial or closing Camera.\n-  Preview 19 built locally: signature, alignment, parity and bridge/camera tests passed.\n-  Gaia keyboard, Android front camera and full theme still need qualification. Sources ready for Sargo, which remained on r35 at this stage. No upload, flashing or ticket closure.\n"
+ },
+ "metadata": {
+ "kind": "development",
+ "status": "development",
+ "platforms": [
+ "pc",
+ "apk",
+ "models"
+ ],
+ "models": [
+ "sargo"
+ ]
+ }
+ },
+ {
+ "id": "development-2026-10-11-settings",
+ "text": {
+ "fr": "## En développement — 11 octobre 2026 — Paramètres et accueil\n\n-  Paramètres : gestion des sonneries, saisie/effacement du code PIN, désactivation du verrouillage et liste des permissions des applications tierces.\n-  Accueil : première initialisation du second lanceur et aperçus des fonds d’écran.\n-  Langues : actualiser les libellés Vulpes au changement de langue ; anglais de secours hors français. Fermer le panneau d’échec caméra et les bandeaux d’installation.\n-  Android : ouvrir le volet Gaia en touchant la barre de statut. Preview 18 construit, signé et comparé aux sources.\n-  PC : neuf parcours Gaia, cinq contrôles de bandeaux et douze tests activités/caméra réussis. Bluetooth Sargo r35 confirmé par l’utilisateur.\n-  Redémarrage à chaud du compositeur encore instable : fichiers restaurés et retour à r35. Corrections non qualifiées sur Sargo à cette étape ; clavier/thème Android ouverts. Aucun APK publié ni flash.\n",
+ "en": "## In development — 11 October 2026 — Settings and homescreens\n\n-  Settings: ringtone management, PIN entry/deletion, lock disabling and third-party app permission list.\n-  Home: first initialization of the second launcher and wallpaper previews.\n-  Languages: refresh Vulpes labels after language changes; English fallback outside French. Dismiss camera failure panels and installation banners.\n-  Android: open the Gaia tray by tapping the status bar. Preview 18 built, signed and compared with sources.\n-  Desktop: nine Gaia workflows, five banner checks and twelve activity/camera tests passed. Sargo r35 Bluetooth confirmed by the user.\n-  Compositor hot restart still unstable: files restored and return to r35. Fixes not yet qualified on Sargo at this stage; Android keyboard/theme open. No APK publication or flashing.\n"
+ },
+ "metadata": {
+ "kind": "development",
+ "status": "development",
+ "platforms": [
+ "pc",
+ "apk",
+ "models"
+ ],
+ "models": [
+ "sargo"
+ ]
+ }
+ },
+ {
+ "id": "development-2026-10-09-mms-bluetooth",
+ "text": {
+ "fr": "## En développement — 9 octobre 2026 — MMS et Bluetooth\n\n-  Bluetooth : adaptateur Gaia partagé, commandes matérielles séparées et accès limité aux applications autorisées.\n-  Sargo : activation, découverte, appairage explicite, connexion et suppression depuis le panneau Bluetooth Gaia. Modules audio Bluetooth intégrés.\n-  Sargo : services réseau Qualcomm et transport MMS raccordés. Une déconnexion D-Bus ne termine plus le daemon MMS. État persistant, reprise sans doublon ni renvoi automatique incertain.\n-  r35 en RAM : MMS avec image visible et accepté par le MMSC Orange ; réception destinataire à confirmer. Cinq appareils Bluetooth détectés, A2DP vers PC validé.\n-  191 tests Python Tundra et 18 tests JavaScript réussis. Ramdisk allégé et ressources Android synchronisées.\n-  Audio d’appel via casque non qualifié. Aucun nouvel APK, flash ni push à cette étape.\n",
+ "en": "## In development — 9 October 2026 — MMS and Bluetooth\n\n-  Bluetooth: shared Gaia adapter, separate hardware commands and access restricted to authorized apps.\n-  Sargo: power, discovery, explicit pairing, connection and removal from Gaia Bluetooth settings. Bluetooth audio modules included.\n-  Sargo: Qualcomm network services and MMS transport connected. D-Bus disconnect no longer terminates the MMS daemon. Persistent state, retry without duplicates or uncertain automatic resends.\n-  r35 in RAM: image MMS visible and accepted by Orange MMSC; recipient delivery still unconfirmed. Five Bluetooth devices discovered, A2DP to desktop verified.\n-  191 Tundra Python tests and 18 JavaScript tests passed. Smaller ramdisk and synchronized Android assets.\n-  Headset call audio unqualified. No new APK, flashing or push at this stage.\n"
+ },
+ "metadata": {
+ "kind": "development",
+ "status": "development",
+ "platforms": [
+ "pc",
+ "apk",
+ "models"
+ ],
+ "models": [
+ "sargo"
+ ]
+ }
+ },
+ {
+ "id": "development-2026-10-08-camera-mms",
+ "text": {
+ "fr": "## En développement — 8 octobre 2026 — Caméra et transport MMS\n\n-  Caméra : arrêter le flux avant de détacher la vidéo ; attendre la fermeture du capteur, réinitialiser le viseur et recalculer sa taille après rotation.\n-  MMS : préserver les pièces jointes Blob et garder les messages en attente jusqu’à confirmation du transport.\n-  Sargo : viseur WebGL réutilisé sans recapture vidéo intermédiaire ; écran maintenu éveillé pendant l’aperçu.\n-  PC et Sargo : parcours Photo/Galerie et huit bascules natives vérifiés. JPEG, focus et zoom fonctionnels ; arrière ~16 FPS, avant ~25–27 FPS dans cet essai.\n-  Sargo : file MMS persistante, codec Nuntium et transport oFono. Interface texte/photo testée avec transport simulé.\n-  Données mobiles bloquantes pour les MMS opérateur à cette étape. Ressources Android synchronisées ; aucun nouvel APK, flash ou push.\n",
+ "en": "## In development — 8 October 2026 — Camera and MMS transport\n\n-  Camera: stop the stream before detaching video; await sensor closure, reset the viewfinder and recalculate size after rotation.\n-  MMS: preserve Blob attachments and keep messages pending until transport confirmation.\n-  Sargo: reused WebGL viewfinder without intermediate video capture; screen stays awake during preview.\n-  Desktop and Sargo: Camera/Gallery workflows and eight native switches checked. JPEG, focus and zoom working; rear ~16 fps, front ~25–27 fps in this test.\n-  Sargo: persistent MMS queue, Nuntium codec and oFono transport. Text/photo UI tested with simulated transport.\n-  Mobile data blocked carrier MMS at this stage. Android assets synchronized; no new APK, flashing or push.\n"
+ },
+ "metadata": {
+ "kind": "development",
+ "status": "development",
+ "platforms": [
+ "pc",
+ "apk",
+ "models"
+ ],
+ "models": [
+ "sargo"
+ ]
+ }
+ },
+ {
+ "id": "development-2026-10-08-call-log-dialogs",
+ "text": {
+ "fr": "## 8 octobre 2026 — Rappel et messages d’erreur\n\n-  Dialogues Gaia : texte visible au lieu d’un écran gris avec un bouton seul.\n-  Journal d’appel : rappel via le même service que le pavé numérique, sans anciennes API Gecko. Fichier partagé PC/Android/Sargo.\n-  MMS : signaler clairement l’envoi indisponible, garder texte et pièces jointes, ne plus annoncer un faux mode avion.\n-  PC : dialogues FR/EN et composeur préservé. Sargo en RAM : 11 contrôles Gaia réussis, rappel testé avec modem simulé.\n-  Aucun appel réel ni validation audio dans cette étape. Ressources Android synchronisées, aucun nouvel APK publié.\n",
+ "en": "## 8 October 2026 — Call-log redial and error dialogs\n\n-  Gaia dialogs: visible text instead of a grey screen with a lone button.\n-  Call log: redial through the same service as the keypad, without legacy Gecko APIs. Shared desktop/Android/Sargo file.\n-  MMS: clearly report unavailable sending, retain text and attachments, no false airplane-mode message.\n-  Desktop: FR/EN dialogs and preserved composer. RAM-booted Sargo: 11 Gaia checks passed, redial tested with a simulated modem.\n-  No real call or audio validation at this stage. Android assets synchronized, no new APK published.\n"
+ },
+ "metadata": {
+ "kind": "history",
+ "status": "history",
+ "platforms": [
+ "pc",
+ "apk",
+ "models"
+ ],
+ "models": [
+ "sargo"
+ ]
+ }
+ },
+ {
+ "id": "development-2026-10-08-sms-audio",
+ "text": {
+ "fr": "## En développement — 8 octobre 2026 — SMS et audio\n\n-  SMS : envoyer en un appui en conservant le clavier. Trombone raccordé au menu Gaia : image, audio, vidéo ou contact.\n-  Interface : indicateur de volume visible, gestes du verrouillage et balayage des notifications corrigés.\n-  PC : clavier, pièces jointes, Galerie, interface d’appel et volume vérifiés ; notification balayée sans déplacer la page. Aucun nouvel APK publié.\n-  Sargo : accès à PulseAudio, relais Qualcomm, protocole D-Bus et périphériques de calibration rétablis. Service audio Android concurrent retiré.\n-  Sargo : 11 contrôles Gaia, calibration et prévisualisations arrière/avant/arrière réussis.\n-  Appels échouant avant sonnerie avec une erreur réseau dans cet essai. Audio bidirectionnel, geste Pixel et MMS encore non validés. Aucun flash ni publication.\n",
+ "en": "## In development — 8 October 2026 — SMS and audio\n\n-  SMS: send in one tap while keeping the keyboard. Paperclip connected to the Gaia image, audio, video or contact menu.\n-  UI: visible volume indicator, lockscreen gestures and notification swiping fixed.\n-  Desktop: keyboard, attachments, Gallery, call UI and volume checked; notification swiped without moving the page. No new APK published.\n-  Sargo: PulseAudio access, Qualcomm relay, D-Bus protocol and calibration devices restored. Conflicting Android audio service removed.\n-  Sargo: 11 Gaia checks, calibration and rear/front/rear previews passed.\n-  Calls failed before ringing with a network error in this test. Two-way audio, Pixel gesture and MMS still unverified. No flashing or publication.\n"
+ },
+ "metadata": {
+ "kind": "development",
+ "status": "development",
+ "platforms": [
+ "pc",
+ "apk",
+ "models"
+ ],
+ "models": [
+ "sargo"
+ ]
+ }
+ },
+ {
+ "id": "development-2026-10-08-radio-reliability",
+ "text": {
+ "fr": "## En développement — 8 octobre 2026 — Fiabilité téléphonie\n\n-  Sargo : service radio maintenu après fermeture d’un client. Jamais de nouvel appel ou SMS automatique après une réponse perdue. Erreurs sans numéro ni texte du message.\n-  Diagnostic réseau/audio sans identifiants personnels et paramètres opérateur épinglés dans l’image native.\n-  SMS : attendre le vrai gestionnaire Gaia et accepter les identifiants oFono avec majuscules. Contrôle des conversations rétabli sans réémission.\n-  Sargo : LTE retrouvé après redémarrage, SMS confirmé envoyé par le modem, microphone non silencieux et lecture audio acceptée.\n-  Réception destinataire, audio d’appel bidirectionnel et stabilité modem restent à qualifier. Compositeur instable au redémarrage à chaud. Aucun nouvel APK, flash ou push.\n",
+ "en": "## In development — 8 October 2026 — Telephony reliability\n\n-  Sargo: radio service survives client closure. No automatic new call or SMS after a lost response. Errors exclude phone numbers and message text.\n-  Network/audio diagnostics without personal identifiers, and pinned carrier settings in the native image.\n-  SMS: wait for the actual Gaia manager and accept uppercase oFono IDs. Conversation checks restored without resending.\n-  Sargo: LTE restored after reboot, SMS confirmed sent by the modem, non-silent microphone and audio playback accepted.\n-  Recipient delivery, two-way call audio and modem stability remain unqualified. Compositor hot restart unstable. No new APK, flashing or push.\n"
+ },
+ "metadata": {
+ "kind": "development",
+ "status": "development",
+ "platforms": [
+ "pc",
+ "apk",
+ "models"
+ ],
+ "models": [
+ "sargo"
+ ]
+ }
+ },
{
"id": "2026-10-08-source-publication",
"text": {
- "fr": "## 8 octobre 2026 — Mise à jour des sources publiques\n\n### Commun · PC / Android / Tundra\n\n- Regrouper les correctifs récents de caméra, Contacts, SMS, Marketplace et les outils de construction et de restauration Sargo dans les sources publiques. Mettre à jour les README français et anglais avec les capacités et limites vérifiées.\n- Corriger le contrôle de parité Android pour créer son dossier de rapports sur une copie neuve du dépôt. Vérifier que la configuration Wi-Fi native embarque un répertoire de connexions vide.\n- Conserver les tickets, feuilles de route, documents internes, données personnelles et clés hors du dépôt.\n\n### Paquets et qualification\n\n- L’APK public reste Preview17 : les correctifs du 8 octobre nécessitent une prochaine compilation. Aucune nouvelle image native publiée ni flash dans cette étape.\n- Sur Sargo, l’enregistrement cellulaire reste en recherche lors du dernier contrôle ; audio bidirectionnel, appairage Bluetooth et profils restent à qualifier.",
- "en": "## 8 October 2026 — Public source update\n\n### Shared · Desktop / Android / Tundra\n\n- Bring recent camera, Contacts, SMS and Marketplace fixes, plus Sargo build and recovery tools, into the public sources. Update both READMEs with verified capabilities and limitations.\n- Make the Android parity check create its report directory in a fresh checkout. Verify that native Wi-Fi configuration ships an empty connections directory.\n- Keep tickets, roadmaps, internal documents, personal data and keys outside the repository.\n\n### Packages and qualification\n\n- The public APK remains Preview17; the 8 October fixes require a future build. No new native image or flash in this step.\n- At the latest Sargo check, cellular registration is still searching; two-way call audio, Bluetooth pairing and profiles remain to be qualified."
+ "fr": "## 8 octobre 2026 — Mise à jour des sources publiques\n\n-  Publication des correctifs Caméra, Contacts, SMS et Marketplace, et des outils Sargo de construction et de restauration.\n-  README français et anglais actualisés avec les fonctions et limites vérifiées.\n-  Création du dossier de rapports lors du contrôle de parité Android sur un dépôt neuf.\n-  Configuration Wi-Fi native vérifiée : aucun profil de connexion embarqué.\n-  Documents internes, données personnelles et clés exclus du dépôt.\n-  APK public inchangé (Preview17) ; correctifs du 8 octobre non inclus. Aucune nouvelle image native publiée.\n-  Sargo : modem encore en recherche réseau ; audio des appels, appairage et profils Bluetooth à valider.\n",
+ "en": "## 8 October 2026 — Public source update\n\n-  Published Camera, Contacts, SMS and Marketplace fixes, plus Sargo build and recovery tools.\n-  Updated French and English READMEs with verified features and limitations.\n-  Create the report directory when checking Android asset parity in a fresh checkout.\n-  Checked native Wi-Fi configuration: no bundled connection profiles.\n-  Internal documents, personal data and keys excluded from the repository.\n-  Public APK unchanged (Preview17); October 8 fixes not included. No new native image published.\n-  Sargo: modem still searching for a network; call audio, Bluetooth pairing and profiles await validation.\n"
+ },
+ "metadata": {
+ "kind": "history",
+ "status": "history",
+ "platforms": [
+ "pc",
+ "apk",
+ "models"
+ ],
+ "models": [
+ "sargo"
+ ]
}
},
{
"id": "development-2026-10-08-camera-lifecycle",
"text": {
- "fr": "## 8 octobre 2026 — Caméra et parcours Contacts · en développement\n\n### Commun · Gaia / PC / Android / Tundra\n\n- Sérialiser les ouvertures de caméra, attendre la fermeture du capteur précédent et libérer les pistes et ressources vidéo même après un échec. Stabiliser l’identification avant/arrière sur PC et Android.\n- Conserver les images binaires lors des échanges PC/Tundra ; encoder explicitement les photos et dates des contacts pour le pont JSON Android.\n- Corriger l’actualisation de la fiche après modification d’un contact. Rétablir les activités Contacts → SMS, sélection d’un numéro et ouverture du pavé numérique, en préservant les demandes imbriquées et les points d’entrée de Gaia.\n- Réouvrir Galerie en mode sélection pour ajouter une photo à un contact, même lorsque Galerie est déjà ouverte en consultation. Sélection, recadrage et enregistrement de la photo validés sur PC.\n- Ajouter des tests de caméra, de stockage des contacts, de permissions, de transport binaire et de routage des activités.\n\n### PC\n\n- Vérifier le parcours original Appareil photo → capture JPEG → Galerie → ouverture de la photo → retour caméra.\n- Vérifier dans Gaia : création et modification d’un contact, actualisation de la fiche, recherche sans accents et sans résultats, favoris, destinataire SMS, sélection d’un numéro, pavé numérique prérempli, annulation puis confirmation de suppression. Le test n’émet ni appel ni SMS et supprime son contact temporaire.\n\n### Tundra · Sargo\n\n- Vérifier en RAM sur Pixel 3a : autofocus, zoom, quatre alternances avant/arrière avec nouvelles trames, capture JPEG depuis Gaia, stockage, ouverture dans Galerie et arrêt du capteur à l’accueil.\n- Vérifier les conversations déjà stockées, le clavier et les notifications. Recherche Wi-Fi : quatre réseaux détectés. Nouvelle émission/réception SMS et audio des appels encore en qualification.\n- Ajouter les paquets BlueZ et les bibliothèques nécessaires, verrouillés par SHA256. Le contrôleur Bluetooth démarre, s’active et lance une recherche ; l’appairage depuis Gaia et les profils ne sont pas encore validés.\n- Ajouter le magasin de certificats natif : HTTPS vérifié sans désactivation de TLS. Tester les archives altérées, collisions de fichiers et reconstructions répétées.\n\n- Corriger les fichiers Contacts embarqués dans le build de démarrage : la création, l’édition, le passage vers SMS, le sélecteur et le pavé numérique passent aussi avec les gestes tactiles sur Sargo.\n\n### Android · APK\n\n- Publier l’APK Preview17 existant, avec téléchargement et SHA256 vérifiés. Il précède les correctifs du 8 octobre.\n- Corriger la transmission des destinataires et du texte de Gaia vers l’application SMS Android, y compris la réponse à une conversation et le numéro saisi sans validation préalable. Tests du pont réussis ; essai Android réel encore nécessaire.\n- Réempaqueter les ressources communes corrigées pour la prochaine compilation ; aucun nouvel APK incluant ces correctifs n’est publié.\n\n**État :** builds natifs de qualification démarrés en RAM. Aucun flash, push de code ni publication de documentation privée dans cette étape. La qualification complète des fonctions de téléphone reste ouverte.\n",
- "en": "## 8 October 2026 — Camera and Contacts workflows · in development\n\n### Shared · Gaia / Desktop / Android / Tundra\n\n- Serialize camera opening, await the previous sensor shutdown and release tracks and video resources after failures. Keep stable front/rear identities on desktop and Android.\n- Preserve binary images across desktop/Tundra transport; explicitly encode contact photos and dates for Android's JSON bridge.\n- Refresh contact details after editing. Restore Contacts → SMS, number picking and prefilled dialer activities, preserving nested requests and Gaia entry points.\n- Reopen Gallery in picker mode when adding a contact photo, even when Gallery is already open for browsing. Selection, cropping and photo persistence verified on desktop.\n- Add camera lifecycle, contact persistence, permissions, binary transport and activity routing tests.\n\n### Desktop\n\n- Verify the original Camera → JPEG capture → Gallery → photo opening → camera workflow.\n- Verify Gaia contact creation, editing, detail refresh, accent-insensitive search and empty results, favourites, SMS recipient, number picker, prefilled keypad, cancelled deletion and confirmed deletion. The test sends no message and makes no call; its temporary contact is removed.\n\n### Tundra · Sargo\n\n- Verify on a RAM-booted Pixel 3a: autofocus, zoom, four front/rear switches with fresh frames, shutter capture, JPEG persistence, Gallery opening and sensor shutdown on Home.\n- Check stored conversations, keyboard and notifications. Wi-Fi scan finds four networks. New SMS transmission/reception and call audio are still being qualified.\n- Add SHA256-pinned BlueZ packages and required libraries. The controller starts, powers on and discovers; pairing through Gaia and Bluetooth profiles are not yet validated.\n- Add native trusted certificates and verify HTTPS without disabling TLS. Test corrupted archives, file collisions and repeatable staging.\n\n- Include the corrected Contacts files in the boot build: contact creation, editing, SMS handoff, picking and dialer prefilling also pass using touch gestures on Sargo.\n\n### Android · APK\n\n- Publish the existing Preview17 APK and verify its download and SHA256. It predates the 8 October fixes.\n- Fix recipient and body handoff from Gaia to the Android SMS app, including replies and a typed recipient that was not yet committed. Bridge tests pass; real Android validation is still required.\n- Repackage corrected shared resources for the next build; no APK containing these new fixes has been published.\n\n**Status:** native qualification builds booted in RAM. No flashing, source push or private documentation publication in this step. Full phone qualification remains open.\n"
+ "fr": "## 8 octobre 2026 — Caméra et parcours Contacts · en développement\n\n-  Caméra : ouvertures successives, libération du capteur et identification avant/arrière corrigées.\n-  Contacts : transport des photos et des dates corrigé sur PC, Android et Tundra.\n-  Contacts : actualisation après modification, ouverture de SMS, sélection d’un numéro et préremplissage du pavé numérique rétablis.\n-  Galerie : ouverture en mode sélection pour la photo d’un contact, même si l’application est déjà ouverte.\n-  Sargo : fichiers Contacts corrigés inclus dans l’image de démarrage.\n-  Android : transmission du destinataire et du texte à l’application SMS, y compris les réponses et les numéros non encore validés.\n-  Tests de caméra, stockage des contacts, permissions, transport binaire et activités.\n-  PC : capture JPEG, Galerie, photo de contact, création, édition, recherche, favoris et suppression vérifiés. Aucun appel ni SMS émis.\n-  Sargo en RAM : autofocus, zoom, quatre bascules de caméra, capture et Galerie vérifiés ; parcours Contacts testés au toucher.\n-  Sargo : conversations stockées, clavier et notifications vérifiés ; quatre réseaux Wi-Fi détectés. Nouveaux SMS et audio d’appel à valider.\n-  Sargo : BlueZ et ses dépendances avec empreintes SHA256. Activation et recherche Bluetooth testées ; appairage et profils à valider.\n-  Sargo : magasin de certificats natif. HTTPS, archives altérées, collisions de fichiers et reconstructions testés.\n-  Preview17 publié avec téléchargement et SHA256 vérifiés. Les correctifs ci-dessus restent dans les sources ; essai SMS Android réel nécessaire. Aucun flash Sargo.\n",
+ "en": "## 8 October 2026 — Camera and Contacts workflows · in development\n\n-  Camera: corrected sequential opening, sensor cleanup and front/rear identification.\n-  Contacts: corrected photo and date transport on desktop, Android and Tundra.\n-  Contacts: restored refresh after editing, SMS handoff, number picking and dialer prefilling.\n-  Gallery: open the contact photo picker even when the app is already running.\n-  Sargo: include corrected Contacts files in the boot image.\n-  Android: pass recipients and text to the SMS app, including replies and unconfirmed typed numbers.\n-  Tests for camera lifecycle, contact storage, permissions, binary transport and activities.\n-  Desktop: verified JPEG capture, Gallery, contact photos, creation, editing, search, favourites and deletion. No calls or SMS sent.\n-  Sargo in RAM: verified autofocus, zoom, four camera switches, capture and Gallery; tested Contacts with touch input.\n-  Sargo: checked stored conversations, keyboard and notifications; detected four Wi-Fi networks. New SMS and call audio await validation.\n-  Sargo: BlueZ and dependencies pinned by SHA256. Power-on and discovery tested; pairing and profiles await validation.\n-  Sargo: native certificate store. Tested HTTPS, corrupted archives, file collisions and repeated builds.\n-  Published Preview17 with download and SHA256 checks. These fixes remain in source; Android SMS needs a real-device test. No Sargo flash.\n"
+ },
+ "metadata": {
+ "kind": "history",
+ "status": "history",
+ "platforms": [
+ "pc",
+ "apk",
+ "models"
+ ],
+ "models": [
+ "sargo"
+ ]
}
},
{
"id": "development-2026-10-06-marketplace-theme",
"text": {
- "fr": "## 6 octobre 2026 — Thème Marketplace intégré · en développement\n\n### Commun · Gaia / PC / Android / Tundra\n\n- Ouvrir le catalogue avec `mode=app`, conservé dans les liens, la recherche, les filtres, les fiches et le changement de langue.\n- Préparer un thème web Vulpes dédié : Fira locale, navigation compacte, boutons tactiles, cartes lisibles, filtres repliables et captures défilantes. Retirer les en-têtes du site dans le mode intégré, tout en conservant l’interface du site classique.\n- Conserver les confirmations et messages système côté Gaia. Le thème ne donne aucun privilège supplémentaire aux pages du catalogue.\n- Vérifier 16 pages FR/EN, 2 pages en mode classique et les ressources du thème. Rendus mobiles de 320 et 360 pixels inspectés. Lanceurs PC et Sargo mis à jour ; les sources seront reprises au prochain empaquetage Android.\n\nFichiers du site prêts localement, non déployés sur le serveur public. Aucun push ni nouvelle publication APK/ROM.\n",
- "en": "## 6 October 2026 — Embedded Marketplace theme · in development\n\n### Shared · Gaia / Desktop / Android / Tundra\n\n- Open the catalogue with `mode=app`, retained in navigation, search, filters, app details and language switches.\n- Prepare a dedicated Vulpes web theme with local Fira fonts, compact navigation, touch-friendly controls, readable cards, collapsible filters and scrolling screenshots. Omit website headers in embedded mode while preserving the standard website layout.\n- Keep confirmations and system messages in Gaia. The theme grants catalogue pages no additional privileges.\n- Check 16 French/English pages, 2 standard pages and theme assets. Inspect mobile renders at 320 and 360 pixels. Desktop and Sargo launchers updated; shared sources will be included in the next Android packaging run.\n\nWebsite files prepared locally, not deployed to the public server. No push or new APK/ROM publication.\n"
+ "fr": "## 6 octobre 2026 — Thème Marketplace intégré · en développement\n\n-  Marketplace : thème intégré avec Fira locale, navigation compacte, boutons tactiles, filtres repliables et captures défilantes.\n-  Conservation de `mode=app` dans la navigation, la recherche, les filtres et le changement de langue.\n-  En-têtes du site masqués en mode intégré. Confirmations gérées par Gaia, sans privilèges supplémentaires pour le catalogue.\n-  16 pages FR/EN, 2 pages classiques et affichages mobiles à 320 et 360 pixels vérifiés.\n-  Thème prêt localement, non déployé. Lanceurs PC et Sargo mis à jour ; intégration Android au prochain APK.\n",
+ "en": "## 6 October 2026 — Embedded Marketplace theme · in development\n\n-  Marketplace: embedded theme with local Fira fonts, compact navigation, touch controls, collapsible filters and scrolling screenshots.\n-  Preserve `mode=app` across navigation, search, filters and language changes.\n-  Hide website headers in embedded mode. Gaia handles confirmations; the catalogue receives no extra privileges.\n-  Checked 16 French/English pages, 2 standard pages and mobile layouts at 320 and 360 pixels.\n-  Theme ready locally, not deployed. Desktop and Sargo launchers updated; Android integration pending the next APK.\n"
+ },
+ "metadata": {
+ "kind": "history",
+ "status": "history",
+ "platforms": [
+ "pc",
+ "apk",
+ "models"
+ ],
+ "models": [
+ "sargo"
+ ]
}
},
{
"id": "development-2026-10-06-system-banner",
"text": {
- "fr": "## 6 octobre 2026 — Bandeaux Gaia · en développement\n\n### Commun · Gaia / PC / Android / Tundra\n\n- Faire disparaître le bandeau « Application installée » après son délai normal, même si la fin d’animation ne remonte pas du composant Gaia. Conserver son apparence et permettre sa fermeture au toucher.\n- Annuler les anciens délais lorsqu’un nouveau message remplace le précédent pour éviter une disparition prématurée.\n- Vérifier sur PC l’affichage, l’expiration, le remplacement et la fermeture au clic ; vérifier sur le Pixel le retrait effectif du bandeau après cinq secondes. Correctif déployé sur l’essai Sargo en RAM et disponible dans les sources communes pour le prochain APK.\n\nAucun flash ni publication dans cette étape.\n",
- "en": "## 6 October 2026 — Gaia banners · in development\n\n### Shared · Gaia / Desktop / Android / Tundra\n\n- Dismiss the “Application installed” banner after its normal timeout even if the animation-end event does not reach the Gaia component host. Keep its appearance and tap-to-dismiss behavior.\n- Cancel old timers when a new message replaces the previous one, preventing premature dismissal.\n- Check display, expiry, replacement and click dismissal on desktop; verify actual removal after five seconds on the Pixel. Fix deployed to the Sargo RAM trial and available in shared sources for the next APK.\n\nNo flash or publication in this step.\n"
+ "fr": "## 6 octobre 2026 — Bandeaux Gaia · en développement\n\n-  Fermeture automatique du bandeau « Application installée », même sans événement de fin d’animation.\n-  Annulation des anciens délais lorsqu’un nouveau bandeau remplace le précédent.\n-  PC : affichage, expiration, remplacement et fermeture au clic vérifiés. Sargo : disparition après cinq secondes vérifiée en RAM.\n-  Correctif disponible dans les sources communes pour le prochain APK. Aucun flash ni publication.\n",
+ "en": "## 6 October 2026 — Gaia banners · in development\n\n-  Automatically dismiss the “Application installed” banner even without an animation-end event.\n-  Cancel old timers when a new banner replaces the previous one.\n-  Desktop: checked display, expiry, replacement and click dismissal. Sargo: verified dismissal after five seconds in RAM.\n-  Fix available in shared sources for the next APK. No flash or release.\n"
+ },
+ "metadata": {
+ "kind": "history",
+ "status": "history",
+ "platforms": [
+ "pc",
+ "apk",
+ "models"
+ ],
+ "models": [
+ "sargo"
+ ]
}
},
{
"id": "development-2026-10-06-wifi",
"text": {
- "fr": "## 6 octobre 2026 — Wi-Fi et panneaux Paramètres · en développement\n\n### Commun · Gaia / PC / Android / Tundra\n\n- Terminer l’ouverture d’un panneau Paramètres même si Gecko ne produit pas l’événement de fin d’animation. Conserver les transitions Gaia et éviter les doubles initialisations.\n- Correctif dans les sources communes et le serveur PC ; inclus au prochain empaquetage Android. Aucun nouvel APK publié dans cette étape.\n\n### Tundra · Sargo\n\n- Corriger les événements de signal Wi-Fi : transmettre le réseau associé et ne pas annoncer de signal de connexion lorsque le téléphone est déconnecté. L’événement incomplet faisait échouer l’affichage des réseaux dans Gaia.\n- Sur le Pixel, vérifier le pilote, le service natif, le passage par Gecko et l’affichage du panneau corrigé : quatre points d’accès détectés, regroupés en deux réseaux, sans erreur de recherche.\n- Connexion Wi-Fi confirmée par l’utilisateur ; requête HTTPS réelle réussie depuis Gecko vers example.org (HTTP 200, vérification des certificats active). Correctifs appliqués à l’essai en RAM ; aucun flash ni push.\n",
- "en": "## 6 October 2026 — Wi-Fi and Settings panels · in development\n\n### Shared · Gaia / Desktop / Android / Tundra\n\n- Complete Settings panel navigation even when Gecko does not emit an animation-end event. Preserve Gaia transitions and prevent duplicate initialization.\n- Updated shared sources and the desktop server; included in the next Android packaging run. No new APK published in this step.\n\n### Tundra · Sargo\n\n- Fix Wi-Fi signal events: include the associated network and do not emit connection information while disconnected. The incomplete event prevented Gaia from displaying scan results.\n- Check the driver, native service, Gecko bridge and corrected panel on the Pixel: four access points detected, grouped into two networks, with no scan error.\n- Wi-Fi connection confirmed by the user; a real HTTPS request from Gecko to example.org passed (HTTP 200, certificate verification enabled). Fixes applied to the RAM trial; no flash or Git push.\n"
+ "fr": "## 6 octobre 2026 — Wi-Fi et panneaux Paramètres · en développement\n\n-  Paramètres : ouverture des panneaux même sans événement de fin d’animation, sans double initialisation.\n-  Sargo : événements Wi-Fi corrigés pour rétablir l’affichage des réseaux et signaler correctement la déconnexion.\n-  Sargo : quatre points d’accès détectés, regroupés en deux réseaux. Connexion confirmée et requête HTTPS réussie avec certificats vérifiés.\n-  Sources communes, serveur PC et essai Sargo en RAM mis à jour. Correctifs non inclus dans l’APK public.\n",
+ "en": "## 6 October 2026 — Wi-Fi and Settings panels · in development\n\n-  Settings: open panels even without an animation-end event, without duplicate initialization.\n-  Sargo: corrected Wi-Fi events to restore network results and report disconnection properly.\n-  Sargo: detected four access points grouped into two networks. Connection confirmed and HTTPS request passed with certificate verification.\n-  Shared sources, desktop server and Sargo RAM trial updated. Fixes not included in the public APK.\n"
+ },
+ "metadata": {
+ "kind": "history",
+ "status": "history",
+ "platforms": [
+ "pc",
+ "apk",
+ "models"
+ ],
+ "models": [
+ "sargo"
+ ]
}
},
{
"id": "development-2026-10-06-marketplace-views",
"text": {
- "fr": "## 6 octobre 2026 — Marketplace · en développement\n\n### PC / Tundra · Sargo\n\n- Maintenir le catalogue sous la barre de statut et dans les limites de son application. Masquer sa vue web à l’accueil et dans le multitâche ; la retirer à la fermeture de l’application.\n- Éviter qu’une navigation annulée ou l’ancienne page d’erreur masque une nouvelle tentative.\n- Afficher un message local et proposer une nouvelle tentative lorsque le catalogue est inaccessible, au lieu de laisser sa page d’erreur recouvrir Gaia.\n- Sur PC : navigation, erreur réseau, nouvelle tentative, retour à l’accueil et fermeture depuis le multitâche vérifiés. Sept contrôles directs passent sur Sargo en RAM, sans fenêtre de débogage, avec une page locale et une précondition de déverrouillage forcée ; le catalogue distant nécessite une connexion Internet sur le téléphone.\n\n### Android · APK\n\n- Reconstruire l’APK local Preview17 avec le message d’erreur Marketplace commun. Signature, alignement et parité des ressources vérifiés ; nouvelle régression sur appareil Android non effectuée dans cette étape.\n\nAucune publication dans cette étape.\n",
- "en": "## 6 October 2026 — Marketplace · in development\n\n### Desktop / Tundra · Sargo\n\n- Keep the catalogue below the status bar and inside its owning application. Hide its web view on Home and in the task switcher; remove it when the app closes.\n- Prevent a cancelled navigation or a previous error page from hiding a retry.\n- Show a local error message with a retry action when the catalogue is unavailable, instead of leaving its error page over Gaia.\n- Desktop navigation, network failure, retry, Home and closing from the task switcher checked. Seven direct checks pass on Sargo in RAM without a debugger window, using a local page and a forced-unlock precondition; the remote catalogue requires an Internet connection on the phone.\n\n### Android · APK\n\n- Rebuild the local Preview17 APK with the shared Marketplace error message. Signature, alignment and asset parity checked; no new Android device regression run in this step.\n\nNothing published in this step.\n"
+ "fr": "## 6 octobre 2026 — Marketplace · en développement\n\n-  PC/Sargo : catalogue Marketplace contenu sous la barre de statut, masqué à l’accueil et retiré à la fermeture.\n-  Une navigation annulée ou une ancienne page d’erreur ne masque plus la nouvelle tentative.\n-  Message d’erreur local avec bouton Réessayer lorsque le catalogue est inaccessible.\n-  PC : navigation, erreur réseau, nouvelle tentative, accueil et fermeture vérifiés.\n-  Sargo en RAM : sept contrôles réussis avec une page locale et déverrouillage forcé. Catalogue distant non validé sans connexion Internet.\n-  APK Preview17 local reconstruit ; signature, alignement et ressources vérifiés. Aucun nouveau test sur appareil Android ni publication.\n",
+ "en": "## 6 October 2026 — Marketplace · in development\n\n-  Desktop/Sargo: keep Marketplace below the status bar, hide it on Home and remove it when closed.\n-  Cancelled navigation or an old error page no longer hides a retry.\n-  Local error message with a Retry button when the catalogue is unavailable.\n-  Desktop: verified navigation, network errors, retry, Home and closing.\n-  Sargo in RAM: seven checks passed with a local page and forced unlock. Remote catalogue not validated without Internet access.\n-  Rebuilt local Preview17 APK; checked signature, alignment and assets. No new Android device test or release.\n"
+ },
+ "metadata": {
+ "kind": "history",
+ "status": "history",
+ "platforms": [
+ "pc",
+ "apk",
+ "models"
+ ],
+ "models": [
+ "sargo"
+ ]
}
},
{
"id": "2.7-preview.17",
"text": {
- "fr": "## 2.7-preview.17 — APK construit · 5 octobre 2026\n\n### Commun · Gaia / PC\n\n- Remettre Marketplace sur l’accueil avec son icône originale et le catalogue Vulpes. Ouvrir sa version française pour les langues fr, anglaise sinon.\n- Adapter les téléchargements du catalogue en installations confirmées : vérifier les archives ZIP, ajouter l’application à l’accueil et permettre son lancement et sa désinstallation.\n- Conserver les applications installées séparément des sources Gaia, sur des origines distinctes et sans privilèges système hérités des anciens manifestes.\n- Valider sur PC le parcours réel avec 2048 : catalogue, confirmation, installation, lancement, persistance et désinstallation. Contrôler les archives corrompues, chemins invalides, limites, annulation et confirmations à usage unique.\n\n### Android · APK\n\n- Construire et signer Preview17 avec le même Marketplace et le même service d’installation. Utiliser la vue GeckoView existante pour le catalogue et le stockage privé Android pour les paquets.\n- Vérifier sur émulateur Android 16/API36 le clic Installer, la confirmation, l’apparition sur l’accueil et le lancement de 2048 sans accès au pont système Vulpes. Vérifier aussi la conservation après arrêt forcé et relance, puis la désinstallation.\n\n### Tundra · Sargo\n\n- Préparer une image de boot locale incluant Marketplace, l’installateur et le serveur des applications ajoutées. Démarrage en RAM effectué sur le Pixel le 6 octobre : accueil et ressources Marketplace vérifiés, sans flash. Neuf contrôles système sur dix passent ; le contrôle global Gaia relève une variation du viewport au déverrouillage. Installation de paquets sur Sargo encore à qualifier.\n\n**Limites :** compatibilité initiale avec les applications web empaquetées. Les anciennes API privilégiées, applications hébergées et services disparus ne sont pas restaurés automatiquement. Rien publié sur Forgejo ni sur le site.\n",
- "en": "## 2.7-preview.17 — APK built · 5 October 2026\n\n### Shared · Gaia / Desktop\n\n- Restore Marketplace on Home with its original icon and the Vulpes catalogue. Select French for fr languages and English otherwise.\n- Turn catalogue downloads into confirmed installations: validate ZIP archives, add the application to Home and support launch and removal.\n- Store installed applications separately from Gaia sources, on distinct origins and without system privileges inherited from archived manifests.\n- Validate the real desktop workflow with 2048: catalogue, confirmation, installation, launch, persistence and removal. Check damaged archives, invalid paths, limits, cancellation and single-use confirmations.\n\n### Android · APK\n\n- Build and sign Preview17 with the same Marketplace and installation service. Use the existing GeckoView browser for the catalogue and private Android storage for packages.\n- Verify the Install click, confirmation, Home icon and 2048 launch without access to the Vulpes system bridge on an Android 16/API36 emulator. Also verify persistence after force-stop/relaunch and removal.\n\n### Tundra · Sargo\n\n- Prepare a local boot image containing Marketplace, the installer and the added-application server. RAM boot performed on the Pixel on 6 October: Home and Marketplace resources verified, without flashing. Nine of ten system checks pass; the overall Gaia check records a viewport change during unlocking. Package installation on Sargo still needs qualification.\n\n**Limitations:** initial support for packaged web applications. Old privileged APIs, hosted apps and discontinued services are not restored automatically. Nothing published to Forgejo or the website.\n"
+ "fr": "## 2.7-preview.17 — APK construit · 5 octobre 2026\n\n-  Marketplace sur l’accueil, avec son icône originale et le catalogue Vulpes en français ou en anglais.\n-  Installation confirmée des applications ZIP, lancement depuis l’accueil et désinstallation.\n-  Applications installées isolées des sources Gaia et des privilèges système.\n-  PC : parcours complet avec 2048 et contrôles des archives, chemins, limites, annulations et confirmations à usage unique.\n-  APK Preview17 construit et signé avec Marketplace, GeckoView et stockage privé des paquets.\n-  Émulateur Android 16/API 36 : installation, lancement, isolation, conservation après redémarrage de l’application et désinstallation de 2048.\n-  Sargo en RAM le 6 octobre : accueil et ressources Marketplace vérifiés, 9 contrôles système sur 10 réussis. Variation du viewport au déverrouillage ; installation de paquets à valider.\n-  Prise en charge initiale des applications web empaquetées. Anciennes API privilégiées et applications hébergées non restaurées. Aucune publication à cette étape.\n",
+ "en": "## 2.7-preview.17 — APK built · 5 October 2026\n\n-  Marketplace on Home with its original icon and the Vulpes catalogue in French or English.\n-  Confirmed ZIP app installation, launch from Home and removal.\n-  Installed apps isolated from Gaia sources and system privileges.\n-  Desktop: full 2048 workflow plus archive, path, limit, cancellation and single-use confirmation checks.\n-  Preview17 APK built and signed with Marketplace, GeckoView and private package storage.\n-  Android 16/API 36 emulator: 2048 installation, launch, isolation, persistence after app restart and removal.\n-  Sargo in RAM on October 6: Home and Marketplace assets verified, 9 of 10 system checks passed. Viewport changes on unlock; package installation awaits validation.\n-  Initial support for packaged web apps. Legacy privileged APIs and hosted apps not restored. No release at this stage.\n"
+ },
+ "metadata": {
+ "kind": "version",
+ "status": "built",
+ "platforms": [
+ "pc",
+ "apk",
+ "models"
+ ],
+ "models": [
+ "sargo"
+ ]
}
},
{
"id": "2.7-preview.16",
"text": {
- "fr": "## 2.7-preview.16 — APK construit · 5 octobre 2026\n\n### Commun · Gaia / PC\n\n- Rétablir les sélecteurs Gaia de fond d’écran et de sons, leur retour vers Paramètres et leur annulation par Accueil. Partager le transport des images et sons enregistrés entre PC et Android.\n- Propager les changements de langue à l’accueil, y compris sans permission de lecture générale des réglages. Vérifier les libellés FR/EN et la persistance.\n- Rétablir l’arrêt du balayage horizontal sur une page et masquer les applications de développement sur l’accueil, sans les supprimer des sources.\n- Utiliser le logo Vulpes fourni dans le splash et adapter les composants originaux de la liste des sons au moteur actuel.\n- Vérifier sur PC deux changements consécutifs de fond, le choix d’un son et les panneaux Gaia restaurés.\n\n### Android · APK\n\n- APK local Preview16 construit et signé ; contenu commun et version vérifiés. Aucune publication dans cette étape.\n- Valider Vulpes comme lanceur HOME, afficher le verrouillage Gaia après veille et rétablir le retour Android depuis la caméra ouverte au verrouillage.\n- Masquer les barres système Android en mode immersif, en conservant leur accès temporaire par balayage.\n- Tests sur émulateur Android 16/API36 : verrouillage, caméra/retour, FR/EN, accueil sans applications de test, fond d’écran et son. Fond conservé après arrêt forcé et relance, sans changement répété pendant 15 secondes. Autres appareils Android non qualifiés dans cette étape.\n\n### Tundra · Sargo\n\n- Inclure les mêmes correctifs et la politique des applications dans une nouvelle image de boot de test. Le boot précédent réappliquait une copie ancienne des fichiers et annulait les modifications à chaud.\n- Démarrage en RAM sur le Pixel réussi : six contrôles de langues/accueil/sélecteurs et dix contrôles système passent. Sélecteurs image/son vérifiés sans écraser les choix personnels. Le boot installé reste Preview15 ; aucune partition flashée dans cette étape.\n\n**Limites :** l’installation tierce FFAA reste ouverte. Le site dépend d’anciennes API mozApps et d’un script nomodule ; un installateur isolant les applications et leurs permissions reste à construire. Le choix d’un son Gaia ne modifie pas la sonnerie globale Android ; l’audio d’appel réel n’a pas été qualifié ici.",
- "en": "## 2.7-preview.16 — APK built · 5 October 2026\n\n### Shared · Gaia / Desktop\n\n- Restore Gaia wallpaper and sound pickers, return navigation to Settings and cancellation through Home. Share persisted image and sound transport between desktop and Android.\n- Propagate language changes to Home without granting general settings read access. Check French/English labels and persistence.\n- Restore horizontal page snapping and hide development apps from Home without deleting their sources.\n- Use the supplied Vulpes logo in the splash and adapt the original sound-list components to the current engine.\n- Verify two consecutive wallpaper changes, sound selection and restored Gaia panels on desktop.\n\n### Android · APK\n\n- Build and sign local Preview16; verify shared payload and version. Nothing published in this step.\n- Validate Vulpes as the HOME launcher, restore Gaia locking after sleep and Android Back navigation from the lockscreen camera.\n- Hide Android system bars in immersive mode while retaining temporary swipe access.\n- Android 16/API36 emulator checks: locking, camera/Back, French/English, Home without test apps, wallpaper and sound. Wallpaper retained after force-stop/relaunch with no repeated changes over 15 seconds. Other Android devices not qualified in this step.\n\n### Tundra · Sargo\n\n- Include shared fixes and app policy in a new test boot image. The previous boot reapplied an old file snapshot, undoing hot updates.\n- RAM boot on Pixel passed: six language/Home/picker checks and ten system checks. Image/sound picker results verified without replacing personal selections. Installed boot remains Preview15; no partition flashed in this step.\n\n**Limitations:** third-party FFAA installation remains open. The site depends on old mozApps APIs and a nomodule script; an installer with application and permission isolation still needs implementation. Selecting a Gaia sound does not change the global Android ringtone; actual call audio was not qualified here."
+ "fr": "## 2.7-preview.16 — APK construit · 5 octobre 2026\n\n-  Sélecteurs Gaia de fond d’écran et de sons rétablis, avec retour vers Paramètres et annulation par Accueil.\n-  Changement de langue appliqué à l’accueil et conservé après redémarrage.\n-  Arrêt du balayage horizontal sur chaque page ; applications de développement masquées sur l’accueil.\n-  Logo Vulpes au démarrage et liste des sons adaptée au moteur actuel. Transport des médias partagé entre PC et Android.\n-  Android : verrouillage Gaia après veille et retour depuis la caméra du verrouillage. Mode lanceur HOME validé.\n-  Android : barres système masquées en mode immersif, accessibles par balayage. APK Preview16 construit et signé.\n-  PC et émulateur Android 16/API 36 : fonds d’écran, sons et panneaux vérifiés ; langue, verrouillage et persistance du fond testés sur Android.\n-  Sargo : image de démarrage mise à jour pour ne plus restaurer d’anciens fichiers.\n-  Sargo en RAM : 6 contrôles d’interface et 10 contrôles système réussis. Installation conservée en Preview15, sans flash.\n-  APK non publié. Installation FFAA et audio d’appel à valider ; le choix d’un son Gaia ne change pas la sonnerie Android.\n",
+ "en": "## 2.7-preview.16 — APK built · 5 October 2026\n\n-  Restored Gaia wallpaper and sound pickers, return to Settings and cancellation through Home.\n-  Apply language changes to Home and preserve them after restart.\n-  Restore horizontal page snapping; hide development apps on Home.\n-  Vulpes startup logo and sound list adapted to the current engine. Media transport shared by desktop and Android.\n-  Android: Gaia locking after sleep and Back from the lockscreen camera. HOME launcher mode validated.\n-  Android: hide system bars in immersive mode, with swipe access. Preview16 APK built and signed.\n-  Desktop and Android 16/API 36 emulator: checked wallpapers, sounds and panels; tested language, locking and wallpaper persistence on Android.\n-  Sargo: updated the boot image to stop restoring outdated files.\n-  Sargo in RAM: 6 UI and 10 system checks passed. Installed build remains Preview15, without flashing.\n-  APK not published. FFAA installation and call audio await validation; Gaia sound selection does not change the Android ringtone.\n"
+ },
+ "metadata": {
+ "kind": "version",
+ "status": "built",
+ "platforms": [
+ "pc",
+ "apk",
+ "models"
+ ],
+ "models": [
+ "sargo"
+ ]
}
},
{
"id": "2026-10-04-sargo-standalone",
"text": {
- "fr": "## En développement — 4 octobre 2026 — Installation autonome Sargo\n\n### Tundra · Sargo\n\n- Construction d’un volume ext4 neuf directement destiné à userdata : système, données et composants matériels vendor/modem embarqués. Le démarrage ne cherche plus de fichiers dans une installation Droidian ; aucune session Android ni connexion ADB n’est nécessaire à l’installation.\n- Installateur Fastboot en Python : contrôle du modèle Sargo, du déverrouillage, des tailles et des empreintes, puis remplacement de userdata, dtbo_a, vbmeta_a et boot_a. Le bootloader reste déverrouillé ; les calibrations propres au téléphone sont conservées.\n- Sauvegarde complète de userdata hors ligne, comparaison intégrale avec le téléphone et vérification de l’archive de restauration avant le flash. Les sauvegardes restent privées ; une restauration réelle n’a pas été effectuée dans cette étape.\n- Deux démarrages depuis le stockage interne réussis : onze contrôles Gaia et dix contrôles natifs par démarrage, image de boot relue et vérifiée, réglages conservés. Le slot A est marqué réussi uniquement après validation du démarrage Gaia courant.\n- Variante générique sans serveur SSH ni clés de maintenance : flash de boot_a et démarrage réel réussis. La variante de développement est ensuite remise sur le Pixel pour poursuivre les travaux. Les images du paquet générique conservent leur état initial vierge.\n- Contrôles finaux sur le démarrage autonome de développement : Photo (dont bascules de capteur), clavier Gaia et notifications réussis.\n- Paquet Fastboot local assemblé avec empreintes et rapport de qualification. 44 tests ciblés passent. Il n’est pas encore publié : la provenance et la redistribution de certains composants matériels restent à finaliser.\n- Limites : qualification sur un Pixel 3a, pas sur toutes les combinaisons de firmware ; volume de 8 Gio comprenant 1 Gio de données modifiables, sans extension automatique. Les fonctionnalités matérielles restent expérimentales.\n\n### PC / Android · APK\n\n- Aucun changement d’interface ni nouveau binaire dans cette étape. Gaia reste partagé entre les trois plateformes.",
- "en": "## In development — 4 October 2026 — Standalone Sargo installation\n\n### Tundra · Sargo\n\n- Build a fresh ext4 volume directly for userdata, including the system, writable data and vendor/modem hardware images. Startup no longer looks for files in a Droidian installation; installation requires neither an Android session nor ADB.\n- Python Fastboot installer checks the Sargo model, unlock state, partition sizes and checksums, then replaces userdata, dtbo_a, vbmeta_a and boot_a. The bootloader stays unlocked and device-specific calibration is retained.\n- Full offline userdata backup, complete comparison against the phone and recovery archive verification before flashing. Backups remain private; an actual restoration was not performed in this step.\n- Two successful boots from internal storage: eleven Gaia and ten native checks per boot, verified boot-image readback and retained settings. Slot A is marked successful only after validating Gaia startup in the current boot.\n- Generic variant without an SSH server or maintenance keys: actual boot_a flash and boot passed. The development variant was then restored on the Pixel for further work. Generic package images retain their original fresh state.\n- Final checks on the standalone development boot passed for Camera (including lens switches), the Gaia keyboard and notifications.\n- Local Fastboot package assembled with checksums and a qualification report. 44 targeted tests pass. It is not published yet: provenance and redistribution of some hardware components still need final review.\n- Limits: qualified on one Pixel 3a, not every firmware combination; an 8 GiB volume including 1 GiB of writable data, without automatic expansion. Hardware functionality remains experimental.\n\n### Desktop / Android · APK\n\n- No interface changes or new binary in this step. Gaia remains shared by all three platforms."
+ "fr": "## En développement — 4 octobre 2026 — Installation autonome Sargo\n\n-  Sargo : volume ext4 autonome pour userdata, avec système, données et composants vendor/modem. Installation indépendante de Droidian et d’ADB.\n-  Installateur Fastboot avec contrôles du modèle, du déverrouillage, des tailles et des empreintes. Remplacement de userdata, dtbo_a, vbmeta_a et boot_a ; calibrations conservées.\n-  Sauvegarde complète de userdata et archive de restauration vérifiées avant le flash. Restauration réelle non testée.\n-  Deux démarrages installés réussis : 11 contrôles Gaia et 10 contrôles natifs par démarrage, réglages conservés et boot relu conforme.\n-  Variante sans SSH ni clés de maintenance démarrée après flash. Variante de développement réinstallée ; caméra, clavier et notifications vérifiés.\n-  Paquet Fastboot local assemblé ; 44 tests ciblés réussis.\n-  Paquet non publié : provenance et redistribution de composants matériels à finaliser. Essais sur un seul Pixel 3a ; volume de 8 Gio dont 1 Gio modifiable, sans extension automatique.\n",
+ "en": "## In development — 4 October 2026 — Standalone Sargo installation\n\n-  Sargo: standalone ext4 userdata volume with system, data and vendor/modem components. Installation independent of Droidian and ADB.\n-  Fastboot installer with model, unlock, size and checksum checks. Replaces userdata, dtbo_a, vbmeta_a and boot_a; retains calibration.\n-  Full userdata backup and recovery archive verified before flashing. Actual restoration not tested.\n-  Two installed boots passed: 11 Gaia and 10 native checks per boot, retained settings and matching boot readback.\n-  Variant without SSH or maintenance keys booted after flashing. Development variant reinstalled; camera, keyboard and notifications checked.\n-  Local Fastboot package assembled; 44 targeted tests passed.\n-  Package unpublished: hardware component provenance and redistribution pending. Tested on one Pixel 3a; 8 GiB volume with 1 GiB writable, without automatic expansion.\n"
+ },
+ "metadata": {
+ "kind": "development",
+ "status": "development",
+ "platforms": [
+ "pc",
+ "apk",
+ "models"
+ ],
+ "models": [
+ "sargo"
+ ]
}
},
{
"id": "2026-10-03-sargo-per-device-installation",
"text": {
- "fr": "## En développement — 3 octobre 2026 — Installation Sargo par appareil\n\n### Tundra · Sargo\n\n- Collecte des six partitions de démarrage en lecture seule et du stockage existant, liés à l’identité du téléphone et au slot A. Les sauvegardes restent privées.\n- Dossier appareil portable : construction RAM, contrôle du boot, installation et export du secours peuvent l’utiliser sans l’inventaire partagé du Pixel de développement. Un build lié à un dossier appareil refuse le repli implicite vers cet inventaire.\n- Transfert SSH contrôlé : vérification de l’identité et du stockage, contrôle des empreintes et création des fichiers sans remplacer les données existantes. Aucun formatage, flash ou redémarrage dans l’outil de transfert.\n- Construction et transfert testés sur le Pixel. Premier boot RAM : onze contrôles Gaia et dix contrôles natifs réussis ; clavier et notifications réussis. Le contrôle caméra a rencontré un délai d’autofocus dépassé, conservé dans le rapport.\n- Second boot de la même image : réglage Gaia conservé, onze contrôles Gaia et dix contrôles natifs réussis ; caméra, clavier et notifications réussis. Le timeout autofocus du premier essai reste documenté, pas déclaré corrigé.\n- Retour au build installé vérifié : démarrage Gaia réussi et partition de boot inchangée. Un nouveau transfert refuse bien de remplacer les données déjà utilisées.\n- Provenance du HAL précisée : paquet et recette de téléchargement identifiés, recettes UBports historiques récupérées. La correspondance exacte avec l’ancien artefact Android et son manifeste de sources reste à établir.\n- Prise en charge toujours limitée au noyau qualifié et au stockage ext4/LVM compatible. Ce travail ne fournit pas encore une installation directe depuis Android d’origine.\n\n### PC / Android · APK\n\n- Aucun changement d’interface ni nouvelle compilation dans cette étape.",
- "en": "## In development — 3 October 2026 — Per-device Sargo installation\n\n### Tundra · Sargo\n\n- Read-only collection of six boot partitions and existing storage, bound to the phone identity and slot A. Backups remain private.\n- Portable device workspace: RAM builds, boot checks, installation and recovery export can use it without the development Pixel’s shared inventory. Workspace-bound builds reject implicit fallback to that inventory.\n- Checked SSH transfers verify identity, storage and checksums and create files without replacing existing data. The transfer tool never formats, flashes or reboots.\n- Build and transfer tested on the Pixel. First RAM boot passed eleven Gaia and ten native checks; keyboard and notifications passed. Camera testing encountered an autofocus timeout, retained in the report.\n- Second boot of the same image retained the Gaia setting and passed eleven Gaia and ten native checks; camera, keyboard and notifications passed. The first autofocus timeout remains documented, not declared fixed.\n- Verify return to the installed build: Gaia starts and the boot partition is unchanged. A repeated transfer correctly refuses to replace existing data.\n- Refine HAL provenance: identify the installed package and download recipe and retrieve historical UBports build recipes. Exact correspondence with the old Android artifact and resolved source manifest remains unverified.\n- Still limited to the qualified kernel and compatible ext4/LVM storage. This does not yet provide direct installation from stock Android.\n\n### Desktop / Android · APK\n\n- No interface changes or new build in this step."
+ "fr": "## En développement — 3 octobre 2026 — Installation Sargo par appareil\n\n-  Sargo : dossier appareil portable pour les builds, l’installation et le secours, indépendant de l’inventaire du Pixel de développement.\n-  Collecte en lecture seule des six partitions de démarrage et du stockage, liée à l’identité du téléphone. Sauvegardes privées.\n-  Transfert SSH avec contrôle d’identité, du stockage et des empreintes, sans écrasement, formatage ni flash.\n-  Deux boots RAM : 11 contrôles Gaia et 10 contrôles natifs réussis chacun ; réglage conservé. Timeout autofocus au premier essai, caméra fonctionnelle au second.\n-  Retour au build installé vérifié ; boot inchangé et écrasement des données refusé.\n-  Timeout autofocus non déclaré corrigé. Provenance exacte du HAL encore à établir ; noyau et stockage ext4/LVM compatibles requis, sans installation directe depuis Android d’origine.\n",
+ "en": "## In development — 3 October 2026 — Per-device Sargo installation\n\n-  Sargo: portable device workspace for builds, installation and recovery, independent of the development Pixel inventory.\n-  Read-only collection of six boot partitions and storage, bound to the phone identity. Private backups.\n-  SSH transfer with identity, storage and checksum checks, without overwriting, formatting or flashing.\n-  Two RAM boots: 11 Gaia and 10 native checks passed each; setting retained. Autofocus timed out on the first trial; camera worked on the second.\n-  Return to the installed build verified; boot unchanged and data overwrite rejected.\n-  Autofocus timeout not considered fixed. Exact HAL provenance still pending; qualified kernel and compatible ext4/LVM storage required, without direct installation from stock Android.\n"
+ },
+ "metadata": {
+ "kind": "development",
+ "status": "development",
+ "platforms": [
+ "pc",
+ "apk",
+ "models"
+ ],
+ "models": [
+ "sargo"
+ ]
}
},
{
"id": "2026-10-03-sargo-installation-tools",
"text": {
- "fr": "## En développement — 3 octobre 2026 — Préparation et secours Sargo\n\n### Tundra · Sargo\n\n- Ajout d’un outil de préparation locale : contrôle du manifeste et de l’archive, décompression bornée, vérification ext4 et création d’un espace de données vierge propre à l’installation. Les dossiers existants ne sont pas écrasés.\n- Ajout d’un kit de restauration privé exportable hors du dépôt. Il conserve la sauvegarde originale de boot_a et un outil autonome qui vérifie le modèle, le numéro de série, le slot et les empreintes avant toute restauration explicite. Il ne constitue pas une sauvegarde des données utilisateur.\n- La restauration existante ne dépend plus de la présence de l’image de diagnostic. Les contrôles de la sauvegarde sont conservés.\n- Construction système : référence matérielle ARM64 désormais explicite, avec refus d’une référence limitée à l’affichage ou dépourvue des services requis.\n- Préparation de l’archive système réelle et vérification locale du kit de secours effectuées. Aucun nouveau flash ni nouvelle ROM publiée ; l’installation automatique sur un autre Sargo reste à finaliser.\n\n### Commun · PC / Android / Tundra\n\n- Le journal JSON partagé est conservé dans les futures publications de sources ; les documents de travail Markdown restent exclus.\n\n### PC / Android · APK\n\n- Aucune modification fonctionnelle ni nouvelle compilation dans cette étape.",
- "en": "## In development — 3 October 2026 — Sargo preparation and recovery tools\n\n### Tundra · Sargo\n\n- Add local package preparation: verify manifest and archive checksums, bound decompression, check ext4 and create a fresh data image for each installation. Existing directories are never overwritten.\n- Add a private recovery kit that can be exported outside the checkout. It retains the original boot_a backup and a standalone tool that checks model, serial number, slot and checksums before explicit restoration. It is not a user-data backup.\n- Existing restoration no longer requires the diagnostic image to remain available. Backup validation is retained.\n- System builds now require an explicit ARM64 hardware reference and reject display-only references or missing required services.\n- Prepare the real system archive and verify the recovery kit locally. No new flash or public ROM release; automated installation on another Sargo still needs completion.\n\n### Shared · Desktop / Android / Tundra\n\n- Retain the shared JSON changelog in future source publications; internal Markdown working documents remain excluded.\n\n### Desktop / Android · APK\n\n- No functional changes or new build in this step."
+ "fr": "## En développement — 3 octobre 2026 — Préparation et secours Sargo\n\n-  Sargo : préparation locale des paquets avec contrôles d’empreintes, décompression bornée, vérification ext4 et création de données vierges sans écrasement.\n-  Kit de secours privé avec sauvegarde de boot_a et contrôles du modèle, du numéro de série, du slot et des empreintes. Données utilisateur non incluses.\n-  Restauration possible sans l’image de diagnostic.\n-  Construction système : référence matérielle ARM64 complète exigée.\n-  Archive système préparée et kit de secours vérifié localement. Installation sur un autre Sargo à finaliser ; aucun flash ni ROM publiée.\n-  Journal JSON inclus dans les prochaines publications de sources ; documents de travail exclus.\n",
+ "en": "## In development — 3 October 2026 — Sargo preparation and recovery tools\n\n-  Sargo: local package preparation with checksum checks, bounded extraction, ext4 validation and fresh data creation without overwriting.\n-  Private recovery kit with boot_a backup and model, serial, slot and checksum checks. User data not included.\n-  Restoration works without the diagnostic image.\n-  System builds require a complete ARM64 hardware reference.\n-  System archive prepared and recovery kit checked locally. Installation on another Sargo pending; no flash or ROM release.\n-  JSON changelog included in future source releases; working documents excluded.\n"
+ },
+ "metadata": {
+ "kind": "development",
+ "status": "development",
+ "platforms": [
+ "pc",
+ "apk",
+ "models"
+ ],
+ "models": [
+ "sargo"
+ ]
}
},
{
"id": "2026-10-03-sargo-modem-persistence",
"text": {
- "fr": "## En développement — 3 octobre 2026 — Sargo : récupération modem et installation vierge\n\n### Tundra · Sargo\n\n- Reconstruction de l’image système avec le correctif du cache des polices et la création du répertoire privé d’oFono au premier démarrage.\n- Récupération limitée au sous-système modem : test contrôlé avec passage hors ligne, remise en service et réenregistrement réseau en environ 22 secondes, sans redémarrage du téléphone. Cela ne corrige pas la cause du watchdog IMS du firmware.\n- Démarrage Gaia, rendu matériel, polices Fira, deux capteurs photo, focus/zoom, clavier et notifications visuelles contrôlés sur le profil neuf. Scan Wi-Fi encore fonctionnel après le redémarrage du modem.\n- Deux démarrages réussis avec conservation du même réglage Gaia sur un espace de données séparé. Caméra, clavier et notifications revérifiés au second démarrage. La partition de boot installée et les données existantes sont conservées.\n- Aucune nouvelle image native publiée : le paquet public d’installation/restauration reste à terminer.\n\n### PC / Android · APK\n\n- Aucun changement de l’interface commune ni nouvelle compilation APK dans cette étape.",
- "en": "## In development — 3 October 2026 — Sargo modem recovery and fresh installation\n\n### Tundra · Sargo\n\n- Rebuild the system image with the font-cache fix and creation of oFono’s private state directory on first boot.\n- Limit recovery to the modem subsystem: a controlled restart went offline, recovered and registered on the network in about 22 seconds without rebooting the phone. This does not fix the firmware’s IMS watchdog root cause.\n- Check Gaia startup, hardware rendering, Fira fonts, both camera sensors, focus/zoom, keyboard and visual notifications with a fresh profile. Wi-Fi scanning still works after the modem restart.\n- Two successful boots retained the same Gaia setting in a separate data image. Camera, keyboard and notifications rechecked after the second boot. Installed boot partition and existing data retained.\n- No new native image published: the public installation/recovery package remains unfinished.\n\n### Desktop / Android · APK\n\n- No shared interface changes or new APK build in this step."
+ "fr": "## En développement — 3 octobre 2026 — Sargo : récupération modem et installation vierge\n\n-  Sargo : cache des polices et création du dossier privé oFono au premier démarrage corrigés dans l’image système.\n-  Récupération du modem sans redémarrer le téléphone. Réenregistrement réseau mesuré à environ 22 secondes lors d’un essai contrôlé.\n-  Profil neuf : Gaia, rendu matériel, Fira, deux caméras, focus, zoom, clavier et notifications vérifiés. Scan Wi-Fi fonctionnel après redémarrage du modem.\n-  Deux démarrages avec conservation des réglages dans un espace de données séparé. Installation existante préservée.\n-  Cause du watchdog IMS non corrigée. Paquet public d’installation et de restauration encore incomplet ; aucune image publiée.\n",
+ "en": "## In development — 3 October 2026 — Sargo modem recovery and fresh installation\n\n-  Sargo: corrected font cache and first-boot creation of oFono’s private directory in the system image.\n-  Modem recovery without rebooting the phone. Network registration took about 22 seconds in a controlled trial.\n-  Fresh profile: checked Gaia, hardware rendering, Fira, both cameras, focus, zoom, keyboard and notifications. Wi-Fi scan worked after modem restart.\n-  Two boots retained settings in a separate data image. Existing installation preserved.\n-  IMS watchdog root cause unresolved. Public installation and recovery package still incomplete; no image released.\n"
+ },
+ "metadata": {
+ "kind": "development",
+ "status": "development",
+ "platforms": [
+ "pc",
+ "apk",
+ "models"
+ ],
+ "models": [
+ "sargo"
+ ]
}
},
{
"id": "2026-10-01-sargo-distribution-preparation",
"text": {
- "fr": "## 1er octobre 2026 — Sargo : préparation de la distribution — en développement\n\n### Tundra · Sargo\n\n- Image système Preview 15 reconstruite avec profils vierges, exclusion des documents de travail et conservation des licences ; inventaire des 493 paquets embarqués.\n- Vérification ext4 et contrôle du contenu effectués. Ces contrôles ne constituent pas une validation de démarrage.\n- Inventaire du stockage en lecture seule et génération de la table de montage depuis cet inventaire, au lieu de reprendre systématiquement les dimensions du Pixel de développement. Prise en charge limitée au volume ext4 à une seule étendue sur userdata Sargo ; aucun formatage ajouté.\n- Image nettoyée transférée et vérifiée sur le Pixel chargé. Essais temporaires en RAM, sans écriture de boot_a ; retour automatique vers le build v59 et redémarrage de Gaia contrôlés.\n- SIM reconnue, enregistrement LTE et scan Wi-Fi contrôlés sur cette image ; ces contrôles ne valident pas les appels, SMS ou données mobiles de bout en bout.\n- Correction des droits du cache des polices au premier démarrage : fc-cache utilise le compte de session. Démarrage Gaia, rendu matériel, Fira, deux capteurs photo, clavier et notifications visuelles contrôlés en RAM. La qualification du fichier reconstruit reste incomplète.\n- Redémarrage intermittent identifié dans le firmware IMS du modem ; politique de récupération ciblée appliquée et second démarrage avec LTE, caméra, clavier et notifications réussi. La récupération après un nouveau crash IMS reste non testée. L’image publique reste non flashable et non publiée.\n\n### PC / APK\n\n- Aucun changement fonctionnel ni nouvelle compilation APK dans cette étape.",
- "en": "## October 1, 2026 — Sargo distribution preparation — in development\n\n### Tundra · Sargo\n\n- Rebuild Preview 15 system image with empty profiles, internal working documents excluded, license notices retained and an inventory of 493 runtime packages.\n- Filesystem and content checks completed; these checks do not qualify hardware boot.\n- Read-only storage inventory and mount-table generation from that inventory instead of hardcoded development-phone dimensions. Limited to a single ext4 extent on Sargo userdata; no formatting added.\n- Clean image transferred and verified on the charged Pixel. Temporary RAM boot tests left boot_a unchanged; automatic return to installed v59 and Gaia startup checked.\n- SIM detection, LTE registration and Wi-Fi scanning checked on this image; these checks do not establish end-to-end calling, SMS or mobile data.\n- Fix first-boot font cache ownership by running fc-cache as the session user. Gaia startup, hardware rendering, Fira, both camera sensors, keyboard and visual notifications checked in RAM. Qualification of the rebuilt image remains incomplete.\n- Intermittent reboot traced to modem IMS firmware; targeted recovery policy applied and another boot with LTE, camera, keyboard and notifications passed. Recovery after a new IMS crash has not been exercised. The public image remains non-flashable and unpublished.\n\n### Desktop / APK\n\n- No functional changes or new APK build in this step."
+ "fr": "## 1er octobre 2026 — Sargo : préparation de la distribution — en développement\n\n-  Sargo : image Preview15 reconstruite avec profils vierges, licences et inventaire des 493 paquets. Documents de travail exclus.\n-  Table de montage générée depuis l’inventaire du stockage. Prise en charge limitée à une seule étendue ext4 sur userdata.\n-  Droits du cache des polices au premier démarrage : fc-cache exécuté avec le compte de session.\n-  Contrôles ext4, contenu et transfert réussis. Essais RAM sans écriture de boot_a ; retour au build v59 vérifié.\n-  SIM, LTE, scan Wi-Fi, Gaia, rendu matériel, Fira, caméras, clavier et notifications vérifiés en RAM.\n-  Récupération ciblée après identification d’un redémarrage intermittent du firmware IMS. Second démarrage réussi ; nouveau crash IMS non testé.\n-  Qualification de l’image incomplète, sans validation des appels, SMS ou données mobiles de bout en bout. Image non publiée et non flashable.\n",
+ "en": "## October 1, 2026 — Sargo distribution preparation — in development\n\n-  Sargo: rebuilt Preview15 image with empty profiles, licenses and an inventory of 493 packages. Working documents excluded.\n-  Mount table generated from the storage inventory. Support limited to a single ext4 extent on userdata.\n-  First-boot font cache permissions: run fc-cache as the session user.\n-  Filesystem, content and transfer checks passed. RAM trials left boot_a unchanged; return to v59 verified.\n-  SIM, LTE, Wi-Fi scan, Gaia, hardware rendering, Fira, cameras, keyboard and notifications checked in RAM.\n-  Targeted recovery after tracing intermittent reboots to IMS firmware. Second boot passed; recovery from another IMS crash not tested.\n-  Image qualification incomplete; end-to-end calls, SMS and mobile data unvalidated. Image unpublished and non-flashable.\n"
+ },
+ "metadata": {
+ "kind": "history",
+ "status": "history",
+ "platforms": [
+ "pc",
+ "models"
+ ],
+ "models": [
+ "sargo"
+ ]
}
},
{
"id": "2.7-preview.15",
"text": {
- "fr": "## 2.7-preview.15 — 30 septembre 2026 — publiée\n\n[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/v2.7-preview.15)\n\n### Commun · Gaia / PC / Android / Tundra\n\n- Première publication des sources communes, avec README français et anglais, licences et scripts de préparation. Documentation interne, roadmap, profils, clés et images privées exclus du dépôt public.\n- Intégration des derniers correctifs SMS (dates des bulles, états d’envoi/échec) et du cycle de vie caméra. Les deux entrées du 30 septembre détaillent les correctifs matériels.\n\n### PC\n\n- Copie de publication testée avec moteur Gecko 156.0.1 et profil neuf : démarrage Gaia, navigation, réglages, gestes, historique et isolation des pages. 42 tests de services passants ; persistance des services après redémarrage vérifiée.\n- Stabilisation du test de navigation lors du remplacement normal d’un document et allongement de l’attente de démarrage à froid. Fermeture propre de l’hôte même si Gaia dépasse le délai de préparation.\n\n### Android · APK\n\n- Preview 15 signée avec la clé des précédentes Preview, GeckoView 156.0 ; parité des ressources communes vérifiée. Mise à jour installée sur émulateur Android 16.\n- Photo et Galerie : capture, affichage et reprise du capteur validés. Dix passages en arrière-plan et récupération après arrêt du processus d’affichage validés. Cette qualification ne couvre pas tous les téléphones Android.\n\n### Tundra · Sargo\n\n- Sources du runtime incluses ; l’image privée v59 reste celle qualifiée sur le téléphone de développement. Aucune ROM générique flashable publiée : démarrage encore lié au stockage inventorié, aux images préinstallées et aux accès de maintenance privés.\n- Limites conservées : aperçu caméra proche de 16 i/s, flash/vidéo, rotation et veille profonde incomplets ; audio d’appel à qualifier et grand dialogue après envoi SMS à identifier.\n",
- "en": "## 2.7-preview.15 — 30 September 2026 — published\n\n[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/v2.7-preview.15)\n\n### Shared · Gaia / Desktop / Android / Tundra\n\n- First shared source publication, including English/French READMEs, licenses and setup scripts. Internal documents, roadmap, profiles, keys and private images excluded from the public repository.\n- Include the latest SMS fixes (bubble dates, sent/failed states) and camera lifecycle fixes. The two 30 September entries detail the hardware changes.\n\n### Desktop PC\n\n- Published checkout tested with Gecko 156.0.1 and a fresh profile: Gaia startup, navigation, settings, gestures, history and page isolation. 42 service tests pass; service persistence after restart checked.\n- Stabilize navigation checks during normal document replacement and allow more time for cold startup. Close the host cleanly even if Gaia exceeds its preparation timeout.\n\n### Android · APK\n\n- Preview 15 signed with the previous Preview key, GeckoView 156.0; shared resource parity checked. Installed as an update on an Android 16 emulator.\n- Photo/Gallery capture, image display and sensor resume validated. Ten background/resume cycles and recovery after renderer termination validated. This does not qualify every Android phone.\n\n### Tundra · Sargo\n\n- Runtime sources included; private v59 remains the build qualified on the development phone. No generic flashable ROM published: boot still depends on inventoried storage, preinstalled images and private maintenance access.\n- Limits remain: approximately 16 fps camera preview, incomplete flash/video, rotation and deep suspend; call audio awaiting qualification and a large dialog after SMS sending still under investigation.\n"
+ "fr": "## 2.7-preview.15 — 30 septembre 2026 — publiée\n\n-  Première publication des sources PC, Android et Tundra, avec README FR/EN, licences et scripts de préparation. Fichiers privés exclus.\n-  SMS : dates des bulles et états d’envoi/échec corrigés. Libération et remplacement des flux caméra fiabilisés.\n-  PC : tests de navigation stabilisés, délai de démarrage à froid ajusté et fermeture propre après dépassement du délai Gaia.\n-  PC avec Gecko 156.0.1 : 42 tests de services, démarrage, navigation, réglages, gestes, historique, isolation et persistance vérifiés.\n-  APK Preview15 signé avec la clé précédente et GeckoView 156.0 ; parité des ressources vérifiée.\n-  Émulateur Android 16 : mise à jour, Photo/Galerie, reprise caméra, dix retours d’arrière-plan et récupération du rendu validés.\n-  Sargo : sources incluses, build privé v59 conservé. Aucune ROM générique ; démarrage lié au stockage et aux images préinstallées.\n-  Sargo : aperçu proche de 16 i/s ; flash, vidéo, rotation et veille profonde incomplets. Audio d’appel et dialogue après envoi SMS à examiner.\n\n[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/v2.7-preview.15)\n",
+ "en": "## 2.7-preview.15 — 30 September 2026 — published\n\n-  First publication of desktop, Android and Tundra sources, with French/English READMEs, licenses and setup scripts. Private files excluded.\n-  SMS: corrected bubble dates and sent/failed states. Improved camera stream cleanup and replacement.\n-  Desktop: stabilized navigation checks, adjusted cold-start timeout and clean shutdown after Gaia times out.\n-  Desktop with Gecko 156.0.1: 42 service tests, startup, navigation, settings, gestures, history, isolation and persistence checked.\n-  Preview15 APK signed with the previous key and GeckoView 156.0; asset parity verified.\n-  Android 16 emulator: validated update, Camera/Gallery, camera resume, ten background returns and renderer recovery.\n-  Sargo: sources included, private v59 retained. No generic ROM; boot depends on storage layout and preinstalled images.\n-  Sargo: preview around 16 fps; flash, video, rotation and deep suspend incomplete. Call audio and the dialog after SMS sending need investigation.\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": "## En développement — retours SMS et caméra · 30 septembre 2026\n\n### Commun · Gaia / PC / Tundra\n\n- SMS : convertir les dates en nombres avant de construire les bulles. Corrige les conversations vides dues à une erreur de formatage de date. Parcours du compositeur et affichage des messages envoyés/reçus vérifiés sur PC avec un modem simulé.\n- Photo : fermeture du flux rendue idempotente; détacher les vidéos, attendre la libération du capteur et libérer le contexte WebGL avant de changer de caméra. Identifier chaque session pour qu’un ancien flux ne puisse pas arrêter son remplaçant. Allers-retours arrière/avant vérifiés avec de nouvelles trames à chaque bascule; retour Accueil et arrêt du capteur validés lors de deux démarrages RAM.\n\n### Tundra / Sargo\n\n- Vibreur : utiliser le contrôle brightness du pilote DRV2624; la commande legacy activate ne coupait pas physiquement le moteur. Coupure confirmée par l'utilisateur; impulsion, motif 200/200/200 ms et arrêt du service vérifiés, avec retour à zéro. Ajouter une coupure de secours au démarrage et à la sortie du service.\n- L'utilisateur confirme focus, zoom et transport SMS dans les deux sens sur v54. Cinq conversations existantes affichées sur le Pixel, avec leurs bulles et sans erreur de date.\n- v59 installé sur boot_a après deux démarrages RAM réussis; démarrage installé et relecture vérifiés, slot A marqué réussi. Sources et runtime PC synchronisés. Le grand dialogue après un envoi réel reste à identifier; la fluidité caméra reste un chantier distinct.\n\n### Android · APK\n\n- Sources Gaia communes corrigées; aucun nouvel APK construit ou publié.\n",
- "en": "## In development — SMS and camera feedback · 30 September 2026\n\n### Shared · Gaia / Desktop / Tundra\n\n- Messages: serialize bubble dates as numbers. Fix empty conversations caused by date formatting errors. Composer flow and sent/received bubbles verified on desktop with a simulated modem.\n- Camera: make release idempotent; detach videos, await sensor release and dispose of the WebGL context before switching cameras. Identify each session so an old stream cannot stop its replacement. Repeated rear/front switches produce fresh frames; Home return and sensor shutdown passed on two RAM boots.\n\n### Tundra / Sargo\n\n- Vibration: use the DRV2624 brightness control; the legacy activate command did not physically stop the motor. The user confirmed stopping; single-pulse, 200/200/200 ms pattern and service-stop checks all returned to zero. Add a stop command when the service starts or exits.\n- The user confirms focus, zoom and SMS transport both ways on v54. Five existing conversations render on the Pixel, with message bubbles and no date errors.\n- v59 installed in boot_a after two successful RAM boots; installed startup and readback verified, slot A marked successful. Desktop sources and runtime synchronized. The large dialog after a real send still needs identification; camera smoothness remains separate work.\n\n### Android · APK\n\n- Shared Gaia sources updated; no APK rebuilt or published.\n"
+ "fr": "## En développement — retours SMS et caméra · 30 septembre 2026\n\n-  SMS : conversion des dates pour corriger les conversations vides.\n-  Caméra : fermeture des flux et libération du capteur corrigées ; une ancienne session ne peut plus fermer la suivante.\n-  Sargo : arrêt physique du vibreur via DRV2624, avec coupure de secours au démarrage et à l’arrêt du service.\n-  PC : composition et affichage des SMS vérifiés avec modem simulé. Sargo : bascules caméra et arrêt du capteur testés sur deux boots RAM.\n-  Sargo v54 : focus, zoom et SMS dans les deux sens confirmés par l’utilisateur. Cinq conversations affichées ; arrêt du vibreur confirmé.\n-  Sargo v59 installé sur boot_a après deux boots RAM ; démarrage, relecture et slot A vérifiés.\n-  Dialogue après envoi SMS et fluidité caméra encore à traiter. Sources communes mises à jour, sans nouvel APK.\n",
+ "en": "## In development — SMS and camera feedback · 30 September 2026\n\n-  SMS: convert dates to fix empty conversations.\n-  Camera: corrected stream and sensor cleanup; an old session can no longer close its replacement.\n-  Sargo: physical vibration shutdown through DRV2624, with a safeguard at service startup and exit.\n-  Desktop: SMS composition and display checked with a simulated modem. Sargo: camera switching and shutdown tested over two RAM boots.\n-  Sargo v54: user confirmed focus, zoom and two-way SMS. Five conversations displayed; vibration shutdown confirmed.\n-  Sargo v59 installed on boot_a after two RAM boots; startup, readback and slot A verified.\n-  Post-SMS dialog and camera smoothness still pending. Shared sources updated, without a new APK.\n"
+ },
+ "metadata": {
+ "kind": "development",
+ "status": "development",
+ "platforms": [
+ "pc",
+ "apk",
+ "models"
+ ],
+ "models": [
+ "sargo"
+ ]
}
},
{
"id": "development-2026-09-30-sms-camera",
"text": {
- "fr": "## En développement — SMS et Photo · 30 septembre 2026\n\n### PC / Tundra\n\n- SMS : conserver le message et sa conversation après un refus du modem; transmettre les événements Gaia « envoi », « échec » et « envoyé ».\n- SMS : distinguer mise en file oFono et confirmation d'envoi, avec suivi persistant des états terminaux. La confirmation modem n'est pas un accusé de réception du destinataire.\n- PC : tests de l'échec d'envoi visible dans la liste des conversations; Photo et Galerie testées avec capture tactile, ouverture, fermeture et reprise de la caméra.\n\n### Tundra / Sargo\n\n- Photo : raccorder les zones de focus tactile, le zoom numérique et la capture JPEG du capteur à l'interface Gaia originale. Aperçu conservé en 640 × 480; JPEG arrière 4032 × 3024 (12,19 Mpx) et avant 3264 × 2448 (7,99 Mpx) mesurés directement. Focus fixe à l'avant.\n- Protocole caméra : séparer les traces du HAL des données binaires, borner les réponses et relancer l'aperçu après une capture. Une erreur de focus ne doit plus fermer la session.\n- SMS : refuser explicitement l'envoi si le modem n'est pas enregistré; conserver les états sent/failed même après disparition de l'objet oFono. La réception a produit des notifications opérateur sur v49 après retour du réseau LTE.\n- Qualification : deux démarrages v54 en RAM puis démarrage depuis boot_a validés; image installée sur le Pixel et slot marqué réussi après relecture. Persistance, interface Gaia et tests caméra passants. L'enregistrement réseau reste instable : essais SMS arrêtés avant émission, aucune livraison de bout en bout confirmée.\n\n### Android · APK\n\n- Aucun nouvel APK construit ou publié dans cette passe. La Preview 14 disponible reste inchangée.\n",
- "en": "## In development — Messages and Camera · 30 September 2026\n\n### Desktop / Tundra\n\n- Messages now retain the message and conversation after a modem rejection and dispatch Gaia sending, failed and sent events.\n- Distinguish the oFono queue from confirmed transmission; persist terminal states. Modem confirmation is not a recipient delivery receipt.\n- Desktop checks cover failed messages visible in conversations, touch capture, Gallery opening and camera release/resume.\n\n### Tundra / Sargo\n\n- Connect touch focus areas, digital zoom and sensor JPEG capture to the original Gaia Camera. Preview stays at 640 × 480; directly measured JPEG sizes are 4032 × 3024 (12.19 MP) rear and 3264 × 2448 (7.99 MP) front. The front camera has fixed focus.\n- Keep HAL logs out of the binary protocol, bound replies and resume preview after capture. A focus failure must not close the session.\n- Reject SMS submission explicitly when unregistered; retain sent/failed states after the oFono object disappears. Carrier messages triggered Gaia notifications on v49 after LTE registration recovered.\n- Qualification: two v54 RAM boots and the installed boot_a startup passed; image installed on the Pixel and slot marked successful after readback. Persistence, Gaia startup and Camera checks passed. Network registration remains unstable: SMS tests stopped before transmission, with no end-to-end delivery confirmed.\n\n### Android · APK\n\n- No APK rebuilt or published in this pass. The available Preview 14 is unchanged.\n"
+ "fr": "## En développement — SMS et Photo · 30 septembre 2026\n\n-  PC/Tundra : conservation des SMS après refus du modem et transmission des états envoi, échec et envoyé à Gaia.\n-  Sargo : états SMS persistants après disparition de l’objet oFono ; envoi refusé si le modem n’est pas enregistré.\n-  Sargo : focus tactile, zoom numérique et capture JPEG du capteur dans Photo Gaia.\n-  Photos mesurées : 4032 × 3024 à l’arrière, 3264 × 2448 à l’avant. Aperçu 640 × 480 ; focus avant fixe.\n-  Caméra native : données binaires séparées des traces HAL, réponses bornées et reprise de l’aperçu après capture ou erreur de focus.\n-  PC : échec d’envoi visible et parcours Photo/Galerie vérifiés. Sargo v54 : deux boots RAM puis installation, persistance et caméra validées.\n-  Réseau instable ; aucune livraison SMS de bout en bout confirmée dans cette passe. Confirmation modem distincte de l’accusé de réception. APK public inchangé (Preview14).\n",
+ "en": "## In development — Messages and Camera · 30 September 2026\n\n-  Desktop/Tundra: retain SMS after modem rejection and pass sending, failed and sent states to Gaia.\n-  Sargo: persist SMS states after the oFono object disappears; reject sending when the modem is unregistered.\n-  Sargo: touch focus, digital zoom and sensor JPEG capture in Gaia Camera.\n-  Measured photos: 4032 × 3024 rear, 3264 × 2448 front. Preview 640 × 480; fixed front focus.\n-  Native camera: separate binary data from HAL logs, bound responses and resume preview after capture or focus errors.\n-  Desktop: checked visible send failures and Camera/Gallery workflows. Sargo v54: validated two RAM boots, installation, persistence and camera.\n-  Unstable network; no end-to-end SMS delivery confirmed in this pass. Modem confirmation is not a delivery receipt. Public APK unchanged (Preview14).\n"
+ },
+ "metadata": {
+ "kind": "development",
+ "status": "development",
+ "platforms": [
+ "pc",
+ "apk",
+ "models"
+ ],
+ "models": [
+ "sargo"
+ ]
}
},
{
"id": "2.7-preview.14",
"text": {
- "fr": "## 2.7-preview.14 — APK publié · 30 septembre 2026\n\n[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-preview.14)\n\nL’APK Preview 14 est publié. La version PC est à jour et le build privé Sargo v48 est installé et testé ; aucune image téléphone n’est publiée avec cette release.\n\n### Commun · PC / APK / Gaia\n\n- Informations : suppression du retrait supplémentaire des lignes de version avant « Dernière mise à jour » ; alignement contrôlé sur PC et Android.\n- Polices : normalisation des noms de fontes dans les feuilles des composants Shadow DOM, y compris les styles ajoutés après leur création. Les polices d’icônes restent distinctes.\n- Calendrier : ouverture du mois et absence d’erreur JavaScript vérifiées sur PC.\n\n### PC / Tundra\n\n- Clavier : calculer le mode portrait depuis l’écran, et non depuis la petite fenêtre du clavier. Touches de 60 px CSS environ sur Pixel ; rotation et retour au portrait testés sur PC à densité 3.\n- SMS : replacer le curseur dans le champ actif lorsque Gecko conserve la sélection du destinataire ; conserver les sélections valides pour l’édition.\n- Notifications Gaia : respecter les indicateurs silencieux, sans son et sans vibration, y compris à la restauration.\n\n- Clavier Gaia : pont de saisie natif, pavé numérique pour les destinataires SMS, événements de sélection corrigés et protection contre les réponses d’un champ devenu inactif. Saisie destinataire et message testée sur PC et Pixel ; l’envoi opérateur reste à qualifier.\n- Verrouillage : accepter l’absence de fenêtre clavier lors d’un redimensionnement.\n- Notifications natives : stockage, propriété par application et ouverture du point d’entrée Téléphone déclaré par Gaia. Présentation et suppression dans le volet et sur le verrouillage validées sur Sargo ; écoute du son à qualifier.\n\n### Tundra / Sargo — build v48 installé\n\n- Vibrations : pont vers le moteur Sargo, motifs bornés à dix secondes, séquencement sans bloquer les boutons physiques et annulation. Activation et arrêt mesurés sur le pilote.\n\n- Wi-Fi : profils privés conservés avec reconnexion automatique et commande d’oubli ; tests du stockage et de la restauration en cas d’échec. Connexion et reconnexion après redémarrage RAM vérifiées sur Sargo ; fichiers de profil privés (0600).\n- SMS entrants : stockage persistant avant acquittement, dédoublonnage des reprises et services créant leur dossier d’état au démarrage. Défaut de montage du dossier corrigé après reproduction sur Sargo.\n- Audio : commandes écouteur/haut-parleur, micro et volume d’appel ajoutées au pont ; bascule écouteur/haut-parleur, sourdine et volume vérifiés sans appel. Conversation dans les deux sens encore à vérifier.\n- Images de diagnostic avec débogueur local interdites au flash par l’outil d’installation. v48/preview.14 écrite sur boot_a après deux démarrages RAM et contrôle de persistance ; démarrage installé validé, image relue conforme et slot A marqué réussi. v43 à v48 n’activent pas de débogueur.\n\n### Android · APK\n\n- APK preview.14 construit, signé et publié sur Forgejo. Démarrage, informations de version, alignement et sélection des fontes des composants contrôlés sur Android 16 émulé. Le clavier Android reste celui du système.",
- "en": "## 2.7-preview.14 — APK published · 30 September 2026\n\n[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-preview.14)\n\nThe Preview 14 APK is published. Desktop is up to date and the private Sargo v48 build is installed and tested; this release does not include a public phone image.\n\n### Shared · PC / APK / Gaia\n\n- About: remove the extra indentation in version rows above “Last updated”; alignment checked on desktop and Android.\n- Fonts: normalize text font names inside Shadow DOM stylesheets, including styles added later; keep icon fonts intact.\n- Calendar: month view opens without JavaScript errors in the desktop check.\n\n### PC / Tundra\n\n- Keyboard: use the screen orientation instead of the short keyboard frame. Keys measure about 60 CSS pixels on Pixel; rotation tested on desktop at DPR 3.\n- SMS: restore the caret to the focused field when Gecko retains the recipient selection; preserve valid selections during editing.\n- Gaia notifications: respect silent, no-sound and no-vibration flags, including restored notifications.\n\n- Gaia keyboard: native input bridge, numeric SMS recipient layout, corrected selection events and protection against stale field replies. Recipient and message typing tested on desktop and Pixel; carrier delivery still requires qualification.\n- Lockscreen: tolerate the absence of a keyboard window during resize.\n- Native notifications: persistent application-owned entries and launch through Gaia's declared Phone entry point. Tray and lockscreen display/removal verified on Sargo; audible sound still needs qualification.\n\n### Tundra / Sargo — v48 build installed\n\n- Vibration: Sargo actuator bridge, patterns limited to ten seconds, non-blocking sequencing and cancellation. Driver activation and stop measured.\n\n- Wi-Fi: private persistent profiles, autoconnect and forget operation; storage and failed-update rollback tests. Connection and reconnection after a RAM reboot verified on Sargo; private profile files (0600).\n- Incoming SMS: persist before acknowledgement and deduplicate retries. Services now create their state directory at startup, fixing a mount failure reproduced on Sargo.\n- Call audio: earpiece/speaker, microphone mute and call volume controls added; earpiece/speaker switching, mute and volume verified without placing a call. Two-way conversation still unverified.\n- Local-debugger images cannot be flashed by the installer. v48/preview.14 written to boot_a after two RAM boots and persistence checks; installed boot verified, image readback matches and slot A marked successful. v43–v48 do not enable a debugger.\n\n### Android · APK\n\n- Preview.14 APK built, signed and published on Forgejo. Startup, version information, alignment and component font selection checked on an Android 16 emulator. Android retains its system keyboard."
+ "fr": "## 2.7-preview.14 — APK publié · 30 septembre 2026\n\n-  Informations système : alignement des versions et polices Fira dans les composants Shadow DOM.\n-  PC/Tundra : taille des touches en portrait, curseur dans le champ actif et réponses de saisie obsolètes.\n-  PC/Tundra : pont clavier Gaia et pavé numérique pour les destinataires SMS. Clavier Android inchangé.\n-  Verrouillage : redimensionnement sans fenêtre clavier. Notifications silencieuses respectées, y compris après restauration.\n-  Sargo : notifications persistantes dans le volet et sur le verrouillage, avec ouverture de l’application concernée.\n-  Sargo : vibrations annulables, limitées à dix secondes ; profils Wi-Fi privés persistants, reconnexion automatique et oubli du réseau.\n-  Sargo : stockage des SMS entrants avant acquittement, dédoublonnage et création du dossier d’état.\n-  Sargo : commandes écouteur/haut-parleur, micro muet et volume d’appel. Bascule, sourdine et volume testés hors appel.\n-  Installation : flash des images de diagnostic avec débogueur refusé. Sargo v48 installé après deux boots RAM ; persistance, relecture et slot A vérifiés.\n-  PC : Calendrier et clavier vérifiés. Sargo : clavier, notifications et reconnexion Wi-Fi vérifiés ; son des notifications et téléphonie réelle à valider.\n-  APK Preview14 signé et publié. Démarrage, versions et polices vérifiés sur émulateur Android 16. Aucune image Sargo publiée.\n\n[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-preview.14)\n",
+ "en": "## 2.7-preview.14 — APK published · 30 September 2026\n\n-  System information: version alignment and Fira fonts in Shadow DOM components.\n-  Desktop/Tundra: portrait key sizing, cursor placement in the active field and stale input replies.\n-  Desktop/Tundra: Gaia keyboard bridge and numeric keypad for SMS recipients. Android keeps its system keyboard.\n-  Lockscreen: resize without a keyboard window. Honour silent notifications, including after restoration.\n-  Sargo: persistent tray and lockscreen notifications, opening the owning application.\n-  Sargo: cancellable vibrations capped at ten seconds; private persistent Wi-Fi profiles, automatic reconnection and network removal.\n-  Sargo: store incoming SMS before acknowledgement, remove duplicates and create the state directory.\n-  Sargo: earpiece/speaker, microphone mute and call-volume controls. Routing, mute and volume tested outside a call.\n-  Installer: reject flashing diagnostic images with a debugger. Sargo v48 installed after two RAM boots; persistence, readback and slot A verified.\n-  Desktop: Calendar and keyboard checked. Sargo: keyboard, notifications and Wi-Fi reconnection checked; notification sound and real telephony await validation.\n-  Preview14 APK signed and published. Startup, versions and fonts checked on an Android 16 emulator. No Sargo image released.\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": "## 2.7-preview.13 — parcours dans les applications · 27 septembre 2026\n\n### Commun · Gaia / PC\n\n- Calendrier : une alarme planifiée avec succès ne provoque plus un rejet lors de la sauvegarde de l’événement; les erreurs de planification sont transmises au demandeur.\n- Rappels : ignorer ceux d’événements supprimés; ne plus marquer une notification refusée comme livrée. La prise en charge des notifications natives Tundra reste à terminer.\n- Notifications : accepter un texte brut vide sans chercher une traduction « undefined ».\n- Galerie : vignettes de taille positive même au démarrage masqué; annulation des mises à jour d’aperçu après fermeture de l’éditeur.\n- SMS : appliquer les filtres correspondant, lu/non lu, période et livraison; tests de lecture et suppression des conversations.\n- Revue approfondie : lecture, déplacement et suppression d’une vidéo WebM; édition noir et blanc et copie d’une photo; création et suppression d’un événement. Interfaces Gaia originales conservées.\n\n- Qualification depuis une copie isolée : démarrage, tests bureau, persistance et empaquetage Android réussis sans réécriture des sources.\n\n### Android · APK\n\n- APK preview.13 construit et signé; Photo/Galerie, Calendrier, dix retours d’arrière-plan et récupération du rendu validés sur Android 16 émulé.\n\n### Tundra · Sargo\n\n- Build v40/preview.13 installé après deux démarrages RAM : Gaia, caméra, Wi-Fi, luminosité et commandes contrôlés; relecture de boot_a conforme et slot validé. Limites restantes dans `docs/APPLICATION-REVIEW.md`.",
- "en": "## 2.7-preview.13 — application workflows · 27 September 2026\n\n### Shared · Gaia / Desktop\n\n- Calendar: successfully scheduling a reminder no longer rejects event saving; scheduling failures reach the caller.\n- Reminders: ignore deleted events and do not mark denied notifications as delivered. Native Tundra notification support remains incomplete.\n- Notifications: accept empty raw text without requesting an “undefined” translation.\n- Gallery: positive thumbnail size even when starting hidden; cancel preview updates after leaving the editor.\n- SMS: apply correspondent, read state, date range and delivery filters; cover conversation reading and deletion.\n- Deeper review: WebM playback, seeking and deletion; black-and-white photo editing and saved copy; event creation and deletion. Retain original Gaia interfaces.\n\n- Isolated checkout qualification passes startup, desktop tests, persistence and Android packaging without rewriting sources.\n\n### Android · APK\n\n- Signed preview.13 APK built; Camera/Gallery, Calendar, ten background/resume cycles and renderer recovery validated on an Android 16 emulator.\n\n### Tundra · Sargo\n\n- Build v40/preview.13 installed after two RAM boots: Gaia, camera, Wi-Fi, brightness and controls checked; matching boot_a readback and successful slot marking. Remaining limits in `docs/APPLICATION-REVIEW.md`."
+ "fr": "## 2.7-preview.13 — parcours dans les applications · 27 septembre 2026\n\n-  Calendrier : sauvegarde des événements avec alarme, remontée des erreurs et exclusion des rappels d’événements supprimés.\n-  Notifications : texte vide accepté ; notification refusée non marquée comme livrée. Support natif Tundra encore incomplet.\n-  Galerie : taille des vignettes au démarrage masqué et arrêt des mises à jour après fermeture de l’éditeur.\n-  SMS : filtres par correspondant, lecture, période et livraison appliqués.\n-  PC : vidéo WebM, édition et copie de photo, calendrier, lecture et suppression des conversations vérifiés.\n-  Copie isolée : démarrage, tests PC, persistance et empaquetage Android réussis.\n-  APK Preview13 construit et signé. Photo/Galerie, Calendrier, dix reprises d’arrière-plan et récupération du rendu validés sur Android 16 émulé.\n-  Sargo v40 installé après deux boots RAM ; Gaia, caméra, Wi-Fi, luminosité, commandes et relecture de boot_a vérifiés.\n",
+ "en": "## 2.7-preview.13 — application workflows · 27 September 2026\n\n-  Calendar: save events with alarms, report scheduling errors and skip reminders for deleted events.\n-  Notifications: accept empty text; do not mark rejected notifications as delivered. Native Tundra support still incomplete.\n-  Gallery: thumbnail sizing at hidden startup and cancellation of updates after closing the editor.\n-  SMS: apply correspondent, read-state, date and delivery filters.\n-  Desktop: checked WebM playback, photo editing and copying, calendar, conversation reading and deletion.\n-  Isolated checkout: startup, desktop tests, persistence and Android packaging passed.\n-  Preview13 APK built and signed. Camera/Gallery, Calendar, ten background returns and renderer recovery validated on emulated Android 16.\n-  Sargo v40 installed after two RAM boots; Gaia, camera, Wi-Fi, brightness, controls and boot_a readback verified.\n"
+ },
+ "metadata": {
+ "kind": "version",
+ "status": "built",
+ "platforms": [
+ "pc",
+ "apk",
+ "models"
+ ],
+ "models": [
+ "sargo"
+ ]
}
},
{
"id": "2.7-preview.12",
"text": {
- "fr": "## 2.7-preview.12 — revue des applications · 27 septembre 2026\n\n### Commun · Gaia / PC\n\n- Icônes internes : variantes raster Gaia originales de 1× à 2,25× rétablies dans\n 1 047 références CSS. Les icônes vectorielles et les polices restent originales.\n- Photo : bouton de changement de capteur réactivé après découverte des caméras;\n flash indisponible visible et grisé; le pincement ne zoome plus le document Gaia.\n- Accueil : gestes natifs réservés au bouton pendant l’appui long; menu contextuel\n désactivé sur ce bouton. Le gestionnaire des tâches Gaia reste utilisé.\n- Transmission des gestes entre System et les applications restaurée avec des\n événements DOM non privilégiés, sans autoriser de faux gestes sur les sites Web.\n- Navigateur : intégration de la page `browser-welcome` fournie, locale, bilingue,\n sans suivi; recherches et liens transmis au véritable navigateur.\n- Page d’accueil : suit automatiquement la langue Gaia, `fr`/`fr-*` en français,\n les autres en anglais; changement appliqué sans redémarrage.\n- SMS : identifiants compatibles avec le registre numérique Gaia, migration des\n anciens messages, dates de l’API et événement d’envoi corrigés; écritures sérialisées.\n- Calendrier : libellé d’accessibilité du jour courant retrouvé dans 37 langues.\n- Revue de 14 applications; 26 contrôles fonctionnels Galerie/Musique/Horloge/UI,\n tests Contacts et SMS, et navigation Web isolée. Voir `docs/APPLICATION-REVIEW.md`.\n\n### Android · APK\n\n- APK `2.7-preview.12` : même interface, correctifs et page locale; reconstruction\n et parité contrôlées. L’accès caméra reste celui d’Android via getUserMedia.\n- Aucun portage automatique des fonctions matérielles Tundra vers Android : chaque\n plateforme conserve son adaptateur et annonce ses capacités réelles.\n\n### Tundra · Sargo\n\n- Aperçu Photo : conversion NV21 par shader, transport binaire au lieu de base64;\n environ 23 images/s mesurées en RAM contre environ 8 dans l’essai intermédiaire.\n- Fermeture du worker attendue avant de rouvrir un autre capteur.\n- Luminosité manuelle : retrait des animations successives, environ 200 ms pour\n atteindre la valeur demandée lors du test matériel; transitions veille/réveil conservées.\n- Wi-Fi : état radio réel et activation/désactivation raccordés aux réglages Gaia;\n réseau mixte WPA2/WPA3 compatible avec le formulaire WPA-PSK.\n- Scan par Gaia, connexion et navigation HTTPS par wlan0 validés en RAM. Les\n profils de connexion restent temporaires; leur persistance est encore à réaliser.\n- Image v38 installée : deux démarrages RAM, démarrage installé, relecture de\n `boot_a`, préservation des autres partitions et validation du slot A réussis.\n- Flash photo, vidéo, capteur de luminosité/rotation, Bluetooth, comptage de données\n et fonctions avancées d’appel restent incomplets. Un écran qui s’ouvre ne vaut\n pas validation de tout son matériel.",
- "en": "## 2.7-preview.12 — application review · 27 September 2026\n\n### Shared · Gaia / Desktop\n\n- Restore original Gaia 1×–2.25× raster variants for 1,047 CSS image references.\n- Camera switch becomes available after device discovery; unavailable flash stays\n visible and disabled; pinching no longer zooms the whole camera application.\n- Reserve Home gestures for long press and suppress its context menu; retain Gaia's task manager.\n- Relay Gaia touch gestures using unprivileged DOM events, without granting trusted\n user activation to external websites.\n- Bundle the supplied bilingual, local browser welcome page and route links and\n searches into the actual browser.\n- Welcome page follows the Gaia language live: `fr`/`fr-*` select French;\n all other languages select English.\n- SMS: numeric identifiers, migration of older records, Date values, sent events,\n multi-recipient request shape and serialized storage writes.\n- Calendar: restore the current day's accessibility label in 37 locales.\n- Review 14 apps; 26 media/clock/UI checks, contact and SMS checks, plus real Web\n navigation and isolation. Scope and limitations: `docs/APPLICATION-REVIEW.md`.\n\n### Android · APK\n\n- Rebuild `2.7-preview.12` with the same shared UI and local welcome page; check\n payload parity. Android camera capture continues to use getUserMedia.\n\n- Tundra hardware features are not automatically ported to Android: each platform\n retains its adapter and reports its actual capabilities.\n\n### Tundra · Sargo\n\n- Convert NV21 using a shader and transfer binary frames instead of base64:\n approximately 23 fps measured in RAM, versus 8 fps in the intermediate trial.\n- Wait for camera worker exit before opening the other sensor.\n- Manual brightness reaches its target in about 200 ms in the hardware test;\n keep screen sleep/wake fades.\n- Connect Gaia Wi-Fi power settings to actual radio state; support WPA-PSK on\n mixed WPA2/WPA3 networks.\n- Scan, connection and HTTPS over wlan0 validated in RAM; connection profiles are temporary.\n- Wi-Fi profiles are still temporary. Flash, video, light/orientation sensors,\n Bluetooth, data counters and advanced call controls remain incomplete.\n- v38 installed: two RAM boots, installed startup, `boot_a` readback, other boot\n partitions preserved and slot A marked successful."
+ "fr": "## 2.7-preview.12 — revue des applications · 27 septembre 2026\n\n-  Icônes Gaia : variantes de 1× à 2,25× restaurées dans 1 047 références CSS.\n-  Photo : changement de capteur après détection et désactivation du zoom de l’interface au pincement. Flash indisponible grisé.\n-  Accueil : appui long et transmission des gestes rétablis, sans privilèges supplémentaires pour les sites web.\n-  Navigateur : page d’accueil locale bilingue, suivant la langue Gaia sans redémarrage.\n-  SMS : identifiants, migration, dates, événements d’envoi, requêtes multi-destinataires et écritures concurrentes corrigés.\n-  Calendrier : libellé d’accessibilité du jour courant rétabli dans 37 langues.\n-  Revue de 14 applications ; 26 contrôles Galerie/Musique/Horloge/UI, tests Contacts/SMS et isolation web.\n-  APK Preview12 reconstruit avec les mêmes ressources ; parité vérifiée. Fonctions matérielles adaptées séparément sur Android et Tundra.\n-  Sargo : aperçu caméra NV21 par shader et transport binaire, environ 23 i/s mesurées contre 8 dans l’essai intermédiaire.\n-  Sargo : fermeture du worker caméra attendue avant changement de capteur ; luminosité manuelle réglée en environ 200 ms.\n-  Sargo : activation Wi-Fi depuis Gaia et connexion WPA-PSK aux réseaux mixtes WPA2/WPA3.\n-  Sargo : scan, connexion et HTTPS validés en RAM. v38 installé après deux boots RAM, relecture et slot A vérifiés.\n-  Sargo : profils Wi-Fi temporaires ; flash, vidéo, capteurs, Bluetooth, comptage de données et appels avancés incomplets.\n",
+ "en": "## 2.7-preview.12 — application review · 27 September 2026\n\n-  Gaia icons: restored 1× to 2.25× variants across 1,047 CSS references.\n-  Camera: enable switching after sensor detection and prevent pinch zoom on the UI. Unavailable flash disabled.\n-  Home: restore long press and gesture forwarding without additional privileges for websites.\n-  Browser: local bilingual welcome page following Gaia’s language without restarting.\n-  SMS: corrected identifiers, migration, dates, sending events, multi-recipient requests and concurrent writes.\n-  Calendar: restored the current-day accessibility label in 37 languages.\n-  Reviewed 14 apps; 26 Gallery/Music/Clock/UI checks, Contacts/SMS tests and web isolation checks.\n-  Preview12 APK rebuilt with shared assets; parity verified. Hardware features handled separately on Android and Tundra.\n-  Sargo: shader-based NV21 preview and binary transport, about 23 fps measured versus 8 in the intermediate trial.\n-  Sargo: wait for camera worker shutdown before switching sensors; manual brightness reaches its target in about 200 ms.\n-  Sargo: Wi-Fi power control from Gaia and WPA-PSK connections to mixed WPA2/WPA3 networks.\n-  Sargo: scan, connection and HTTPS validated in RAM. v38 installed after two RAM boots, with readback and slot A checks.\n-  Sargo: temporary Wi-Fi profiles; flash, video, sensors, Bluetooth, data counters and advanced call controls incomplete.\n"
+ },
+ "metadata": {
+ "kind": "version",
+ "status": "built",
+ "platforms": [
+ "pc",
+ "apk",
+ "models"
+ ],
+ "models": [
+ "sargo"
+ ]
}
},
{
"id": "development-2026-09-27-4",
"text": {
- "fr": "## En développement — retours Sargo et interface · 27 septembre 2026\n\n### Commun · Gaia / PC\n\n- Animation d’arrêt : trois nuances bleues remplacent les ronds orange; dimensions,\n séquence et durées de l’animation Gaia sont conservées.\n\n- Téléphone : empêcher le défilement du document lors du changement d’onglet;\n les en-têtes Journal et Contacts restent en place. Les icônes du journal utilisent\n les images Gaia originales en plusieurs densités, jusqu’à 2,25×.\n- Menus déroulants : retrait du décalage prévu pour les anciens moteurs, qui coupait\n les jambages de Fira. Vidéo : texte de présentation contenu dans la fenêtre.\n- Luminosité : le curseur enregistre un nombre et répond aux événements input/change;\n les anciennes valeurs textuelles sont converties avant la transition Gaia. La file\n matérielle regroupe les pas obsolètes de luminosité sans réordonner veille/réveil.\n- Accueil : le fond d’écran continue sous le dégradé transparent. L’espace de navigation\n reste réservé dans les applications; en paysage, le bouton se place à droite.\n- Navigateur : page locale éditable dans `overrides/search.gaiamobile.org/home/index.html`.\n Les branchements de Gaia et le mode privé restent en place.\n- Wi-Fi : un échec affiche une explication et le bouton de reprise au lieu de relancer\n indéfiniment le scanner. La recherche et l’affichage dans le panneau Gaia ont\n maintenant été vérifiés sur Sargo en RAM.\n- Suivi consolidé : `docs/CHANTIER.md`, avec anciens et nouveaux retours, preuves et limites.\n\n### Android · APK\n\n- APK `2.7-preview.11` construit et signé avec la même base Gaia; parité vérifiée.\n Barre Accueil à droite en paysage et correction du fond en mode Accueil.\n Rotation et absence de recouvrement vérifiées sur émulateur Android 16.\n\n### Tundra · Sargo\n\n- Caméra : aperçu matériel dans Photo Gaia, JPEG et bascule arrière/avant validés\n en RAM sur Sargo; les capteurs se ferment au retour à l’accueil. Capture initiale\n limitée à 640 × 480 (480 × 640 en portrait); vidéo et flash non disponibles.\n L’interface reste commune au PC et à l’APK, qui conservent getUserMedia.\n\n- v33 (`2.7-preview.11`) installé après deux démarrages RAM réussis. Démarrage installé, relecture de\n `boot_a`, conservation des données et finalisation du slot A vérifiés.\n- Luminosité mesurée depuis Gaia : 35 % → 89/255, 80 % → 204/255. Résultats Wi-Fi\n visibles dans le panneau original. Animation d’arrêt bleue incluse.\n- Routage audio : prise en charge du format de PulseAudio 14; suppression du double\n chargement des modules audio. Passage au profil téléphonique et à l’écouteur, puis\n restauration du profil média, vérifiés sur Sargo. Conversation réelle encore à valider.\n- Restent ouverts : conversation audible dans les deux sens, capture pleine résolution/vidéo et\n raccordement des capteurs pour la rotation automatique. Voir la liste de suivi.",
- "en": "## In development — Sargo feedback and interface · 27 September 2026\n\n### Shared · Gaia / Desktop\n\n- Shutdown animation: three blue shades replace the orange rings; original Gaia\n dimensions, sequence and timing are preserved.\n\n- Phone: prevent document scrolling on tab changes, keeping Recents and Contacts\n headers in place. Use Gaia’s original call-log artwork at multiple densities up to 2.25×.\n- Select controls: remove the old engine offset that clipped Fira descenders.\n Video: keep the introductory text within the viewport.\n- Brightness: save a number from input/change events; normalize legacy text values\n before Gaia animates the transition. Coalesce stale hardware brightness steps\n while preserving the order of sleep/wake commands.\n- Home: extend wallpaper beneath the transparent gradient, reserve navigation space\n inside applications, and place Home on the right in landscape.\n- Browser: editable local page at `overrides/search.gaiamobile.org/home/index.html`,\n retaining Gaia integration and private mode.\n- Wi-Fi: show a scan failure and retry action instead of endless retries. Network\n discovery and results in the original Gaia panel now verified on Sargo in RAM.\n- Consolidated tracking: `docs/CHANTIER.md` includes earlier and new feedback and limitations.\n\n### Android · APK\n\n- Build and sign `2.7-preview.11` with shared Gaia; payload parity checked.\n Place Home on the right in landscape and fix its wallpaper background.\n Check rotation and non-overlapping app/navigation bounds on Android 16 emulator.\n\n### Tundra · Sargo\n\n- Camera: hardware preview in Gaia, JPEG capture and rear/front switching verified\n on Sargo in RAM; sensors close on returning Home. Capture is currently limited\n to 640 × 480 (480 × 640 portrait); video and flash remain unavailable.\n Desktop and APK keep getUserMedia and share the same Gaia interface.\n\n- v33 (`2.7-preview.11`) installed after two successful RAM boots. Installed boot, `boot_a` readback,\n data preservation and slot A success finalization verified.\n- Backlight measured from Gaia: 35% → 89/255, 80% → 204/255. Wi-Fi results visible\n in the original panel. Blue shutdown rings included.\n- Call audio: support PulseAudio 14 text output and prevent loading audio modules twice.\n Voice-call profile, earpiece selection and media-profile restoration verified on Sargo.\n An actual two-way conversation remains unvalidated.\n- Still open: audible two-way calls, full-resolution camera capture/video and sensor-driven rotation.\n See the tracking document."
+ "fr": "## En développement — retours Sargo et interface · 27 septembre 2026\n\n-  Animation d’arrêt Gaia recolorée en bleu.\n-  Téléphone : en-têtes fixes au changement d’onglet et icônes du journal adaptées aux densités jusqu’à 2,25×.\n-  Texte coupé dans les menus déroulants et débordement du texte d’accueil Vidéo.\n-  Luminosité : valeurs numériques et regroupement des commandes obsolètes, avec ordre veille/réveil préservé.\n-  Accueil : fond sous le dégradé et bouton à droite en paysage, sans recouvrir les applications.\n-  Navigateur : page d’accueil locale modifiable, avec mode privé conservé.\n-  Wi-Fi : message d’erreur et bouton Réessayer à la place des relances sans fin.\n-  APK Preview11 construit et signé ; rotation et position du bouton Accueil vérifiées sur Android 16 émulé.\n-  Sargo : aperçu, JPEG et bascules caméra validés en RAM. Capture limitée à 640 × 480 ; flash et vidéo indisponibles.\n-  Sargo v33 installé après deux boots RAM ; démarrage, données, relecture et slot A vérifiés. Luminosité et résultats Wi-Fi contrôlés dans Gaia.\n-  Sargo : compatibilité PulseAudio 14 et suppression du double chargement des modules audio. Profils appel/média et écouteur testés.\n-  Sargo : conversation audio, capture pleine résolution, vidéo et rotation automatique à valider.\n",
+ "en": "## In development — Sargo feedback and interface · 27 September 2026\n\n-  Gaia shutdown animation recoloured blue.\n-  Phone: keep headers fixed when changing tabs and use call-log icons up to 2.25× density.\n-  Clipped dropdown text and overflowing Video welcome text.\n-  Brightness: numeric values and coalesced stale commands, preserving sleep/wake order.\n-  Home: wallpaper under the gradient and right-side button in landscape, without covering apps.\n-  Browser: editable local welcome page, retaining private mode.\n-  Wi-Fi: error message and Retry button instead of endless retries.\n-  Preview11 APK built and signed; rotation and Home button placement checked on emulated Android 16.\n-  Sargo: preview, JPEG and camera switching validated in RAM. Capture limited to 640 × 480; flash and video unavailable.\n-  Sargo v33 installed after two RAM boots; startup, data, readback and slot A verified. Brightness and Wi-Fi results checked in Gaia.\n-  Sargo: PulseAudio 14 compatibility and duplicate audio module loading. Call/media profiles and earpiece tested.\n-  Sargo: call audio, full-resolution capture, video and automatic rotation await validation.\n"
+ },
+ "metadata": {
+ "kind": "development",
+ "status": "development",
+ "platforms": [
+ "pc",
+ "apk",
+ "models"
+ ],
+ "models": [
+ "sargo"
+ ]
}
},
{
"id": "development-2026-09-27-3",
"text": {
- "fr": "## En développement — stabilité Wi-Fi native · 27 septembre 2026\n\n### Commun · Gaia / PC\n\n- Correction de la boucle Wi-Fi sans délai : recherche toutes les 20 secondes,\n conformément à l’intervalle prévu par Gaia. Regroupement des scans simultanés.\n- Version de développement commune : `2.7-preview.9`, Gaia `2.7.0`; versions\n réelles de Gecko inchangées. Tests de rafales, erreurs et temporisation ajoutés.\n\n### Android · APK\n\n- Nouveau paquet `2.7-preview.9` pour conserver la même base Gaia. Le Wi-Fi Android\n reste géré par Android; ce correctif ne prétend pas ajouter un pilote à l’APK.\n\n### Tundra · Sargo\n\n- Cause du blocage v21/v22 identifiée : multiplication des clients Python de\n recherche Wi-Fi, épuisement mémoire et arrêt de Gecko par le lowmemorykiller.\n Le SIGSEGV de phoc survient ensuite pendant sa fermeture.\n- Un seul scan matériel en cours, cache court des résultats et des erreurs,\n invalidation après connexion/déconnexion. Aucun affaiblissement de l’isolation\n des applications, ni désactivation du mécanisme de protection mémoire.\n- Candidat v25 qualifié sur deux démarrages RAM, puis installé et vérifié sur le\n Pixel : 11 contrôles Gaia et 10 contrôles système passent, sans destruction de\n processus par manque de mémoire. Verrouillage Gaia, Fira, batterie, date et\n panneau Wi-Fi contrôlés; veille/réveil et volume testés par événements injectés.\n- Relecture de `boot_a` conforme, autres partitions de démarrage inchangées,\n données conservées et slot A marqué comme démarré avec succès. La caméra native\n et la validation complète des appels restent à terminer.",
- "en": "## In development — native Wi-Fi stability · 27 September 2026\n\n### Shared · Gaia / Desktop\n\n- Fix the Wi-Fi loop with no delay: scan every 20 seconds as intended by Gaia.\n Coalesce concurrent scans. Add burst, failure and timer regression tests.\n- Shared development version: `2.7-preview.9`, Gaia `2.7.0`; actual Gecko versions\n remain unchanged.\n\n### Android · APK\n\n- Build `2.7-preview.9` to retain shared Gaia parity. Android still manages its\n own Wi-Fi; this change does not claim to add a hardware driver to the APK.\n\n### Tundra · Sargo\n\n- Identify the v21/v22 failure: unbounded Python Wi-Fi scan clients exhausted\n memory, causing the lowmemorykiller to terminate Gecko. The phoc SIGSEGV occurs\n afterwards during display teardown.\n- Allow one hardware scan at a time, briefly cache results and errors, invalidate\n after connect/disconnect. Application isolation and memory protection remain enabled.\n- Qualify v25 through two RAM boots, then install and verify it on the Pixel:\n 11 Gaia and 10 system checks pass, with no memory-pressure process kills.\n Check the Gaia lockscreen, Fira, battery, date and Wi-Fi panel; exercise\n sleep/wake and volume through injected input events.\n- Verify `boot_a` by readback, preserve the other boot partitions and user data,\n and mark slot A successful. Native camera support and full call validation\n remain unfinished."
+ "fr": "## En développement — stabilité Wi-Fi native · 27 septembre 2026\n\n-  Wi-Fi : intervalle de recherche rétabli à 20 secondes et regroupement des scans simultanés.\n-  Sargo : un seul scan matériel à la fois, avec cache court et invalidation à la connexion/déconnexion. Corrige l’épuisement mémoire des builds v21/v22.\n-  Tests de rafales, erreurs et temporisation Wi-Fi.\n-  Version commune Preview9, Gaia 2.7.0 ; nouvel APK avec les mêmes sources. Wi-Fi Android toujours géré par Android.\n-  Sargo v25 : deux boots RAM puis installation, 11 contrôles Gaia et 10 système réussis, sans arrêt de processus par manque de mémoire.\n-  Verrouillage, polices, batterie, date et Wi-Fi vérifiés ; veille/réveil et volume testés par événements injectés. Données, relecture et slot A contrôlés.\n-  Caméra native et validation complète des appels encore à terminer.\n",
+ "en": "## In development — native Wi-Fi stability · 27 September 2026\n\n-  Wi-Fi: restore the 20-second scan interval and coalesce concurrent scans.\n-  Sargo: one hardware scan at a time, with a short cache cleared on connect/disconnect. Fixes memory exhaustion in v21/v22.\n-  Wi-Fi burst, failure and timer tests.\n-  Shared Preview9 version, Gaia 2.7.0; new APK with the same sources. Android still manages its own Wi-Fi.\n-  Sargo v25: two RAM boots then installation, 11 Gaia and 10 system checks passed, without low-memory process kills.\n-  Checked locking, fonts, battery, date and Wi-Fi; tested sleep/wake and volume with injected events. Data, readback and slot A verified.\n-  Native camera and full call validation still pending.\n"
+ },
+ "metadata": {
+ "kind": "development",
+ "status": "development",
+ "platforms": [
+ "pc",
+ "apk",
+ "models"
+ ],
+ "models": [
+ "sargo"
+ ]
}
},
{
"id": "development-2026-09-27-2",
"text": {
- "fr": "## En développement — restauration Gaia · 27 septembre 2026\n\n### Commun · Gaia / PC\n\n- Restauration des panneaux Gaia et suppression des fenêtres/boutons provisoires :\n Paramètres, Suivi conso, FM et import de contacts/médias. Les fonctions sans API\n restent visibles avec commandes désactivées, sans valeurs matérielles inventées.\n- Correction de la portée des anciens styles : les sélecteurs `div` ne sont plus\n transformés en racines de composants. Retour des icônes du verrouillage,\n conservation de Fira et glyphes Gaia vectoriels pour les onglets Téléphone.\n- Accueil dispose de 44 pixels CSS réservés et ne recouvre plus Décrocher/Raccrocher.\n La date utilise le composant historique; la batterie est actualisée dans Paramètres\n et ses libellés n’affichent plus `{{ level }}`.\n- Écran de verrouillage Gaia réactivé; splash provisoire avec le logo fourni,\n dimensions stables et animation douce. Nom localisé : Navigateur / Browser.\n Remerciements dans Informations légales, avis de licences conservés.\n- Tests PC : 26 contrôles applicatifs passent (Galerie, Musique, Horloge, tâches),\n panneaux, imports, batterie, date et navigation; appels simulés,\n journal, veille/réveil, menu Power et volume. Voir `docs/GAIA-RESTORATION.md`.\n\n### Android · APK\n\n- APK `2.7-preview.8` construit et installé sur l’émulateur Android 16, avec la même base Gaia; batterie lue depuis Android,\n Paramètres Android en bas de liste et mode Accueil Android optionnel. Le catalogue\n des applications Android dans Gaia reste à raccorder.\n- Validation Android : dix reprises après changement d’application et récupération\n après destruction du renderer. Parité du contenu partagé vérifiée; pas de test\n physique Android dans cette passe.\n- Les imports utilisent les composants Gaia; le bouton SMS ouvre la confirmation\n Android. Le verrouillage interne ne remplace pas la sécurité Android.\n\n### Tundra · Sargo\n\n- Candidats v21/v22 construits avec tous les correctifs `overrides/` dans le paquet de boot;\n raccordement scan/association Wi-Fi au panneau Gaia via NetworkManager.\n- Essais RAM bloqués : service graphique arrêté avec le code 139 avant qualification.\n Aucun nouveau flash. Retour sur v20 installé validé (11 contrôles Gaia, 9 système,\n données conservées); verrouillage du profil natif remis à son état précédent.\n Wi-Fi du candidat, caméra native, API SIM/Bluetooth et suspension CPU non qualifiés.",
- "en": "## In development — Gaia restoration · 27 September 2026\n\n### Shared · Gaia / Desktop\n\n- Restore Gaia panels and remove temporary windows/floating controls: Settings,\n Usage, FM and contact/media import. Features without an API remain visible with\n disabled controls, without invented hardware values.\n- Fix legacy style scoping: ordinary `div` selectors no longer become component\n roots. Restore lockscreen icons, retain Fira and use scalable Gaia glyphs for\n Phone tabs.\n- Reserve 44 CSS pixels for Home so it cannot cover Answer/Hang up. The date uses\n its original component; battery updates reach Settings and its labels no longer\n display `{{ level }}`.\n- Re-enable the original Gaia lockscreen; temporary splash uses the supplied logo,\n stable dimensions and a gentle animation. Localized name: Navigateur / Browser.\n Credits appear in Legal Information; original license notices remain.\n- All 26 application checks pass (Gallery, Music, Clock, task management). Further\n desktop checks cover panels, imports, battery, date and navigation; simulated\n calls, history, sleep/wake, Power menu and volume. See `docs/GAIA-RESTORATION.md`.\n\n### Android · APK\n\n- Build and install `2.7-preview.8` on the Android 16 emulator with shared Gaia; read battery state from Android,\n add Android Settings at the bottom and optional Android Home mode. The Android\n application catalogue in Gaia still needs integration.\n- Android checks: ten background/resume cycles and renderer termination recovery;\n shared payload parity verified. No physical Android device test in this pass.\n- Imports use Gaia components; the SMS button opens Android confirmation.\n The internal lockscreen does not replace Android security.\n\n### Tundra · Sargo\n\n- Build v21/v22 candidates with all `overrides/` fixes in the boot payload; connect Wi-Fi\n scan/association to the Gaia panel through NetworkManager.\n- RAM trials blocked: the display service exits with code 139 before qualification.\n No new flash. Restore installed v20 and verify 11 Gaia and 9 system checks with\n preserved data; restore the native profile’s previous unlocked state. Candidate\n Wi-Fi, native camera, full SIM/Bluetooth APIs and CPU suspend remain unqualified."
+ "fr": "## En développement — restauration Gaia · 27 septembre 2026\n\n-  Panneaux Gaia restaurés : Paramètres, Suivi conso, FM et imports. Interfaces provisoires retirées ; fonctions indisponibles désactivées.\n-  Styles des anciens composants, icônes du verrouillage et glyphes Téléphone corrigés.\n-  Espace de 44 pixels réservé au bouton Accueil ; date et batterie rétablies, sans texte `{{ level }}`.\n-  Verrouillage Gaia réactivé, splash stabilisé, nom Navigateur/Browser localisé et crédits ajoutés.\n-  PC : 26 contrôles applicatifs réussis, plus panneaux, imports, batterie, navigation et appels simulés.\n-  APK Preview8 installé sur Android 16 émulé : batterie Android, accès aux paramètres et mode HOME optionnel. Catalogue Android encore incomplet.\n-  Android émulé : dix reprises d’arrière-plan, récupération du rendu et parité des ressources vérifiées. Verrouillage Gaia distinct de la sécurité Android.\n-  Sargo v21/v22 : scan et association Wi-Fi raccordés à Gaia via NetworkManager.\n-  Sargo : essais RAM interrompus par l’arrêt du service graphique (code 139). Retour au v20 installé vérifié, sans nouveau flash.\n-  Wi-Fi du candidat, caméra, API SIM/Bluetooth et suspension CPU non validés.\n",
+ "en": "## In development — Gaia restoration · 27 September 2026\n\n-  Restored Gaia Settings, Usage, FM and import panels. Temporary interfaces removed; unavailable features disabled.\n-  Corrected legacy component styles, lockscreen icons and Phone glyphs.\n-  Reserve 44 pixels for Home; restore date and battery without `{{ level }}` placeholders.\n-  Gaia lockscreen re-enabled, splash stabilized, Browser name localized and credits added.\n-  Desktop: 26 app checks passed, plus panels, imports, battery, navigation and simulated calls.\n-  Preview8 APK installed on emulated Android 16: Android battery, settings access and optional HOME mode. Android app catalogue still incomplete.\n-  Emulated Android: ten background returns, renderer recovery and asset parity checked. Gaia locking remains separate from Android security.\n-  Sargo v21/v22: Wi-Fi scan and association connected to Gaia through NetworkManager.\n-  Sargo: RAM trials interrupted by display-service failure (code 139). Return to installed v20 verified, without a new flash.\n-  Candidate Wi-Fi, camera, SIM/Bluetooth APIs and CPU suspend unvalidated.\n"
+ },
+ "metadata": {
+ "kind": "development",
+ "status": "development",
+ "platforms": [
+ "pc",
+ "apk",
+ "models"
+ ],
+ "models": [
+ "sargo"
+ ]
}
},
{
"id": "development-2026-09-27-1",
"text": {
- "fr": "## En développement — 27 septembre 2026\n\n### Commun · Gaia / PC\n\n- Remplacement de l’écran d’appel provisoire par **Callscreen**, l’interface\n originale de Gaia : appel entrant/sortant, réponse, raccrochage et fermeture.\n L’adaptateur fournit les événements `mozTelephony` aux modules historiques.\n Les contrôles audio/conférence non encore raccordés restent grisés.\n- Journal d’appels persistant côté hôte, puis import dans le vrai journal Gaia,\n avec acquittement et détection des doublons après interruption.\n- Batterie : pont vers l’icône historique ; une batterie inconnue n’est pas\n présentée comme vide. Correction de l’index de contacts Facebook absent ou\n mal formé, qui bloquait aussi la recherche de numéro et l’historique local.\n- Polices : comparaison des glyphes réellement rendus. L’accueil utilisait\n déjà Fira Sans des deux côtés, mais certains textes système utilisaient Noto\n sur PC. Les règles de texte principales sélectionnent désormais la Fira de\n Gaia ; les polices d’icônes sont conservées.\n- Test Gecko avec modem simulé : appels entrants/sortants, boutons Répondre et\n Raccrocher, journal, veille/réveil, menu Power, volume et batterie.\n Aucun nouvel APK compilé dans cette étape.\n\n- Étape v13 (remplacée par Callscreen ci-dessus) : écran d’appel géré par l’application système : numérotation, sonnerie,\n communication et bouton Raccrocher. Les contrôles restent présents si le\n service perd momentanément l’état de l’appel.\n- Tests sur le vrai Gecko bureau avec modem simulé, y compris échec puis nouvel\n essai de raccrochage. Sources PC synchronisées ; aucun APK reconstruit.\n\n### Tundra · Sargo\n\n- Build privé v20 installé et relu dans `boot_a`, après deux démarrages RAM\n et un démarrage depuis le stockage ; réglages conservés, slot marqué réussi.\n- Lecture des seules touches Power/Volume du Sargo et\n transmission à `HardwareButtons`. Appui court : écran éteint/rallumé ; appui\n long : menu Gaia. Le broker natif commande rétroéclairage, volume, arrêt et\n redémarrage avec une liste d’opérations fixe.\n- Sérialisation des changements de luminosité et d’alimentation : deux\n requêtes simultanées ne peuvent plus laisser le rétroéclairage éteint au réveil.\n- Volume natif : accès en lecture au socket PulseAudio de la session, sans\n lequel l’indicateur Gaia changeait mais la sortie audio restait inchangée.\n- La veille écran ne suspend pas encore le noyau. Réception réelle des appels,\n audio de conversation et commandes audio avancées restent à qualifier.\n\n- Étape précédente : build privé v13 installé dans `boot_a` après deux démarrages RAM ; démarrage\n depuis le stockage, relecture et indicateur de réussite A/B vérifiés.\n- Transmission des appels et de leurs états depuis oFono ; commandes Répondre et\n Raccrocher limitées aux chemins d’appel du modem.\n- Correction des réponses D-Bus sans contenu : un raccrochage réussi ne provoque\n plus une fausse erreur de réponse invalide.\n- Un appel réel court atteint la sonnerie puis est raccroché automatiquement.\n Cela ne qualifie pas encore l’audio en conversation ni les appels entrants.\n- Caméra : diagnostic corrigé, sans annoncer une initialisation à partir d’un\n simple délai d’attente. Deux capteurs sont détectés via libhybris, mais le test\n de flux reçoit zéro image. La caméra Gaia native reste indisponible ; voir la\n [note technique](tundra/docs/CAMERA-SARGO.md).",
- "en": "## In development — 27 September 2026\n\n### Shared · Gaia / Desktop\n\n- Replace the temporary call overlay with Gaia’s original **Callscreen**:\n incoming/outgoing calls, answer, hang up and automatic close. A compatibility\n adapter supplies `mozTelephony` events to the original modules. Unsupported\n audio/conference controls remain disabled.\n- Persist completed calls in the host and import them into Gaia’s original call\n log, with acknowledgement and duplicate detection after interruption.\n- Connect real battery state to the original status icon; unknown is not empty.\n Handle a missing/malformed Facebook contacts index without blocking local\n number lookup or call history.\n- Compare rendered font faces: home already used Fira on both platforms, but\n desktop system text partly used Noto. Main text rules now select Gaia’s\n bundled Fira face while preserving icon fonts.\n- Desktop Gecko test with a simulated modem covers incoming/outgoing UI,\n answer/hang up, history, screen sleep/wake, power menu, volume and battery.\n No APK rebuilt in this step.\n\n- Earlier v13 step (superseded above): system-owned call screen with dialing, ringing, connected state and Hang up.\n Controls remain available if the service temporarily loses call status.\n- Tested in desktop Gecko with a simulated modem, including hang-up failure and\n retry. Desktop sources synchronized; no APK rebuilt.\n\n### Tundra · Sargo\n\n- Private v20 installed and read back in `boot_a` after two RAM boots and one\n installed boot; settings retained and slot marked successful.\n- Read only Sargo’s physical Power/Volume keys and forward\n their edges to Gaia HardwareButtons. Short press sleeps/wakes the screen;\n long press opens Gaia’s menu. The restricted native broker controls backlight,\n volume, shutdown and reboot.\n- Serialized brightness and power changes: concurrent requests no longer\n leave the backlight disabled while waking the screen.\n- Native volume: expose the session PulseAudio socket read-only to the service;\n otherwise Gaia’s indicator changed while the audio output remained unchanged.\n- Screen sleep does not suspend the kernel yet. Real incoming calls, conversation\n audio and advanced call audio controls remain to be qualified.\n\n- Earlier step: private v13 installed in `boot_a` after two RAM boots; installed boot,\n readback and the A/B success flag verified.\n- oFono call state reporting and Answer/Hang up commands restricted to modem call paths.\n- Successful void D-Bus responses no longer cause a false invalid-response error.\n- A short real outgoing call reached ringing and was automatically hung up.\n Conversation audio and incoming calls remain unqualified.\n- Camera diagnostics no longer mistake a timeout for successful initialization.\n Libhybris detects two sensors, but the preview probe receives no frames.\n Native Gaia camera access remains unavailable."
+ "fr": "## En développement — 27 septembre 2026\n\n-  Callscreen Gaia pour les appels entrants/sortants, avec réponse et raccrochage. Remplace l’écran provisoire v13.\n-  Journal d’appels persistant avec acquittement et dédoublonnage.\n-  Batterie inconnue distincte d’une batterie vide ; index Contacts absent ou mal formé toléré.\n-  Textes système affichés avec Fira Sans, sans modifier les polices d’icônes.\n-  PC avec modem simulé : appels, raccrochage en échec puis nouvel essai, journal, veille/réveil, menu Power, volume et batterie.\n-  Sargo : boutons Power/Volume raccordés à Gaia et commandes natives d’écran, volume, arrêt et redémarrage.\n-  Sargo : luminosité et alimentation sérialisées pour éviter l’écran noir au réveil ; accès PulseAudio corrigé pour le volume.\n-  Sargo : un raccrochage réussi avec réponse D-Bus vide ne produit plus de fausse erreur.\n-  Sargo v13 puis v20 installés après essais RAM ; démarrage, relecture, réglages et validation du slot contrôlés.\n-  Appel sortant réel arrivé à la sonnerie puis raccroché. Appels entrants et audio de conversation non validés.\n-  Diagnostic caméra : un timeout n’est plus présenté comme une initialisation réussie. Deux capteurs détectés, sans trames reçues.\n-  Caméra native indisponible, suspension CPU et commandes audio avancées incomplètes. Aucun nouvel APK.\n",
+ "en": "## In development — 27 September 2026\n\n-  Gaia Callscreen for incoming/outgoing calls, answer and hangup. Replaces the temporary v13 screen.\n-  Persistent call log with acknowledgement and duplicate detection.\n-  Distinguish unknown battery state from empty battery; tolerate missing or malformed Contacts indexes.\n-  Render system text with Fira Sans while preserving icon fonts.\n-  Desktop with simulated modem: calls, failed hangup and retry, history, sleep/wake, Power menu, volume and battery.\n-  Sargo: Power/Volume buttons connected to Gaia, with native screen, volume, shutdown and reboot controls.\n-  Sargo: serialize brightness and power changes to prevent a black screen on wake; fix PulseAudio access for volume.\n-  Sargo: successful hangup with an empty D-Bus reply no longer produces a false error.\n-  Sargo v13 then v20 installed after RAM trials; startup, readback, settings and slot validation checked.\n-  Real outgoing call reached ringing and was hung up. Incoming calls and conversation audio unvalidated.\n-  Camera diagnostics: a timeout no longer counts as successful initialization. Two sensors detected, without received frames.\n-  Native camera unavailable, CPU suspend and advanced audio controls incomplete. No new APK.\n"
+ },
+ "metadata": {
+ "kind": "development",
+ "status": "development",
+ "platforms": [
+ "pc",
+ "models"
+ ],
+ "models": [
+ "sargo"
+ ]
}
},
{
"id": "development-2026-09-26-1",
"text": {
- "fr": "## En développement — 26 septembre 2026\n\n### Commun · Gaia\n\n- Polices : Fira Sans OpenType du simulateur historique, avec les mêmes fichiers\n pour les pages Gaia et le système Tundra. Correction du repli `sans-serif` natif\n et vérification Fontconfig ajoutée ; les polices d’icônes restent distinctes.\n- Paramètres : retrait des boutons de diagnostic « Paramètres Tundra » et\n « Tester une notification ». Le résumé des versions et les informations\n techniques restent disponibles.\n- Panneau matériel : transmission des capacités de la plateforme au panneau.\n Une variable absente provoquait une alerte Gecko au lieu d’afficher la batterie.\n\n### PC\n\n- Adaptateur local synchronisé avec les sources communes. Gecko reste en 156.0.1.\n\n### Android\n\n- Correctif du panneau présent dans les sources communes ; aucun nouvel APK construit.\n\n### Tundra / Sargo\n\n- Validation explicite du succès A/B après installation : sauvegarde et contrôle\n des métadonnées GPT, outil Qualcomm vérifié, confirmation Fastboot.\n- Erreurs du modem distinguées des erreurs de transport ; boîte d’erreur intégrée\n à Gaia. La caméra native indisponible ne suggère plus une autorisation inutile.\n- Diagnostic des polices réellement utilisées par Gecko et du réseau radio, sans\n appel ni SMS automatique. Fira Sans confirmée sur l’accueil et la barre d’état.\n- Installation réelle du v9 polices dans `boot_a` sur le Sargo inventorié.\n Contenu écrit relu et conforme ; cinq autres partitions de boot sauvegardées intactes.\n- Deux démarrages depuis le stockage : 10 contrôles Gaia et 9 contrôles système\n réussis par boot, réglages conservés. Le PC n’est plus nécessaire pour démarrer.\n Fichiers de secours Droidian conservés ; restauration réelle non testée.\n- Candidat persistant privé : image de données ext4 2 Gio, profils et réglages Gaia\n conservés, identité/comptes système préservés et suppression du watchdog de dix minutes.\n- Deux démarrages successifs de la même image v8 : 9 contrôles Gaia et 9 contrôles\n système réussis par boot. Diagnostic séparé : 15 contrôles avec la même interface.\n- Installation limitée à `boot_a` et restauration de l’original préparées et testées\n localement ; aucun flash effectué. Les limites matérielles restent documentées.\n- Démarrage normal sans ouverture automatique de Paramètres/Horloge ni défilement\n de l’accueil. Ces gestes appartiennent désormais au mode explicite `--diagnostics`.\n- Plein écran établi avant le chargement de Gaia ; décorations natives désactivées.\n Sur le Pixel testé : 360 × 740 pixels CSS, facteur 3, dimensions stables et accueil\n inchangé pendant les cinq relevés après démarrage.\n- v13 testé en RAM : huit contrôles de démarrage et neuf contrôles système réussis.\n Les rapports distinguent cette vérification du démarrage des tests interactifs.\n- Diagnostic v14 séparé : treize contrôles interactifs réussis, dont le panneau\n matériel ; ouverture et scan Wi-Fi vérifiés. Même interface que le v13 normal.\n- Les images de diagnostic interactif ne peuvent pas devenir la sélection normale\n du lanceur. L’ancienne sélection v13 est conservée dans son historique.\n- Modem enregistré en UMTS pendant l’essai ; lecture audio réussie et signal du\n microphone mesuré sans conserver d’enregistrement. Cela ne qualifie pas encore\n l’audio en appel, la caméra ou la veille.\n- Construction du ramdisk indépendante de la langue du PC : analyse `readelf` en\n locale C pour conserver le chargeur dynamique ARM64 et ses dépendances.\n\nLes six partitions boot/DTBO/vbmeta A/B sont inchangées. Le build reste un essai\nprivé en RAM, **pas une ROM flashable**. Détails : [état de Tundra](tundra/docs/STATUS.md).",
- "en": "## In development — 26 September 2026\n\n### Shared · Gaia\n\n- Fonts: use the archived simulator’s Fira Sans OpenType files for both Gaia\n pages and Tundra system text. Fix native sans-serif fallback and add Fontconfig\n validation; icon fonts remain separate.\n- Settings: remove the “Tundra settings” and “Test a notification” diagnostic\n buttons. Release information and the technical details remain available.\n- Hardware panel: pass platform capabilities into the panel. An undefined variable\n used to open a Gecko alert instead of displaying battery information.\n\n### Desktop\n\n- Local adapter synchronized with the shared sources. Gecko remains at 156.0.1.\n\n### Android\n\n- Hardware panel fix is in the shared sources; no new APK has been built.\n\n### Tundra / Sargo\n\n- Explicit A/B boot-success finalization after installation: backed-up and checked\n GPT metadata, verified Qualcomm utility, Fastboot confirmation.\n- Modem rejections distinguished from transport failures; errors shown inside Gaia.\n The unavailable native camera no longer suggests an ineffective permission change.\n- Rendered-font and radio diagnostics without automatic calls or messages. Fira Sans\n confirmed on the home screen and status bar.\n- Installed the v9 font build into `boot_a` on the inventoried Sargo. Written\n image verified by readback; the other five backed-up boot partitions are unchanged.\n- Two boots from device storage pass 10 Gaia and 9 system checks each and retain\n settings. A PC is no longer required to boot. Droidian fallback files retained;\n actual restoration has not been tested.\n- Private persistent candidate: separate 2 GiB ext4 data image, retained Gaia\n settings/profiles and system identities, no ten-minute reboot watchdog.\n- Two consecutive boots of the same v8 image: 9 Gaia and 9 system checks pass on\n each boot. Separate diagnostic: 15 checks using identical shared UI files.\n- Boot-a-only installation and original-boot restoration tools prepared and tested\n locally. No actual flash performed; remaining hardware limits are documented.\n- Normal startup no longer opens Settings/Clock or scrolls the home screen. These\n actions now belong to the explicit `--diagnostics` mode.\n- Enter fullscreen before loading Gaia and hide native window decorations. On the\n tested Pixel: 360 × 740 CSS pixels at scale 3, stable dimensions and an unchanged\n home screen throughout five post-startup samples.\n- v13 RAM trial passes eight startup checks and nine system checks. Reports now\n distinguish startup observation from interactive qualification.\n- Separate v14 diagnostic: thirteen interactive checks pass, including the hardware\n panel; Wi-Fi panel and scanning verified with the same UI files as normal v13.\n- Interactive diagnostic images are rejected as the normal launch target. The\n earlier v13 selection is retained in the launcher history.\n- Modem registered on UMTS during the trial; audio playback completed and microphone\n samples were measured without retaining a recording. In-call audio, camera and\n suspend remain unqualified.\n- Ramdisk dependency discovery now runs `readelf` in the C locale, preserving the\n ARM64 dynamic loader regardless of the build machine's language.\n\nAll six A/B boot/DTBO/vbmeta partitions are unchanged. This remains a private RAM\ntrial, **not a flashable ROM**. See [Tundra status](tundra/docs/STATUS.md)."
+ "fr": "## En développement — 26 septembre 2026\n\n-  Polices Fira Sans historiques utilisées dans Gaia et Tundra ; repli sans-serif et contrôle Fontconfig corrigés.\n-  Boutons de diagnostic retirés des Paramètres.\n-  Panneau matériel : capacités de la plateforme transmises correctement pour afficher la batterie.\n-  Sargo : erreurs modem et transport distinguées ; message caméra indisponible corrigé.\n-  Sargo : données persistantes sur ext4 2 Gio et validation du succès A/B après installation.\n-  Sargo v8 : deux boots avec persistance, 9 contrôles Gaia et 9 système par boot. Outils d’installation et de secours testés localement.\n-  Sargo v9 installé sur boot_a : deux démarrages avec 10 contrôles Gaia et 9 système chacun. Relecture conforme, cinq autres partitions préservées ; restauration réelle non testée.\n-  Démarrage normal sans gestes de diagnostic automatiques ; plein écran établi avant Gaia, dimensions stables à 360 × 740 pixels CSS.\n-  Mode `--diagnostics` séparé ; images de diagnostic exclues du lancement normal.\n-  Sargo : v13 testé en RAM (8 contrôles de démarrage, 9 système), v14 en diagnostic (13 contrôles interactifs), Wi-Fi inclus.\n-  Enregistrement UMTS, lecture audio et signal du micro vérifiés. Audio d’appel, caméra et veille non validés.\n-  Construction du ramdisk indépendante de la langue du PC grâce à readelf en locale C.\n-  Essais privés, sans ROM publique. Sources PC synchronisées ; correctif commun disponible pour Android, sans nouvel APK.\n",
+ "en": "## In development — 26 September 2026\n\n-  Use historical Fira Sans fonts in Gaia and Tundra; correct sans-serif fallback and Fontconfig checks.\n-  Removed diagnostic buttons from Settings.\n-  Hardware panel: pass platform capabilities correctly to display the battery.\n-  Sargo: distinguish modem and transport errors; correct the unavailable-camera message.\n-  Sargo: persistent data on a 2 GiB ext4 image and A/B success validation after installation.\n-  Sargo v8: two boots with persistence, 9 Gaia and 9 system checks per boot. Installation and recovery tools tested locally.\n-  Sargo v9 installed on boot_a: two boots with 10 Gaia and 9 system checks each. Matching readback, five other partitions preserved; actual restoration untested.\n-  Normal startup without automatic diagnostic gestures; fullscreen before Gaia, stable 360 × 740 CSS-pixel layout.\n-  Separate `--diagnostics` mode; diagnostic images excluded from normal launch.\n-  Sargo: v13 tested in RAM (8 startup, 9 system checks), v14 diagnostics (13 interactive checks), including Wi-Fi.\n-  UMTS registration, audio playback and microphone signal checked. Call audio, camera and suspend unvalidated.\n-  Ramdisk builds independent of the desktop language by running readelf in the C locale.\n-  Private trials, without a public ROM. Desktop sources synchronized; shared Android fix available, without a new APK.\n"
+ },
+ "metadata": {
+ "kind": "development",
+ "status": "development",
+ "platforms": [
+ "pc",
+ "apk",
+ "models"
+ ],
+ "models": [
+ "sargo"
+ ]
}
},
{
"id": "development-2026-09-25-1",
"text": {
- "fr": "## En développement — 25 septembre 2026\n\n### Commun · Gaia\n\n- **Navigation :** bouton Accueil rond ; dégradé noir vers transparent au-dessus du\n fond d’écran, fond bleu uni dans les applications. L’appui modifie le cercle\n plutôt que de laisser un rectangle bleu sur l’accueil.\n- **Défilement :** le dégradé disparaît au bas du panneau de l’accueil ; il revient\n quand on remonte. Le changement de panneau Applications / Pages est pris en compte.\n- **Typographie :** polices Fira Sans embarquées, avec plusieurs graisses et italiques,\n au lieu de dépendre des polices installées sur le système hôte.\n- **Informations :** affichage séparé de Vulpes, Gaia, du moteur réellement exécuté,\n de la plateforme, du modèle et de l’adaptateur. Le panneau prévoit Tundra et\n l’adaptation matérielle, maintenant raccordés au backend natif Tundra.\n- **Icônes :** reprise du chargement après une première mesure de taille nulle,\n partagée entre PC, Android et Tundra. Le correctif était auparavant limité à Android.\n- **Maintenance :** ce journal centralise les évolutions des trois plateformes.\n\n### PC · Validé localement\n\n- Chargement d’une icône initialisée à taille nulle puis rendue visible : testé.\n- Navigation Accueil / Paramètres / retour Accueil, fond transparent en bas de page,\n dégradé en haut et contrôle des permissions testés dans un profil isolé.\n- Panneau Informations testé en français et en anglais, sans anciennes lignes en double.\n- Le moteur demeure Gecko **156.0.1** ; ces changements d’interface ne recompilent pas Gecko.\n\n### Android · Sources adaptées, Java compilé\n\n- Même comportement de navigation prévu par le pont GeckoView / Android : barre\n superposée sur l’accueil, place réservée dans les applications et le navigateur.\n- État de navigation conservé lors de la recréation de l’activité Android.\n- Compilation Java réussie. **Aucun nouvel APK livré pour ces changements** et\n comportement visuel encore à retester sur Android.\n\n### Tundra / Sargo · Essais privés en RAM\n\n- Build réseau v6 devenu référence du lanceur après 13 contrôles Gaia et 9\n contrôles système ; retour automatique et six partitions inchangées vérifiés.\n- Backend Tundra distinct du PC : modèle Pixel 3a, adaptation Sargo et version\n native affichés sans annoncer une caméra ou une téléphonie non raccordées.\n- Batterie et charge lues sur le matériel ; panneau d’état bilingue dans Gaia.\n 13 contrôles Gaia réussis sur l’essai réseau, dont ce nouveau panneau.\n- Wi-Fi réel testé : scan, connexion, IPv4, DNS et page HTTPS sous Gecko.\n Ajout de `pd-mapper`, désactivation d’IPA non initialisé et du sondage caméra\n prématuré ; 9 contrôles système passent, sans Oops noyau. La sélection du réseau\n dans Gaia reste à raccorder ; les identifiants du test restent en RAM.\n- Configuration des services embarquable dans le petit ramdisk : pas de nouvelle\n copie des 3 Gio pour chaque ajustement de démarrage. Partitions modem protégées\n en utilisant des copies NV en RAM pour les essais Qualcomm.\n\n- Nouvelle image système ext4 contenant le runtime ARM64, les HAL de référence,\n Phoc, Gecko et Gaia. Le runtime n’est plus chargé depuis l’arborescence de travail\n Droidian, ni le HAL depuis son ancien fichier `/var/lib/lxc/android/`.\n- Démarrage du second essai vérifié sur Pixel 3a : PID 1 Tundra, services HAL,\n compositeur et Gaia ; **7 contrôles Gaia et 8 contrôles système réussis**.\n- Capture physique de l’accueil contrôlée : icônes présentes, Fira Sans chargée,\n bouton rond et dégradé visibles. Gecko **156.0.1 ARM64**, WebRender matériel.\n- Petits changements d’interface embarqués dans le ramdisk d’essai : inutile de\n retransférer les 3 Gio du système pour chaque correction CSS/JavaScript.\n- Copie des sources de construction conservée avec les nouvelles images, vérifiée\n par empreintes, pour permettre de les retester après l’évolution du code courant.\n- Le dernier essai (`sargo-system-boot-20260925-v5`) valide **10 contrôles Gaia et\n 8 contrôles système**, dont icônes, police chargée, fond à l’appui, bas de page\n et retour depuis une application. Capture physique contrôlée ; référence locale\n du lanceur mise à jour. Aucun APK publié ni téléphone flashé.\n\n**Limites :** stockage installé encore utilisé comme support du fichier système ;\nmodifications perdues à l’arrêt ; retour automatique au système conservé après dix\nminutes. Aucun flash ni remplacement de Droidian. Le redémarrage à chaud du\ncompositeur a échoué lors d’un essai ; le démarrage à froid fonctionne. Réseau,\nmodem, audio, caméra et veille restent à intégrer ou qualifier. Ce n’est pas une\nROM publique installable ni un téléphone quotidien validé.\n\nDétails : [image système](tundra/docs/SYSTEM-IMAGE-SARGO.md),\n[état Tundra](tundra/docs/STATUS.md), [parité des plateformes](docs/PARITE-BUREAU-ANDROID.md).",
- "en": "## In development — 25 September 2026\n\n### Shared · Gaia\n\n- **Navigation:** a circular Home button, a black-to-transparent gradient over the wallpaper,\n and a solid blue bar inside apps. Pressing Home now changes the ring instead of leaving\n a blue rectangle over the home screen.\n- **Scrolling:** the gradient disappears at the bottom of the home panel and returns when\n scrolling up. Switching between the Apps and Pages panels is supported.\n- **Typography:** bundled Fira Sans fonts, with multiple weights and italics, instead of\n relying on the host system’s installed fonts.\n- **Device information:** separate product, Gaia, actual engine, platform, model and adapter\n versions. The panel includes support for Tundra and hardware adaptation fields;\n connecting these to the native platform is still pending.\n- **Icons:** loading resumes after an initial zero-width measurement, across desktop,\n Android and Tundra. This fix was previously limited to Android.\n- **Maintenance:** this changelog brings together the three platforms’ progress.\n\n### Desktop · Locally tested\n\n- Tested an icon initially measured at zero width, then made visible.\n- Tested Home / Settings / Home navigation, a transparent bar at the bottom, the gradient\n at the top and permission checks in an isolated profile.\n- Tested the information panel in French and English, without duplicate legacy rows.\n- The engine remains Gecko **156.0.1**; these UI changes do not rebuild Gecko.\n\n### Android · Sources adapted, Java compiled\n\n- The GeckoView / Android bridge implements the same navigation states: an overlay on Home,\n with space reserved for the bar in apps and the browser.\n- Navigation state survives Android activity recreation.\n- Java compilation passed. **No new APK delivered for these changes**;\n visual behaviour still needs testing on Android.\n\n### Tundra / Sargo · Private RAM trials\n\n- Network build v6 is the launcher reference after 13 Gaia and 9 system checks;\n automatic return and unchanged hashes of all six boot partitions verified.\n- Separate Tundra backend reports Pixel 3a, Sargo adaptation and native version,\n without claiming an unimplemented camera or telephony stack.\n- Real battery and charging state with a bilingual Gaia hardware panel.\n The network trial passed 13 Gaia checks, including this panel.\n- Real Wi-Fi tested: scan, connection, IPv4, DNS and HTTPS in Gecko. Added\n `pd-mapper`, disabled uninitialized IPA offload and premature camera probing;\n 9 system checks pass without kernel Oops. Gaia network selection is still\n pending; trial credentials remain in RAM.\n- Service configuration can travel in the small ramdisk, avoiding another 3 GiB\n transfer per boot adjustment. Qualcomm trials use RAM copies of modem NV data.\n\n- A new ext4 system image contains the ARM64 runtime, reference HALs, Phoc, Gecko and Gaia.\n The runtime is no longer loaded from the Droidian working directory, nor the HAL\n from its previous `/var/lib/lxc/android/` location.\n- The second Pixel 3a trial passed **7 Gaia checks and 8 system checks**, with Tundra PID 1,\n its HAL services, compositor and Gaia.\n- A physical display capture confirms home icons, loaded Fira Sans, the circular button\n and gradient. Gecko **156.0.1 ARM64**, with hardware WebRender.\n- Small UI changes travel in the trial ramdisk, avoiding another 3 GiB system transfer\n for each CSS or JavaScript adjustment.\n- Build sources are saved alongside new images and verified by hashes, so qualified\n images remain testable after the checkout changes.\n- The latest trial (`sargo-system-boot-20260925-v5`) passes **10 Gaia checks and 8 system\n checks**, including icons, fonts, pressed Home, bottom-of-page behaviour and app return.\n Physical capture reviewed; local launcher reference updated. No APK published or phone flashed.\n\n**Limitations:** the installed filesystem still carries the system image; writes are lost\nat shutdown; the trial returns to the retained system after ten minutes. No flashing or\nDroidian replacement. A warm compositor restart failed during one trial; cold boot works.\nNetworking, modem, audio, camera and suspend still need integration or qualification.\nThis is not a public installable ROM or a validated daily-use phone."
+ "fr": "## En développement — 25 septembre 2026\n\n-  Bouton Accueil rond, dégradé transparent sur le fond d’écran et barre bleue dans les applications.\n-  Dégradé masqué en bas de page et rétabli au défilement ; plus de rectangle bleu à l’appui sur Accueil.\n-  Fira Sans embarquée avec graisses et italiques ; versions produit, Gaia, moteur et plateforme affichées séparément.\n-  Reprise du chargement des icônes après une première mesure de taille nulle sur les trois plateformes.\n-  PC : icônes, navigation, permissions et panneau Informations FR/EN vérifiés avec Gecko 156.0.1.\n-  Android : état de navigation conservé après recréation de l’activité. Compilation Java réussie ; rendu à retester, aucun nouvel APK livré.\n-  Tundra : image ext4 avec runtime ARM64, HAL, Phoc, Gecko et Gaia ; petits correctifs embarqués dans le ramdisk.\n-  Sargo : backend dédié, informations Pixel 3a et lecture matérielle de la batterie. Sources de construction archivées avec empreintes.\n-  Sargo : WebRender matériel, icônes et polices contrôlés sur capture physique. v5 : 10 contrôles Gaia et 8 système réussis.\n-  Sargo : ajout de pd-mapper, désactivation d’IPA non initialisé et du sondage caméra prématuré. Données NV du modem copiées en RAM pour les essais.\n-  Sargo v6 : scan Wi-Fi, connexion, IPv4, DNS et HTTPS vérifiés ; 13 contrôles Gaia et 9 système réussis. Sélection du réseau dans Gaia encore à raccorder.\n-  Essais RAM sans flash, retour automatique après dix minutes et modifications perdues à l’arrêt. Démarrage à froid réussi ; redémarrage à chaud du compositeur en échec.\n-  Pas de ROM publique ; modem, audio, caméra et veille encore à intégrer ou valider.\n",
+ "en": "## In development — 25 September 2026\n\n-  Round Home button, transparent wallpaper gradient and blue bar inside apps.\n-  Hide the gradient at the bottom and restore it when scrolling; remove the blue rectangle when pressing Home.\n-  Bundled Fira Sans weights and italics; separate product, Gaia, engine and platform versions.\n-  Resume icon loading after an initial zero-size measurement on all three platforms.\n-  Desktop: icons, navigation, permissions and French/English information panel checked with Gecko 156.0.1.\n-  Android: retain navigation state after activity recreation. Java compilation passed; visuals need retesting, no new APK delivered.\n-  Tundra: ext4 image with ARM64 runtime, HAL, Phoc, Gecko and Gaia; small fixes bundled in the ramdisk.\n-  Sargo: dedicated backend, Pixel 3a information and hardware battery readings. Build sources archived with checksums.\n-  Sargo: hardware WebRender, icons and fonts checked on a physical display capture. v5: 10 Gaia and 8 system checks passed.\n-  Sargo: add pd-mapper, disable uninitialized IPA and premature camera probing. Copy modem NV data to RAM for trials.\n-  Sargo v6: Wi-Fi scan, connection, IPv4, DNS and HTTPS verified; 13 Gaia and 9 system checks passed. Gaia network selection still pending.\n-  RAM trials without flashing, automatic return after ten minutes and changes lost at shutdown. Cold boot passed; warm compositor restart failed.\n-  No public ROM; modem, audio, camera and suspend still need integration or validation.\n"
+ },
+ "metadata": {
+ "kind": "development",
+ "status": "development",
+ "platforms": [
+ "pc",
+ "apk",
+ "models"
+ ],
+ "models": [
+ "sargo"
+ ]
}
},
{
"id": "2.7-preview.7",
"text": {
- "fr": "## 2.7-preview.7 — APK construit, référence locale\n\n### Commun / PC / Android\n\n- Correction de l’autorisation interne d’ouverture du capteur sur PC et de la\n reprise après la première demande de permission CAMERA sur Android.\n- Messages Photo distincts pour un refus, un capteur absent ou un échec d’ouverture.\n- Version produit commune **2.7-preview.7**, Gaia **2.7.0** ; numéros du moteur séparés.\n- Gecko PC **156.0.1**, GeckoView Android **156.0** : l’écart réel de correctif est\n affiché, sans renommer artificiellement l’un des moteurs.\n\nDétails : [Photo et Galerie](docs/CAMERA-GALERIE.md), [README Android](android/README.md).",
- "en": "## 2.7-preview.7 — APK built, local reference\n\n### Shared / Desktop / Android\n\n- Fixed internal camera allocation permission on desktop and recovery after the first\n CAMERA permission prompt on Android.\n- Photo distinguishes permission denial, a missing sensor and an opening failure.\n- Shared product version **2.7-preview.7**, Gaia **2.7.0**, with separate engine versions.\n- Desktop Gecko **156.0.1**, Android GeckoView **156.0**: the real patch difference is\n reported rather than relabelling either engine."
+ "fr": "## 2.7-preview.7 — APK construit, référence locale\n\n-  Permission caméra sur PC et reprise après la première autorisation Android.\n-  Messages distincts pour une permission refusée, un capteur absent et un échec d’ouverture.\n-  Versions produit et moteur séparées : Vulpes Preview7, Gaia 2.7.0, Gecko PC 156.0.1 et GeckoView Android 156.0.\n",
+ "en": "## 2.7-preview.7 — APK built, local reference\n\n-  Desktop camera permission and recovery after the first Android permission prompt.\n-  Separate messages for denied permission, missing sensors and opening failures.\n-  Separate product and engine versions: Vulpes Preview7, Gaia 2.7.0, desktop Gecko 156.0.1 and Android GeckoView 156.0.\n"
+ },
+ "metadata": {
+ "kind": "version",
+ "status": "built",
+ "platforms": [
+ "pc",
+ "apk"
+ ],
+ "models": []
}
},
{
"id": "2.7-preview.6",
"text": {
- "fr": "## 2.7-preview.6 — APK construit\n\n### Commun / PC / Android\n\n- Portage de la véritable application Photo Gaia : aperçu et capture JPEG via\n webcam sur PC ou capteur via GeckoView sur Android.\n- Photos enregistrées dans Galerie Gaia ; import de médias par le sélecteur du système.\n- Commandes non prises en charge désactivées : notamment flash et vidéo ; changement\n de caméra selon les capteurs disponibles.",
- "en": "## 2.7-preview.6 — APK built\n\n### Shared / Desktop / Android\n\n- Ported the original Gaia Camera app: webcam preview and JPEG capture on desktop,\n or capture through GeckoView on Android.\n- Photos saved in Gaia Gallery; media imports use the host system’s file picker.\n- Unsupported controls disabled, including flash and video; camera switching depends\n on available sensors."
+ "fr": "## 2.7-preview.6 — APK construit\n\n-  Photo Gaia sur PC et Android : aperçu et capture JPEG via webcam ou GeckoView.\n-  Enregistrement dans Galerie et import par le sélecteur du système.\n-  Flash et vidéo désactivés ; changement de caméra selon les capteurs disponibles.\n",
+ "en": "## 2.7-preview.6 — APK built\n\n-  Gaia Camera on desktop and Android: preview and JPEG capture through webcam or GeckoView.\n-  Save photos in Gallery and import through the system picker.\n-  Flash and video disabled; camera switching depends on available sensors.\n"
+ },
+ "metadata": {
+ "kind": "version",
+ "status": "built",
+ "platforms": [
+ "pc",
+ "apk"
+ ],
+ "models": []
}
},
{
"id": "2.7-preview.5",
"text": {
- "fr": "## 2.7-preview.5 — APK construit\n\n### Commun\n\n- Versions centralisées dans `release.json`, séparation produit / Gaia / moteur.\n- Interface des fonctions natives partagée entre PC et Android ; contrôle du contenu\n commun réellement embarqué dans l’APK avant distribution.\n\n### PC\n\n- Intégration des panneaux de réglages Linux disponibles, import de médias et vCard,\n notifications du bureau et capture webcam de test.\n\n### Android\n\n- Même interface Gaia et commandes natives que le PC, adaptées aux capacités Android.\n- Tests de retour au premier plan et de récupération après arrêt du processus de rendu.",
- "en": "## 2.7-preview.5 — APK built\n\n### Shared\n\n- Centralised versions in `release.json`, separating product, Gaia and engine versions.\n- Shared native integration UI between desktop and Android; packaged common files\n checked inside the APK before distribution.\n\n### Desktop\n\n- Integration with available Linux settings panels, media and vCard imports,\n desktop notifications and a test webcam capture.\n\n### Android\n\n- Same Gaia interface and native commands as desktop, adapted to Android capabilities.\n- Foreground return and recovery after rendering-process termination tested."
+ "fr": "## 2.7-preview.5 — APK construit\n\n-  Versions centralisées dans `release.json` ; produit, Gaia et moteur séparés.\n-  Interface native commune PC/Android et contrôle de parité des ressources de l’APK.\n-  PC : réglages Linux, imports médias/vCard, notifications et capture webcam de test.\n-  Android : retour au premier plan et récupération après arrêt du processus de rendu.\n",
+ "en": "## 2.7-preview.5 — APK built\n\n-  Versions centralized in `release.json`; product, Gaia and engine separated.\n-  Shared desktop/Android native interface and APK asset parity checks.\n-  Desktop: Linux settings, media/vCard imports, notifications and test webcam capture.\n-  Android: foreground return and recovery after renderer termination.\n"
+ },
+ "metadata": {
+ "kind": "version",
+ "status": "built",
+ "platforms": [
+ "pc",
+ "apk"
+ ],
+ "models": []
}
},
{
"id": "2.7-156.0.2",
"text": {
- "fr": "## 2.7-156.0.2 — APK historique construit (révision 4)\n\nCe nom historique mélangeait révision du produit et version du moteur ; il ne\nsignifie pas que l’APK utilisait Gecko 156.0.2. Le moteur Android était Gecko 156.0.\n\n### Android\n\n- Reprise de l’activité et des sessions pour limiter les écrans blancs au retour dans Vulpes.\n- Accès aux réglages Android, au volume et à la luminosité de la fenêtre.\n- Import de médias et contacts, notifications natives avec permissions.\n- Transfert au composeur, à SMS et à Horloge Android ; aucun envoi silencieux ni\n remplacement des applications système Téléphone/SMS.\n- Capture initialement déléguée à l’application photo Android, avant le portage de Photo Gaia.",
- "en": "## 2.7-156.0.2 — Historical APK built (revision 4)\n\nThis historical name mixed product revision and engine version; it does not mean\nthat the APK used Gecko 156.0.2. Its Android engine was Gecko 156.0.\n\n### Android\n\n- Activity and session recovery to reduce white screens when returning to Vulpes.\n- Access to Android settings, volume and window brightness.\n- Media and contact imports, native notifications with permissions.\n- Handoff to Android’s dialer, SMS and clock apps; no silent sending or replacement\n of the system Phone and SMS apps.\n- Capture initially delegated to Android’s camera app, before the Gaia Camera port."
+ "fr": "## 2.7-156.0.2 — APK historique construit (révision 4)\n\n-  Android : reprise de l’activité et des sessions pour réduire les écrans blancs.\n-  Accès aux réglages Android, volume et luminosité de la fenêtre.\n-  Import de médias et contacts ; notifications natives avec permissions.\n-  Transfert vers Téléphone, SMS, Horloge et Appareil photo Android. Aucun envoi silencieux ni remplacement des applications par défaut.\n-  Numérotation historique : moteur Android Gecko 156.0, malgré le nom 2.7-156.0.2.\n",
+ "en": "## 2.7-156.0.2 — Historical APK built (revision 4)\n\n-  Android: activity and session recovery to reduce blank screens.\n-  Access to Android settings, volume and window brightness.\n-  Media and contact imports; native notifications with permissions.\n-  Handoff to Android Phone, SMS, Clock and Camera. No silent sending or replacement of default apps.\n-  Historical numbering: Android engine Gecko 156.0, despite the 2.7-156.0.2 name.\n"
+ },
+ "metadata": {
+ "kind": "version",
+ "status": "built",
+ "platforms": [
+ "apk"
+ ],
+ "models": []
}
},
{
"id": "2.7-156.0.1",
"text": {
- "fr": "## 2.7-156.0.1 — APK publié (révision 3)\n\n- Nom **Vulpes OS - Preview** et icône du projet.\n- Correctif du fond d’écran clignotant inclus.\n- Numérotation historique conservée ; ne pas en déduire le patch du moteur Android.\n\n[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-156.0.1)",
- "en": "## 2.7-156.0.1 — APK published (revision 3)\n\n- **Vulpes OS - Preview** name and project icon.\n- Wallpaper flickering fix included.\n- Historical numbering retained; it does not identify the Android engine’s patch version.\n\n[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-156.0.1)"
+ "fr": "## 2.7-156.0.1 — APK publié (révision 3)\n\n-  Nom « Vulpes OS - Preview » et icône du projet.\n-  Clignotement du fond d’écran corrigé.\n-  Numérotation historique, distincte de la version du moteur Android.\n\n[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-156.0.1)\n",
+ "en": "## 2.7-156.0.1 — APK published (revision 3)\n\n-  “Vulpes OS - Preview” name and project icon.\n-  Wallpaper flickering corrected.\n-  Historical numbering, separate from the Android engine version.\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 — APK publié\n\n- Correction du fond d’écran clignotant dans la Preview Android.\n\n[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-preview.2)",
- "en": "## 2.7-preview.2 — APK published\n\n- Fixed wallpaper flickering in the Android Preview.\n\n[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-preview.2)"
+ "fr": "## 2.7-preview.2 — APK publié\n\n-  Clignotement du fond d’écran dans la Preview Android.\n\n[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-preview.2)\n",
+ "en": "## 2.7-preview.2 — APK published\n\n-  Wallpaper flickering in the Android Preview.\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 — APK publié\n\n- Première Preview Android embarquant Gaia et GeckoView.\n- Version archivée ; clignotement du fond d’écran connu.\n\n[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-preview.1)",
- "en": "## 2.7-preview.1 — APK published\n\n- First Android Preview bundling Gaia and GeckoView.\n- Archived version; known wallpaper flickering issue.\n\n[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-preview.1)"
+ "fr": "## 2.7-preview.1 — APK publié\n\n-  Première Preview Android avec Gaia et GeckoView.\n-  Version archivée ; clignotement du fond d’écran connu.\n\n[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-preview.1)\n",
+ "en": "## 2.7-preview.1 — APK published\n\n-  First Android Preview with Gaia and GeckoView.\n-  Archived version; known wallpaper flickering issue.\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": "## Étapes antérieures — rétrospective technique\n\nCes étapes ne correspondent pas à de nouvelles releases publiques datées.\n\n### Gaia / PC\n\n- Constitution de Vulpes à partir de Gaia historique ; identité visuelle, fond\n d’écran, traductions et travail sur les informations système.\n- Portage expérimental Gecko 45 → 52, puis séparation de Gaia, des services et du\n moteur pour l’hôte Gecko récent.\n- Réimplémentation des API nécessaires : réglages, registre d’applications,\n contacts locaux, messages, alarmes, stockage et communications entre applications.\n- Adaptation des composants visuels anciens, des interactions tactiles simulées,\n du navigateur et des applications ; essais d’applications Firefox OS archivées.\n- Outillage de mise à jour Gecko avec vérifications, moteur candidat et retour\n arrière explicite. Il détecte les incompatibilités, sans les réparer automatiquement.\n- Lancement et mise à jour de Gaia sans recompilation complète du moteur.\n\n### Tundra / Sargo\n\n- Inventaire du Pixel 3a, sauvegardes des partitions de boot et tests USB de diagnostic.\n- Tests QEMU, puis Gecko ARM64 dans Droidian et sous un compositeur dédié.\n- Racine distincte, services HAL sélectionnés et PID 1 Tundra ; essais graphiques\n temporaires par Fastboot avec secours SSH et retour automatique.\n- Noyau de référence **4.9-124-google-sargo**, adaptation **Android 9 / API 28**.",
- "en": "## Earlier milestones — technical retrospective\n\nThese milestones are not newly dated public releases.\n\n### Gaia / Desktop\n\n- Vulpes assembled from historical Gaia: branding, wallpaper, translations and\n work on system information.\n- Experimental Gecko 45 → 52 port, followed by separation of Gaia, services and\n the engine for the current Gecko host.\n- Reimplementation of required APIs: settings, application registry, local contacts,\n messages, alarms, storage and inter-app communication.\n- Adaptation of old UI components, simulated touch, browser and applications;\n testing of archived Firefox OS apps.\n- Gecko update tools with checks, candidate engines and explicit rollback.\n They detect incompatibilities without automatically repairing them.\n- Gaia launch and updates without rebuilding the whole engine.\n\n### Tundra / Sargo\n\n- Pixel 3a inventory, boot partition backups and USB diagnostic trials.\n- QEMU trials, then ARM64 Gecko inside Droidian and under a dedicated compositor.\n- Separate root, selected HAL services and Tundra PID 1; temporary graphical\n Fastboot trials with rescue SSH and automatic return.\n- Reference kernel **4.9-124-google-sargo**, **Android 9 / API 28** adaptation."
+ "fr": "## Étapes antérieures — rétrospective technique\n\n-  Base Gaia historique avec identité Vulpes, fond d’écran, traductions et informations système.\n-  Portage expérimental Gecko 45 vers 52, puis séparation de Gaia, des services et du moteur.\n-  API de réglages, applications, contacts, messages, alarmes, stockage et communications entre applications.\n-  Adaptation des composants Gaia, gestes tactiles, navigateur et applications aux moteurs récents.\n-  Mise à jour Gecko avec contrôles et retour arrière ; mise à jour Gaia sans recompilation du moteur.\n-  Applications Firefox OS archivées, QEMU, puis Gecko ARM64 dans Droidian et sous compositeur dédié.\n-  Sargo : inventaire, sauvegardes de boot, racine distincte, services HAL et PID 1 Tundra.\n-  Essais graphiques Fastboot temporaires avec secours SSH et retour automatique.\n-  Noyau 4.9-124-google-sargo, adaptation Android 9/API 28. Étapes historiques, sans nouvelles releases datées.\n",
+ "en": "## Earlier milestones — technical retrospective\n\n-  Historical Gaia base with Vulpes branding, wallpaper, translations and system information.\n-  Experimental Gecko 45 to 52 port, followed by separation of Gaia, services and engine.\n-  APIs for settings, apps, contacts, messages, alarms, storage and inter-app communication.\n-  Adapt Gaia components, touch gestures, browser and apps to recent engines.\n-  Gecko updates with checks and rollback; Gaia updates without rebuilding the engine.\n-  Archived Firefox OS apps, QEMU, then ARM64 Gecko inside Droidian and under a dedicated compositor.\n-  Sargo: inventory, boot backups, separate root, HAL services and Tundra PID 1.\n-  Temporary graphical Fastboot trials with SSH recovery and automatic return.\n-  Kernel 4.9-124-google-sargo, Android 9/API 28 adaptation. Historical milestones, without newly dated releases.\n"
+ },
+ "metadata": {
+ "kind": "history",
+ "status": "history",
+ "platforms": [
+ "pc",
+ "models"
+ ],
+ "models": [
+ "sargo"
+ ]
}
}
],
@@ -254,6 +888,12 @@
"after": "2.7-preview.15",
"url": "https://nnsprod.com/git/oversu/VulpesOS/releases/tag/v2.7-preview.17",
"summary": "# Français\n\nAPK expérimental Android : Vulpes OS 2.7-preview.17, Gaia 2.7.0, GeckoView 156.0. Installation par-dessus une Preview précédente signée avec la même clé. Android 8.0/API26 minimum ; essais réalisés sur émulateur Android 16/API36.\n\n## 2.7-preview.17 — APK construit · 5 octobre 2026\n\n### Commun · Gaia / PC\n\n- Remettre Marketplace sur l’accueil avec son icône originale et le catalogue Vulpes. Ouvrir sa version française pour les langues fr, anglaise sinon.\n- Adapter les téléchargements du catalogue en installations confirmées : vérifier les archives ZIP, ajouter l’application à l’accueil et permettre son lancement et sa désinstallation.\n- Conserver les applications installées séparément des sources Gaia, sur des origines distinctes et sans privilèges système hérités des anciens manifestes.\n- Valider sur PC le parcours réel avec 2048 : catalogue, confirmation, installation, lancement, persistance et désinstallation. Contrôler les archives corrompues, chemins invalides, limites, annulation et confirmations à usage unique.\n\n### Android · APK\n\n- Construire et signer Preview17 avec le même Marketplace et le même service d’installation. Utiliser la vue GeckoView existante pour le catalogue et le stockage privé Android pour les paquets.\n- Vérifier sur émulateur Android 16/API36 le clic Installer, la confirmation, l’apparition sur l’accueil et le lancement de 2048 sans accès au pont système Vulpes. Vérifier aussi la conservation après arrêt forcé et relance, puis la désinstallation.\n\n## 2.7-preview.16 — APK construit · 5 octobre 2026\n\n### Commun · Gaia / PC\n\n- Rétablir les sélecteurs Gaia de fond d’écran et de sons, leur retour vers Paramètres et leur annulation par Accueil. Partager le transport des images et sons enregistrés entre PC et Android.\n- Propager les changements de langue à l’accueil, y compris sans permission de lecture générale des réglages. Vérifier les libellés FR/EN et la persistance.\n- Rétablir l’arrêt du balayage horizontal sur une page et masquer les applications de développement sur l’accueil, sans les supprimer des sources.\n- Utiliser le logo Vulpes fourni dans le splash et adapter les composants originaux de la liste des sons au moteur actuel.\n- Vérifier sur PC deux changements consécutifs de fond, le choix d’un son et les panneaux Gaia restaurés.\n\n### Android · APK\n\n- APK local Preview16 construit et signé ; contenu commun et version vérifiés. Aucune publication dans cette étape.\n- Valider Vulpes comme lanceur HOME, afficher le verrouillage Gaia après veille et rétablir le retour Android depuis la caméra ouverte au verrouillage.\n- Masquer les barres système Android en mode immersif, en conservant leur accès temporaire par balayage.\n- Tests sur émulateur Android 16/API36 : verrouillage, caméra/retour, FR/EN, accueil sans applications de test, fond d’écran et son. Fond conservé après arrêt forcé et relance, sans changement répété pendant 15 secondes. Autres appareils Android non qualifiés dans cette étape.\n\nLes correctifs caméra du 8 octobre ainsi que les derniers correctifs Wi-Fi, bannière et thème Marketplace du 6 octobre ne sont pas inclus dans cet APK. La validation de la caméra avant sur Sargo reste ouverte. Cette publication concerne uniquement Android.\n\n# English\n\nExperimental Android APK: Vulpes OS 2.7-preview.17, Gaia 2.7.0, GeckoView 156.0. Install over an earlier Preview signed with the same key. Android 8.0/API26 minimum; tested on an Android 16/API36 emulator.\n\n## 2.7-preview.17 — APK built · 5 October 2026\n\n### Shared · Gaia / Desktop\n\n- Restore Marketplace on Home with its original icon and the Vulpes catalogue. Select French for fr languages and English otherwise.\n- Turn catalogue downloads into confirmed installations: validate ZIP archives, add the application to Home and support launch and removal.\n- Store installed applications separately from Gaia sources, on distinct origins and without system privileges inherited from archived manifests.\n- Validate the real desktop workflow with 2048: catalogue, confirmation, installation, launch, persistence and removal. Check damaged archives, invalid paths, limits, cancellation and single-use confirmations.\n\n### Android · APK\n\n- Build and sign Preview17 with the same Marketplace and installation service. Use the existing GeckoView browser for the catalogue and private Android storage for packages.\n- Verify the Install click, confirmation, Home icon and 2048 launch without access to the Vulpes system bridge on an Android 16/API36 emulator. Also verify persistence after force-stop/relaunch and removal.\n\n## 2.7-preview.16 — APK built · 5 October 2026\n\n### Shared · Gaia / Desktop\n\n- Restore Gaia wallpaper and sound pickers, return navigation to Settings and cancellation through Home. Share persisted image and sound transport between desktop and Android.\n- Propagate language changes to Home without granting general settings read access. Check French/English labels and persistence.\n- Restore horizontal page snapping and hide development apps from Home without deleting their sources.\n- Use the supplied Vulpes logo in the splash and adapt the original sound-list components to the current engine.\n- Verify two consecutive wallpaper changes, sound selection and restored Gaia panels on desktop.\n\n### Android · APK\n\n- Build and sign local Preview16; verify shared payload and version. Nothing published in this step.\n- Validate Vulpes as the HOME launcher, restore Gaia locking after sleep and Android Back navigation from the lockscreen camera.\n- Hide Android system bars in immersive mode while retaining temporary swipe access.\n- Android 16/API36 emulator checks: locking, camera/Back, French/English, Home without test apps, wallpaper and sound. Wallpaper retained after force-stop/relaunch with no repeated changes over 15 seconds. Other Android devices not qualified in this step.\n\nThe October 8 camera changes and the later October 6 Wi-Fi, banner and Marketplace theme changes are not included in this APK. Front-camera validation on Sargo remains open. This release concerns Android only."
+ },
+ "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 — Preview 20\n\nAPK expérimental et sources communes PC / Android / Tundra mis à jour depuis la Preview 17. Les étapes 18 et 19 sont incluses dans cette version ; aucun ancien APK supplémentaire n’est publié.\n\n- Android 8 / API 26 minimum déclaré, API 37 ciblée. ARM64, ARMv7 et x86_64. Installer l’APK par-dessus une Preview signée avec la même clé conserve ses données.\n- Vérifier le téléchargement avec `sha256sum -c SHA256SUMS.txt`.\n- PC : `./setup.sh`, puis `./run.sh`. Sources Sargo incluses ; cette release ne distribue pas de nouvelle image native.\n- Vulpes 2.7-preview.20 · Gaia 2.7.0 · Gecko PC/Sargo 156.0.1 · GeckoView Android 156.0.\n\nAPK de développement signé, débogage via ADB autorisé. Appels/SMS délégués aux applications Android. Aucun engagement de compatibilité avec tous les appareils ; fonctions matérielles et services distants encore incomplets. L’historique ci-dessous distingue les tests de chaque plateforme.\n\n# 🇬🇧 Vulpes OS — Preview 20\n\nExperimental APK and shared desktop / Android / Tundra sources updated since Preview 17. Stages 18 and 19 are included; no additional older APKs are published.\n\n- Declared minimum Android 8 / API 26, target API 37. ARM64, ARMv7 and x86_64. Installing over a Preview signed with the same key preserves its data.\n- Verify your download with `sha256sum -c SHA256SUMS.txt`.\n- Desktop: `./setup.sh`, then `./run.sh`. Sargo sources included; this release supplies no new native image.\n- Vulpes 2.7-preview.20 · Gaia 2.7.0 · desktop/Sargo Gecko 156.0.1 · Android GeckoView 156.0.\n\nSigned development APK, debugging through authorized ADB. Calls/SMS delegated to Android applications. Not every device is qualified; hardware functions and remote services remain incomplete. The history below separates platform-specific test results.\n"
}
}
}
diff --git a/gaia/apps/camera/js/controllers/activity.js b/gaia/apps/camera/js/controllers/activity.js
index 9be7de3f..3f167e07 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 = 'video/3gpp';
+ media.type = media.blob.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 952d8633..89d29fa1 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 && type !== 'request-fail';
+ var closable = self.activity.pick || reason === 'request-fail';
var view = new OverlayView({
type: reason,
closable: closable,
@@ -118,6 +118,10 @@ 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 0f196f32..23a8058c 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', '.jpg');
+ poster.filepath = video.filepath.replace(/\.(?:3gp|mp4|webm)$/, '.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 ccd9d398..8462f776 100755
--- a/gaia/apps/camera/js/controllers/viewfinder.js
+++ b/gaia/apps/camera/js/controllers/viewfinder.js
@@ -102,6 +102,7 @@ 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 98ffb082..ba28ba98 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: '3gp', image: 'jpg'}
+ ext: {video: window.VulpesVideoExtension || '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 fcc18ac4..e92f6a5c 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', '.3gp');
+ filepath = filepath.replace('.jpg', '.' + (window.VulpesVideoExtension || '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', '.jpg');
+ var poster = filepath.replace(/\.(?:3gp|mp4|webm)$/, '.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 ed6f508a..d89e3c23 100755
--- a/gaia/apps/camera/js/views/viewfinder.js
+++ b/gaia/apps/camera/js/views/viewfinder.js
@@ -205,6 +205,9 @@ 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/dialer/js/telephony_helper.js b/gaia/apps/communications/dialer/js/telephony_helper.js
index 6d7b7052..cdf2ce0c 100755
--- a/gaia/apps/communications/dialer/js/telephony_helper.js
+++ b/gaia/apps/communications/dialer/js/telephony_helper.js
@@ -25,6 +25,19 @@ 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];
@@ -170,11 +183,13 @@ var TelephonyHelper = (function() {
call.addEventListener('connected', onconnected);
call.addEventListener('disconnected', function handleDisconnect(evt) {
- loadTelephonyMessages(function() {
- TelephonyMessages.handleDisconnect(
- evt.call.disconnectedReason, number, emergencyOnly
- );
- });
+ if (evt.call.disconnectedReason) {
+ 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 43607764..87fba101 100755
--- a/gaia/apps/gallery/js/MetadataParser.js
+++ b/gaia/apps/gallery/js/MetadataParser.js
@@ -651,25 +651,30 @@ var metadataParser = (function() {
function videoMetadataParser(file, metadataCallback, errorCallback) {
var metadata = {};
- 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;
+ 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;
}
- else if (typeof rotation === 'string') {
- errorCallback('Video rotation:', rotation);
- }
- });
- };
+ 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);
diff --git a/gaia/apps/settings/js/modules/date_time.js b/gaia/apps/settings/js/modules/date_time.js
index 42eae938..300c053c 100755
--- a/gaia/apps/settings/js/modules/date_time.js
+++ b/gaia/apps/settings/js/modules/date_time.js
@@ -139,10 +139,6 @@ 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;
@@ -320,9 +316,7 @@ define(function(require) {
switch (type) {
case 'date':
tokens = value.split('-');
- newDate.setYear(tokens[0]);
- newDate.setMonth(tokens[1] - 1);
- newDate.setDate(tokens[2]);
+ newDate.setFullYear(Number(tokens[0]), Number(tokens[1]) - 1, Number(tokens[2]));
break;
case 'time':
@@ -331,7 +325,8 @@ define(function(require) {
newDate.setMinutes(parseInt(tokens[1]));
break;
}
- this._mozTime.set(newDate);
+ if (!this._mozTime) return;
+ return 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 bc59abdc..1692d23d 100755
--- a/gaia/apps/settings/js/modules/dialog_manager.js
+++ b/gaia/apps/settings/js/modules/dialog_manager.js
@@ -99,37 +99,35 @@ define(function(require) {
_transit: function dm__transit(method, dialog, options) {
var promise = new Promise(function(resolve) {
var panel = dialog.panel;
-
- 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;
+ 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();
}
-
- // 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.
+ if (method === 'open') panel.hidden = false;
setTimeout(function() {
- if (method === 'open') {
- // Let's unhide the panel first
- panel.classList.add('current');
- } else {
- panel.classList.remove('current');
+ 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;
diff --git a/gaia/apps/settings/js/modules/dialog_service.js b/gaia/apps/settings/js/modules/dialog_service.js
index a9188715..8f96465c 100755
--- a/gaia/apps/settings/js/modules/dialog_service.js
+++ b/gaia/apps/settings/js/modules/dialog_service.js
@@ -190,29 +190,30 @@ define(function(require) {
if ('#' + panelId === currentPanel) {
defer.reject('You are showing the same panel #' + panelId);
} else {
- options.onWrapSubmit = function() {
- DialogManager.close(dialog, 'submit', options)
- .then(function(result) {
- defer.resolve({
- type: 'submit',
- value: result
- });
+ 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.onWrapCancel = function() {
- DialogManager.close(dialog, 'cancel', options)
- .then(function(result) {
- defer.resolve({
- type: 'cancel',
- value: result
- });
- self._navigating = false;
- self._execPendingRequest();
- });
- };
+ }
+ options.onWrapSubmit = function() { close('submit'); };
+ options.onWrapCancel = function() { close('cancel'); };
switch (options.type) {
case 'alert':
@@ -229,7 +230,17 @@ define(function(require) {
break;
}
this._navigating = true;
- DialogManager.open(dialog, options);
+ 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();
+ });
}
}
diff --git a/gaia/apps/settings/js/modules/settings_service.js b/gaia/apps/settings/js/modules/settings_service.js
index 4b81f1cf..767adbef 100755
--- a/gaia/apps/settings/js/modules/settings_service.js
+++ b/gaia/apps/settings/js/modules/settings_service.js
@@ -246,6 +246,17 @@ 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 e5f3b161..7e01cc98 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,9 +103,10 @@ define(function(require) {
* @access public
*/
_onAppChoose: function pl__onAppChoose(evt) {
- if (evt.target.dataset && evt.target.dataset.appIndex) {
+ var target = evt.target.closest('[data-app-index]');
+ if (target && this._listRoot.contains(target)) {
SettingsService.navigate('appPermissions-details', {
- app: this._apps[evt.target.dataset.appIndex],
+ app: this._apps[target.dataset.appIndex],
permissionsTable: this._permissionTable
});
}
@@ -262,7 +263,7 @@ define(function(require) {
* @return {Bool}
*/
_isExplicitPerm: function pl_isExplicitPerm(perm) {
- return mozPerms.isExplicit(perm, this._currentApp.manifestURL,
+ return mozPerms && 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 24a2e565..01c77d96 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 = 'http://support.mozilla.org/products/firefox-os';
+ var url = 'https://vulpes-os.org/';
this._elements.userGuide.addEventListener('click', () => {
this._elements.userGuide.blur();
var activity = new window.MozActivity({
@@ -55,7 +55,10 @@ define(function(require) {
.then(function loadSupportInfo(data) {
this._supportInfo = data;
callback(this._supportInfo);
- }.bind(this));
+ }.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
@@ -103,7 +106,7 @@ define(function(require) {
_renderSupportInfo: function support_renderSupportInfo(result) {
var onlineSupportInfo = null;
var onlineSupportTitle = result['support.onlinesupport.title'];
- if (onlineSupportTitle !== '') {
+ if (typeof onlineSupportTitle === 'string' && onlineSupportTitle !== '') {
onlineSupportInfo = { title: onlineSupportTitle };
onlineSupportInfo.href = result['support.onlinesupport.href'];
this._setOnlineSupportInfo(onlineSupportInfo);
@@ -114,7 +117,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 (callSupport1Title !== '') {
+ if (typeof callSupport1Title === 'string' && callSupport1Title !== '') {
this._callSupportInfo = [
{
'title': callSupport1Title,
@@ -125,7 +128,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 (callSupport2Title !== '') {
+ if (typeof callSupport2Title === 'string' && 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 b13186d2..d87a0810 100755
--- a/gaia/apps/settings/js/panels/screen_lock_passcode/panel.js
+++ b/gaia/apps/settings/js/panels/screen_lock_passcode/panel.js
@@ -66,8 +66,11 @@ define(function(require) {
},
_bindEvents: function() {
- this._elements.passcodeInput.onkeypress =
- this._onPasscodeInputKeypress.bind(this);
+ 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.passcodeContainer.onclick =
this._onPasscodeContainerClick.bind(this);
},
@@ -77,37 +80,14 @@ define(function(require) {
this._elements.passcodeInput.focus();
},
- _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;
+ _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();
}
-
- 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._passcodeBuffer = value;
this._updatePassCodeUI();
this._processPasscode();
},
@@ -134,6 +114,7 @@ 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 3958d8b4..261320af 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].getInfo();
+ var secondCardInfo = (this._simcards[1] ? this._simcards[1].getInfo() : {absent: true});
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].getInfo();
+ var secondCardInfo = (this._simcards[1] ? this._simcards[1].getInfo() : {absent: true});
// 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 e992f3f6..aa325547 100755
--- a/gaia/apps/settings/js/panels/wifi_status/panel.js
+++ b/gaia/apps/settings/js/panels/wifi_status/panel.js
@@ -41,7 +41,8 @@ define(function(require) {
var info = wifiManager.connectionInformation || {};
elements.ip.textContent = info.ipAddress || '';
document.l10n.setAttributes(elements.speed,
- 'linkSpeedMbs', { linkSpeed: info.linkSpeed });
+ Number.isFinite(info.linkSpeed) ? 'linkSpeedMbs' : 'unavailable',
+ Number.isFinite(info.linkSpeed) ? { linkSpeed: info.linkSpeed } : {});
}
});
};
diff --git a/gaia/apps/verticalhome/js/stores/item.js b/gaia/apps/verticalhome/js/stores/item.js
index e5fd249c..5da09703 100755
--- a/gaia/apps/verticalhome/js/stores/item.js
+++ b/gaia/apps/verticalhome/js/stores/item.js
@@ -439,7 +439,8 @@
*/
populate: function(callback) {
this.initSources(function(entries) {
- this.deferredSave(entries, callback);
+ // The first render waits for this write; deferring it waits for that render.
+ this.save(entries, callback);
}.bind(this));
},
diff --git a/gaia/apps/video/js/video.js b/gaia/apps/video/js/video.js
index ebeaf7a7..ab1cce7c 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$/;
+var FROMCAMERA = /DCIM\/\d{3}MZLLA\/VID_\d{4}\.(?:3gp|mp4|webm)$/;
// 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', '.jpg');
+ var postername = filename.replace(/\.(?:3gp|mp4|webm)$/, '.jpg');
navigator.getDeviceStorage('pictures'). delete(postername);
}
diff --git a/gaia/apps/wallpaper/style/wallpaper.css b/gaia/apps/wallpaper/style/wallpaper.css
index 0a581104..51e131ed 100755
--- a/gaia/apps/wallpaper/style/wallpaper.css
+++ b/gaia/apps/wallpaper/style/wallpaper.css
@@ -70,3 +70,6 @@ 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 70ff7080..58427d5e 100755
--- a/gaia/shared/js/download/download_helper.js
+++ b/gaia/shared/js/download/download_helper.js
@@ -113,6 +113,10 @@ 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();
@@ -394,6 +398,10 @@ 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
new file mode 100644
index 00000000..8e62a4c0
--- /dev/null
+++ b/host/Downloads.sys.mjs
@@ -0,0 +1,30 @@
+// 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 6c73c982..d4b8fc75 100644
--- a/host/Input.sys.mjs
+++ b/host/Input.sys.mjs
@@ -1,36 +1 @@
-// 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;
- }
-}
+export {Input} from 'resource://vulpes/services/input.mjs';
diff --git a/host/Messages.sys.mjs b/host/Messages.sys.mjs
index 02a88f0b..6f6730e2 100644
--- a/host/Messages.sys.mjs
+++ b/host/Messages.sys.mjs
@@ -1,10 +1,11 @@
// Desktop message store. Radio operations fail explicitly; no synthetic SMS.
export class Messages {
- constructor(storage, radio = null, notify = () => {}, radioStatus = null) {
+ constructor(storage, radio = null, notify = () => {}, radioStatus = null, mms = null) {
this.storage = storage;
this.radio = radio;
this.notify = notify;
this.radioStatus = radioStatus;
+ this.mms = mms;
this.queue = Promise.resolve();
}
receive(incoming) {
@@ -14,7 +15,8 @@ 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:'sms',sender:incoming.sender,receiver:null,
+ 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:[]} : {}),
body:incoming.body,timestamp:incoming.timestamp,delivery:'received',read:false};
records.push(record);
await this.storage.run('vulpes-private-messages','put',[records,'records']);
@@ -24,14 +26,16 @@ export class Messages {
return work;
}
refreshOutgoing() {
- if (!this.radioStatus) return Promise.resolve();
+ if (!this.radioStatus && !this.mms) 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 this.radioStatus(message.radioPath);
+ const result=await (message.type==='mms' ? this.mms.status(message.radioPath) : 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));
}
@@ -104,7 +108,34 @@ export class Messages {
this.notify('sent', structuredClone(record));
return record;
}
- if (['send', 'sendMMS', 'retrieveMMS', 'getSmscAddress', 'setSmscAddress'].includes(operation))
+ 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))
throw new Error('RadioDisabledError');
if (operation === 'getThreads') {
const threads = new Map();
@@ -115,7 +146,7 @@ export class Messages {
id: message.threadId,
body: message.body,
timestamp: message.timestamp,
- participants: [message.sender || message.receiver],
+ participants: message.delivery==='received' ? [message.sender] : message.type==='mms' ? message.receivers : [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 2ae36500..4afb80f1 100644
--- a/host/NativeCamera.sys.mjs
+++ b/host/NativeCamera.sys.mjs
@@ -17,8 +17,9 @@ export class NativeCamera {
return await Promise.race([
(async () => {
const pipe=session.process.stdout;
- const sequence=metadata ? null : await pipe.readUint32();
- const length=await pipe.readUint32();
+ 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);
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 84fe5cfb..4b8503cc 100644
--- a/host/Platform.sys.mjs
+++ b/host/Platform.sys.mjs
@@ -1,9 +1,12 @@
+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';
@@ -48,6 +51,25 @@ 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 (_) {}
@@ -80,6 +102,16 @@ 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();
@@ -136,48 +168,39 @@ export class Platform {
!request.secure ||
request.sharingScreen ||
request.sharingAudio ||
- request.audioInputDevices?.length ||
- !request.videoInputDevices?.length
+ !(request.videoInputDevices?.length || request.audioInputDevices?.length)
) {
deny();
return;
}
const principal = global.documentPrincipal;
- 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);
+ 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; }
}
- 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.
+ if (!(await actor.checkOSPermission(!!video, !!audio, false))) { deny(); return; }
const topPrincipal = browser.browsingContext.currentWindowGlobal.documentPrincipal;
- 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);
+ 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);
+ }
actor.sendAsyncMessage('webrtc:Allow', {
callID: request.callID,
windowID: request.windowID,
- devices: [request.videoInputDevices[0].deviceIndex],
+ devices,
});
}
async request(actor, app, operation, params) {
@@ -193,6 +216,9 @@ 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 e328a542..381f999c 100644
--- a/host/Runtime.sys.mjs
+++ b/host/Runtime.sys.mjs
@@ -1,3 +1,4 @@
+import {mmsPayload} from 'resource://vulpes/services/mms.mjs';
import { setInterval, setTimeout, clearTimeout } 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';
@@ -13,7 +14,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 } from 'resource://vulpes/host/Tundra.sys.mjs';
+import { tundraCallAudio, tundraHardware, tundraRadio, tundraMms } from 'resource://vulpes/host/Tundra.sys.mjs';
Cu.importGlobalProperties(['indexedDB']);
// This is the only privileged owner of application identity and settings data.
@@ -111,6 +112,7 @@ export const Runtime = {
}
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,
@@ -123,6 +125,19 @@ 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') {
@@ -156,6 +171,8 @@ 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);
@@ -301,9 +318,10 @@ export const Runtime = {
}
}
if (typeof method !== 'string') return { error: { code: 'INVALID_REQUEST' } };
- if (['activities.subscribe','activities.finish','activities.cancel'].includes(method) || (method === 'activities.start' && isGaiaActivity(params))) {
+ if (['activities.subscribe','activities.finish','activities.cancel','activities.select'].includes(method) || (method === 'activities.start' && isGaiaActivity(params))) {
try {
- const result = method === 'activities.cancel' ? this.activities.cancel(actor)
+ 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);
@@ -359,11 +377,28 @@ 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') {
@@ -517,7 +552,7 @@ export const Runtime = {
try {
const app = this.identity(actor);
if (!app || (target && app.id !== target)) continue;
- if (type === 'settingchange' && data.key !== 'language.current' && !this.grants(app).includes('settings.read')) continue;
+ if (type === 'settingchange' && !['language.current', 'vulpes.theme'].includes(data.key) && !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
new file mode 100644
index 00000000..952b0144
--- /dev/null
+++ b/host/SettingsAudit.sys.mjs
@@ -0,0 +1,133 @@
+// 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
new file mode 100644
index 00000000..38357bd5
--- /dev/null
+++ b/host/SettingsFollowupAudit.sys.mjs
@@ -0,0 +1,56 @@
+// 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
new file mode 100644
index 00000000..0c07bd25
--- /dev/null
+++ b/host/StorageStats.sys.mjs
@@ -0,0 +1,28 @@
+// 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 a6667461..3c5f29e9 100644
--- a/host/Tundra.sys.mjs
+++ b/host/Tundra.sys.mjs
@@ -42,6 +42,7 @@ export function tundraCapabilities(release) {
dial: radio,
callControl: radio,
sms: radio,
+ mms: radio,
alarms: false,
hardwareStatus: true,
wifiControl: Services.env?.get('VULPES_TUNDRA_WIFI') === '1',
@@ -50,7 +51,7 @@ export function tundraCapabilities(release) {
export async function tundraRadio(operation, params = {}) {
if (Services.env.get('VULPES_TUNDRA_RADIO') !== '1') throw Error('RADIO_UNAVAILABLE');
- if (!['status', 'calls', 'dial', 'hangup', 'answer', 'sms', 'incomingMessages', 'ackMessages', 'messageState'].includes(operation)) throw Error('RADIO_INVALID_REQUEST');
+ if (!['deviceSettings', 'mobileSettings', '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',
@@ -78,8 +79,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_REQUEST_FAILED', 'INVALID_NUMBER', 'INVALID_SMS', 'INVALID_CALL'];
+ 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'];
throw Error(errors.includes(reply.error) ? reply.error : 'RADIO_REQUEST_FAILED');
}
if (reply?.ok !== true) throw Error('RADIO_INVALID_REPLY');
@@ -92,11 +93,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', 'disconnect', 'status', 'setEnabled', 'known', 'forget'].includes(params.operation))
+ if (!['scan', 'connect', 'connectHidden', 'disconnect', 'status', 'setEnabled', 'known', 'forget','hotspotStatus','hotspotSave','hotspotPower'].includes(params.operation))
throw Error('WIFI_INVALID_REQUEST');
if (params.operation !== 'scan') {
try { return await wifiCommand(params); }
- finally { wifiScanCache = null; }
+ finally { if (['connect','connectHidden','disconnect','forget','setEnabled','hotspotPower'].includes(params.operation)) wifiScanCache = null; }
}
// System and Settings share a broker: at most one Python scan client exists.
if (wifiScanPending) return wifiScanPending;
@@ -176,3 +177,33 @@ 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/actors/VulpesChild.sys.mjs b/host/actors/VulpesChild.sys.mjs
index c69ed27f..960dd01f 100644
--- a/host/actors/VulpesChild.sys.mjs
+++ b/host/actors/VulpesChild.sys.mjs
@@ -1,16 +1,20 @@
+import {InputEditor} from 'resource://vulpes/services/input-editor.mjs';
export class VulpesChild extends JSWindowActorChild {
handleEvent(event) {
- if(['focusin','pointerdown'].includes(event.type)) {
- if(this.editing) return;
+ if(['focusin','pointerdown','click'].includes(event.type)) {
+ if(this._editor?.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) {
- if(event.type==='pointerdown') this.sendAsyncMessage('Vulpes:InputFocus',null);
+ // 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);
return;
}
- this.inputElement=element;
+ this.editor.focus(element);
this.sendAsyncMessage('Vulpes:InputFocus',this.inputState());
return;
}
@@ -76,69 +80,9 @@ export class VulpesChild extends JSWindowActorChild {
{ defineAs: 'vulpesFrameForContext' },
);
}
- 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();
- }
+ get editor() { return this._editor ||= new InputEditor(this.contentWindow); }
+ inputState() { return this.editor.state(); }
+ edit(params) { return this.editor.edit(params); }
receiveMessage(message) {
if(message.name==='Vulpes:Edit') return this.edit(message.data);
if (message.name === 'Vulpes:ViewState') {
@@ -146,9 +90,16 @@ export class VulpesChild extends JSWindowActorChild {
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}) &&
- [[.5,.5],[.05,.05],[.95,.05],[.05,.95],[.95,.95]].every(([x,y]) =>
- this.document.elementFromPoint(rect.left+rect.width*x,rect.top+rect.height*y)===frame);
+ 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') {
diff --git a/host/desktop.css b/host/desktop.css
index 40a36351..2f92504c 100644
--- a/host/desktop.css
+++ b/host/desktop.css
@@ -146,6 +146,12 @@ 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. */
@@ -200,3 +206,47 @@ html[data-vulpes-app='camera'], html[data-vulpes-app='camera'] body { touch-acti
/* 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
new file mode 100644
index 00000000..cc848e55
--- /dev/null
+++ b/host/gaia-bluetooth.js
@@ -0,0 +1,126 @@
+// 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 b056ff2d..63b46b0c 100644
--- a/host/gaia-camera.js
+++ b/host/gaia-camera.js
@@ -1,6 +1,8 @@
// 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,
@@ -8,11 +10,22 @@
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();
@@ -96,12 +109,33 @@
Object.defineProperty(HTMLMediaElement.prototype, 'mozSrcObject', {
configurable: true,
get() {
- return nativeSource.get.call(this);
+ return nativeViews.get(this) || nativeSource.get.call(this);
},
set(stream) {
- nativeSource.set.call(this, 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);
},
});
+ 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);
@@ -136,15 +170,20 @@
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 = document.createElement('canvas');
+ const canvas = nativeCanvas || (nativeCanvas = document.createElement('canvas'));
const sideways = meta.orientation % 180 !== 0;
- canvas.width = sideways ? meta.height : meta.width;
- canvas.height = sideways ? meta.width : meta.height;
+ 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;
// 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});
@@ -152,18 +191,21 @@
await call({action:'stop'});
throw new DOMException('Camera preview requires WebGL', 'NotSupportedError');
}
- const timing = {frames:0, startedAt:performance.now(), conversionMs:0};
- let stopped = false, timer, stream, sequence = -1, closing, program, buffer;
+ 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 = [];
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);
- gl.getExtension('WEBGL_lose_context')?.loseContext();
+ // The next lens reuses this context after its old stream has stopped.
+ gl.clearColor(0,0,0,1); gl.clear(gl.COLOR_BUFFER_BIT);
});
return closing;
};
@@ -210,17 +252,25 @@
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;
+ sequence = frame.sequence; timing.sensorFrames = 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.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.texImage2D(gl.TEXTURE_2D,0,gl.LUMINANCE_ALPHA,w/2,h/2,0,gl.LUMINANCE_ALPHA,gl.UNSIGNED_BYTE,data.subarray(w*h));
+ 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;
@@ -230,7 +280,11 @@
if (Date.now() > deadline) throw new DOMException('No camera frames', 'NotReadableError');
await new Promise(resolve => setTimeout(resolve, 50));
}
- stream = canvas.captureStream(24);
+ 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.vulpesTiming = timing;
stream.vulpesNative = true;
stream.vulpesCameraMetadata = meta;
@@ -245,15 +299,28 @@
const started = performance.now();
try { await draw(); }
catch (error) {
- if (!stopped) { recordError(error); stream.getTracks().forEach(track => track.stop()); }
+ if (!stopped) { recordError(error); await stop().catch(recordError); }
return;
}
- if (!stopped) timer = setTimeout(next, Math.max(0, 1000/24-(performance.now()-started)));
+ if (!stopped) timer = setTimeout(next, Math.max(0, 1000/30-(performance.now()-started)));
};
- timer = setTimeout(next, 1000/24);
+ timer = setTimeout(next, 1000/30);
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));
@@ -275,7 +342,7 @@
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 navigator.mediaDevices.getUserMedia({
+ stream = platform.platform === 'tundra' ? await nativeStream(name) : await captureVideo({
audio: false,
video: {
...(selected
@@ -284,7 +351,7 @@
width: { ideal: 1920 },
height: { ideal: 1080 },
},
- });
+ }, ticket, platform.platform);
} catch (error) {
recordError(error);
throw error;
@@ -304,44 +371,55 @@
throw new DOMException('Camera hidden', 'AbortError');
const track = stream.getVideoTracks()[0];
- 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));
+ 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};
+ }
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;
+ 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}};
const configuration = () => ({
- mode: 'picture',
+ mode,
previewSize: size,
pictureSize,
- recorderProfile: null,
+ recorderProfile: videoMime ? 'webm' : null,
});
Object.assign(stream, {
capabilities: {
pictureSizes,
previewSizes: [size],
thumbnailSizes: [],
- recorderProfiles: {},
+ recorderProfiles: videoMime ? {webm:profile} : {},
flashModes: ['off'],
focusModes: native ? (native.focusModes.includes('auto') ? ['auto','infinity'] : ['fixed']) : ['fixed'],
sceneModes: ['auto'],
@@ -359,8 +437,9 @@
zoom: 1,
async setConfiguration(value) {
if (value.pictureSize) stream.setPictureSize(value.pictureSize);
- if (value.mode && value.mode !== 'picture')
+ if (value.mode && !['picture',...(videoMime ? ['video'] : [])].includes(value.mode))
throw new DOMException('Video unavailable', 'NotSupportedError');
+ if(value.mode)mode=value.mode;
queueMicrotask(() => emit(stream, 'previewstatechange', { newState: 'started' }));
return configuration();
},
@@ -415,10 +494,66 @@
release() {
return releaseStream(stream, preview);
},
- async startRecording() {
- throw new DOMException('Video unavailable', 'NotSupportedError');
+ 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;
},
- stopRecording() {},
});
if (native) Object.defineProperty(stream,'zoom',{
configurable:true, get:()=>zoom,
@@ -433,7 +568,7 @@
});
dispatchEvent(
new CustomEvent('vulpes-camera-capabilities', {
- detail: { cameras: [...cameras], flash: false, video: false },
+ detail: { cameras: [...cameras], flash: false, video: !!videoMime },
}),
);
return { camera: stream, configuration: configuration() };
@@ -479,7 +614,7 @@
'Only one camera available',
'toggle-camera-rear',
],
- ['.mode-switch', true, 'Vidéo pas encore disponible', 'Video is not available yet', null],
+ ['.mode-switch', !videoMime, '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) {
@@ -489,7 +624,7 @@
e.setAttribute('aria-disabled', String(disabled));
if (!disabled) {
e.removeAttribute('title');
- e.setAttribute('aria-label', fr ? 'Changer de caméra' : 'Switch camera');
+ if(selector==='.js-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 b0c4627b..a56ecc2c 100644
--- a/host/gaia-compat.js
+++ b/host/gaia-compat.js
@@ -67,7 +67,25 @@
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();
- telephony.dial = number => call('platform.dial',{number});
+ 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.dialEmergency = unsupported;
telephony.sendTones = unsupported;
telephony.startTone = unsupported;
@@ -94,14 +112,17 @@
const state = ({active:'connected',waiting:'incoming'})[item.state] || item.state;
if (!c) {
changed = true;
- 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});
+ c = makeCall(item);
} 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';changes.push(c);changed=true;}
+ 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 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');
@@ -131,13 +152,13 @@
const settingsCodec = window.VulpesSettingsCodec;
async function call(method, params) {
if (!navigator.vulpesRequest) return Promise.reject(new Error('VULPES_HOST_UNAVAILABLE'));
- if (settingsCodec && (method === 'settings.set' ||
+ if (settingsCodec && (method === 'settings.set' || method === 'messages.request' ||
(window.VulpesPlatform === 'android' && ['media.request', 'activities.finish', 'contacts.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 &&
- ['settings.get', 'settings.snapshot', 'media.request', 'activities.start', 'contacts.request'].includes(method)
+ ['messages.request', 'settings.get', 'settings.snapshot', 'media.request', 'activities.start', 'contacts.request'].includes(method)
? settingsCodec.decode(reply.result)
: reply.result;
});
@@ -217,11 +238,15 @@
}
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') {
- dispatchEvent(new CustomEvent('secure-killapps'));
- window.Service?.request('lock').catch(console.error);
+ 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') {
@@ -230,6 +255,10 @@
}
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.
@@ -520,7 +549,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);
+ frame.setAttribute('allow', 'camera http://camera.localhost:' + location.port + '; microphone http://camera.localhost:' + location.port);
frame.addEventListener('load', () => {
if (!frame.hasAttribute('mozbrowser')) return;
if (webViews.has(frame)) return;
diff --git a/host/gaia-input.js b/host/gaia-input.js
index 662aee9b..59f6c7e3 100644
--- a/host/gaia-input.js
+++ b/host/gaia-input.js
@@ -1,6 +1,10 @@
// Keep Gaia's keyboard and layouts; replace its retired Gecko input-method API.
(() => {
- if (!window.VulpesNativeInput) return;
+ 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;
const app=location.hostname.split('.')[0], {call}=VulpesCompat;
if (app==='keyboard') {
let viewport, pendingState;
@@ -16,13 +20,14 @@
dispatchEvent(new Event('resize'));
if(pendingState!==undefined) {setState(pendingState);pendingState=undefined;}
});
- addEventListener('DOMContentLoaded', () => {
+ const ready = () => {
ViewManager.prototype.screenInPortraitMode = function() {
return viewport ? viewport.width<=viewport.height :
this.cachedWindowWidth<=this.cachedWindowHeight;
};
parent.postMessage({type:'vulpes-keyboard-ready'},'http://system.localhost:8765');
- }, {once:true});
+ };
+ if(document.readyState==='loading')addEventListener('DOMContentLoaded',ready,{once:true});else ready();
const ime=Object.assign(new EventTarget(),{inputcontext:null,mgmt:{
supportsSwitching:()=>false,
hide:()=>call('input.request',{operation:'hide'}),
@@ -93,4 +98,7 @@
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
new file mode 100644
index 00000000..628e1849
--- /dev/null
+++ b/host/gaia-mobile-settings.js
@@ -0,0 +1,366 @@
+// 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 d5d72a6f..1ac4bb39 100644
--- a/host/gaia-restoration.js
+++ b/host/gaia-restoration.js
@@ -3,6 +3,22 @@
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');
@@ -28,6 +44,14 @@
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;
@@ -50,8 +74,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;
- button.textContent = label('Carte SIM', 'SIM card');
- error.className = 'error-message';error.textContent = unavailable();
+ 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.');
li.append(button,error);list.prepend(li);
}
const item = document.getElementById('import-sd-option');
@@ -62,7 +86,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');span.textContent = label('Fichier de contacts', 'Contact file');
+ span.removeAttribute('data-l10n-id');localize(span, 'Fichier de contacts', 'Contact file');
}
const error = item.querySelector('p');
if(error && !error.hidden) error.hidden = true;
@@ -79,6 +103,17 @@
}, 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;
@@ -103,7 +138,7 @@
if (!header || header.querySelector('[data-vulpes-import]')) return;
const action = document.createElement('button');
action.dataset.vulpesImport = ''; action.dataset.icon = 'add';
- action.setAttribute('aria-label', label('Importer des fichiers', 'Import files'));
+ localize(action, 'Importer des fichiers', 'Import files', 'aria-label');
action.onclick = () => VulpesCompat.call('platform.pick',{type:mediaType}).catch(error => {
if (error.name !== 'AbortError' && error.message !== 'AbortError') notice(unavailable());
});
@@ -134,31 +169,79 @@
const unsupported = {
wifi: !navigator.mozWifiManager,
bluetooth: !navigator.mozBluetooth,
- carrier: !navigator.mozMobileConnections,
- call: !navigator.mozMobileConnections,
- messaging: !navigator.mozMobileConnections,
+ carrier: !window.VulpesMobileSettings,
+ call: !window.VulpesMobileSettings,
+ messaging: !navigator.mozMobileConnections?.length,
'sim-manager': !navigator.mozIccManager,
- simpin: !navigator.mozIccManager,
- hotspot: !navigator.mozWifiManager,
+ 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,
findmydevice: true,
fxa: true,
fxsync: true,
addons: true,
mediaStorage: !navigator.getDeviceStorages,
applicationStorage: !navigator.getDeviceStorage,
- usbStorage: !navigator.getDeviceStorages,
+ usbStorage: true,
};
function preservePanel(panel) {
- const content = panel.querySelector(':scope > div');
- if (!content || content.querySelector('[data-vulpes-unavailable]')) return;
+ const content = panel.querySelector(':scope > div') || panel;
+ if (content.querySelector('[data-vulpes-unavailable]')) return;
const explanation = document.createElement('p');
explanation.className = 'explanation'; explanation.dataset.vulpesUnavailable = '';
- explanation.setAttribute('role', 'status'); explanation.textContent = unavailable();
- content.prepend(explanation);
+ 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;
+ }
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);
}
@@ -168,9 +251,19 @@
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'], (cache, SettingsPanel) => {
+ require(['modules/panel_cache', 'modules/settings_panel', 'modules/dialog_panel'], (cache, SettingsPanel, DialogPanel) => {
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));
@@ -179,26 +272,70 @@
}, 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 = '#';
- a.textContent = label('Paramètres Android', 'Android settings');
+ localize(a, '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 = '#';
- a.textContent = label('Accueil Android par défaut', 'Default Android home');
+ localize(a, '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);
}
@@ -213,12 +350,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';
- 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.',
+ 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.',
'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']});
+ new MutationObserver(restore).observe(document.body, {subtree:true, childList:true, attributes:true, attributeFilter:['hidden','aria-disabled','disabled']});
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
new file mode 100644
index 00000000..c6b94c76
--- /dev/null
+++ b/host/gaia-select.js
@@ -0,0 +1,206 @@
+// 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 238282e8..de360e80 100644
--- a/host/gaia-services.js
+++ b/host/gaia-services.js
@@ -18,6 +18,21 @@
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'];
@@ -72,6 +87,23 @@
);
}
});
+ 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: () =>
@@ -222,6 +254,15 @@
}
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'])
@@ -242,11 +283,10 @@
'getSegmentInfoForText',
])
messages[operation] = (...args) => request(call('messages.request', { operation, args }));
- const dated = value => value && typeof value==='object' && 'timestamp' in value ?
- {...value,timestamp:new Date(value.timestamp)} : value;
+ const dated = raw => { const value=window.VulpesSettingsCodec.decode(raw);return 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));
- messages.send = (recipients,body,options) => {
- const send = recipient => {
+ const submit = (operation,args) => {
let id, settle;
const early = new Map();
const terminal = new Promise((resolve,reject)=>{settle=message=>{
@@ -260,7 +300,7 @@
else if(id===undefined && early.size<100) early.set(message.id,message);
};
addEventListener('vulpes-service-event',listener);
- const result=call('messages.request',{operation:'send',args:[recipient,body,options]}).then(message=>{
+ const result=call('messages.request',{operation,args}).then(message=>{
if(message.delivery!=='sending') return dated(message);
id=message.id;
if(early.has(id)) settle(early.get(id));
@@ -268,9 +308,12 @@
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;
@@ -281,17 +324,22 @@
Object.defineProperty(navigator, 'mozMobileMessage', { value: messages });
}
if (
- ['gallery', 'music', 'video', 'camera', 'ringtones'].includes(location.hostname.split('.')[0])
+ ['settings', 'gallery', 'music', 'video', 'camera', 'ringtones'].includes(location.hostname.split('.')[0])
) {
const stores = new Map();
function storage(type) {
- if (!['pictures', 'music', 'videos'].includes(type)) return null;
+ if (!['pictures', 'music', 'videos', ...(location.hostname==='settings.localhost'?['apps','sdcard']:[])].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 = {}) => call('media.request', { type, operation, ...params });
+ 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'));
+ };
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 01000cf2..1ece3fb7 100644
--- a/host/gaia-styles.js
+++ b/host/gaia-styles.js
@@ -109,6 +109,14 @@
// 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
new file mode 100644
index 00000000..1d91c0bf
--- /dev/null
+++ b/host/gaia-theme.css
@@ -0,0 +1,105 @@
+/* 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;
+}
diff --git a/host/gaia-theme.js b/host/gaia-theme.js
new file mode 100644
index 00000000..f6745cf3
--- /dev/null
+++ b/host/gaia-theme.js
@@ -0,0 +1,58 @@
+// 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 e2d81f9d..9de90ff4 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 = [];
+ let networks = [], connectionInfo = {};
function emit(type, values = {}) {
const event = Object.assign(new Event(type), values);
manager.dispatchEvent(event); manager['on' + type]?.(event);
@@ -21,7 +21,7 @@
const status = network ? 'connected' : 'disconnected';
const changed = manager.connection.status !== status || manager.connection.network?.ssid !== network?.ssid;
manager.connection = {status, network};
- manager.connectionInformation = network ? {relSignalStrength:network.relSignalStrength} : null;
+ manager.connectionInformation = network ? {...connectionInfo,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.
@@ -36,17 +36,23 @@
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' ? [] : [n.security === 'wpa-psk' ? 'WPA-PSK' : n.security === 'sae' ? 'SAE' : 'WPA-EAP'],
+ security:n.security === 'open' ? [] : [['wpa-psk','sae'].includes(n.security) ? 'WPA-PSK' : '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());
- 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}))));
+ 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.associate = network => request((async () => {
- 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 || ''});
+ 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 || ''});
await scan(); return network;
})());
manager.forget = network => request(call('platform.wifi',{operation:'forget',id:network.savedId}).then(()=>scan()));
@@ -54,6 +60,91 @@
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'});
@@ -67,6 +158,7 @@
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/platform-ui.js b/host/platform-ui.js
index 182d4947..241b2174 100644
--- a/host/platform-ui.js
+++ b/host/platform-ui.js
@@ -86,6 +86,18 @@
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;
@@ -145,7 +157,10 @@
event.preventDefault();
event.stopImmediatePropagation();
const number = document.getElementById('phone-number-view')?.value;
- if (number) run('dial', {number});
+ if (number) {
+ if (window.CallHandler) CallHandler.call(number, 0);
+ else window.VulpesPlatformDial(number).catch(() => {});
+ }
}, true);
async function initialize() {
const caps = await capabilities;
diff --git a/host/shell.js b/host/shell.js
index 2c57bc75..cfc1615f 100644
--- a/host/shell.js
+++ b/host/shell.js
@@ -30,7 +30,15 @@ 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; sequence = state.sequence; }
+ 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;
+ }
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');
@@ -46,7 +54,7 @@ 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} },
+ events: { DOMDocElementInserted: { capture: true }, focusin:{capture:true}, pointerdown:{capture:true}, click:{capture:true} },
},
allFrames: true,
safeForUntrustedWebProcess: true,
@@ -89,6 +97,11 @@ 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);
diff --git a/overrides/camera.gaiamobile.org/js/lib/dcf.js b/overrides/camera.gaiamobile.org/js/lib/dcf.js
new file mode 100644
index 00000000..ba28ba98
--- /dev/null
+++ b/overrides/camera.gaiamobile.org/js/lib/dcf.js
@@ -0,0 +1,101 @@
+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 e8443fe6..376b3c17 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 && type !== 'request-fail';
+ var closable = self.activity.pick || reason === 'request-fail';
var view = new OverlayView({
type: reason,
closable: closable
@@ -4274,6 +4274,10 @@ 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);
@@ -5452,6 +5456,9 @@ 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 = {
@@ -5702,6 +5709,7 @@ 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'));
@@ -6280,167 +6288,313 @@ 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';
- 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');
+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);
}
- 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);
+ });
+
+ 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.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',
@@ -6792,354 +6946,606 @@ define('controllers/camera', [
}
};
});
-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');
+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();
}
- 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);
- });
+
+ 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.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);
- };
- }
+ }
+
+ function refetchFile(filepath, absolutePath) {
+ var req = self.picture.get(filepath);
+ req.onerror = function() {
+ self.emit('error');
+ done('Error adding picture to storage');
};
- 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);
- };
- });
+ req.onsuccess = function(e) {
+ debug('image file blob handle retrieved');
+ var fileBlob = e.target.result;
+ done(null, filepath, absolutePath, fileBlob);
};
- 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();
+ }
+};
+
+/**
+ * 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.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
- });
+
+ req.onsuccess = function(e) {
+ videoStorage.delete(e.target.result);
+ done(null, filepath);
};
- 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) : '';
+ });
+};
+
+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;
+ }
}
+
+ 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';
- 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);
- }
- };
+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);
+ }
+};
+
});
window.jsStarted = Date.now();
define('main', [
diff --git a/overrides/gallery.gaiamobile.org/js/MetadataParser.js b/overrides/gallery.gaiamobile.org/js/MetadataParser.js
new file mode 100644
index 00000000..87fba101
--- /dev/null
+++ b/overrides/gallery.gaiamobile.org/js/MetadataParser.js
@@ -0,0 +1,713 @@
+'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
new file mode 100644
index 00000000..94691295
--- /dev/null
+++ b/overrides/gallery.gaiamobile.org/js/metadata_scripts.js
@@ -0,0 +1,1777 @@
+/* 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/settings.gaiamobile.org/js/main.js b/overrides/settings.gaiamobile.org/js/main.js
index fe93178a..a45439d4 100644
--- a/overrides/settings.gaiamobile.org/js/main.js
+++ b/overrides/settings.gaiamobile.org/js/main.js
@@ -38,6 +38,26 @@ var oldValue=this._array[index];this._array[index]=value;this._emitEvent('replac
*
* @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';
@@ -155,7 +175,6 @@ define('modules/page_transitions',[],function() {
}
};
});
-
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;}
@@ -214,23 +233,403 @@ return this._isOnRealDevice;},getCurrentLayout:function sl_getCurrentLayout(type
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();define("shared/screen_layout",(function(global){return function(){var ret,fn;return ret||global.ScreenLayout;};}(this)));define('modules/settings_service',['require','modules/page_transitions','modules/panel_cache','shared/screen_layout','shared/lazy_loader','settings'],function(require){'use strict';var PageTransitions=require('modules/page_transitions');var PanelCache=require('modules/panel_cache');var ScreenLayout=require('shared/screen_layout');var LazyLoader=require('shared/lazy_loader');var Settings=require('settings');var _rootPanelId=null;var _currentNavigation=null;var _navigating=false;var _pendingNavigationRequest=null;var _cachedNavigation=null;var _cachedNavigationOptions={};var _activityHandler=null;var _loadModulesForSubPanelsPromise=null;var _getAppNameToLink=function ss_get_app_name_to_link(panelId){var reAppName=/app:(\w+)/;var name=reAppName.exec(panelId);return name&&name[1];};var _getAppInfo=function ss_get_app_info(appName){var _supportedAppInFrame={keyboard:{},bluetooth:{}};var appInfo=_supportedAppInFrame[appName];if(!appInfo){return false;}
-var prefix='app://'+appName+'.gaiamobile.org/';var defaultSrc=prefix+'settings.html';var appMozapp=prefix+'manifest.webapp';var appSrc=appInfo.src?prefix+appInfo.src:defaultSrc;return{src:appSrc,mozapp:appMozapp};};var _isTabletAndLandscape=function ss_is_tablet_and_landscape(){return ScreenLayout.getCurrentLayout('tabletAndLandscaped');};var _retriveParentPanelId=function ss_retriveParentPanelId(panelId){var headerSelector='#'+panelId+' > gaia-header';var header=document.querySelector(headerSelector);return(header&&header.dataset.href||'').replace('#','');};var _shallCloseActivity=function ss_shallCloseActivity(panelId){if(panelId==='close'){return true;}
-if(!_currentNavigation){return false;}
-var parentPanelId=_retriveParentPanelId(_currentNavigation.panelId);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){callback();return;}
-panelElement.dataset.rendered=true;if(panelElement.dataset.requireSubPanels){var selector='section[id^="'+panelElement.id+'-"]';var subPanels=document.querySelectorAll(selector);for(var i=0,il=subPanels.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) {
+ 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);}
diff --git a/overrides/settings.gaiamobile.org/js/modules/date_time.js b/overrides/settings.gaiamobile.org/js/modules/date_time.js
new file mode 100644
index 00000000..b2532240
--- /dev/null
+++ b/overrides/settings.gaiamobile.org/js/modules/date_time.js
@@ -0,0 +1,419 @@
+/**
+ * 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
new file mode 100644
index 00000000..25f129a7
--- /dev/null
+++ b/overrides/settings.gaiamobile.org/js/modules/dialog_service.js
@@ -0,0 +1,483 @@
+
+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 d0a8914c..e2da4234 100644
--- a/overrides/settings.gaiamobile.org/js/panels/display/panel.js
+++ b/overrides/settings.gaiamobile.org/js/panels/display/panel.js
@@ -1,4 +1,52 @@
+/**
+ * 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.
@@ -128,6 +176,5 @@ 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
new file mode 100644
index 00000000..84a028a3
--- /dev/null
+++ b/overrides/settings.gaiamobile.org/js/panels/feedback_send/panel.js
@@ -0,0 +1,492 @@
+
+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
new file mode 100644
index 00000000..1710566e
--- /dev/null
+++ b/overrides/settings.gaiamobile.org/js/panels/firefox_accounts/panel.js
@@ -0,0 +1,508 @@
+
+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
new file mode 100644
index 00000000..ea67ba9f
--- /dev/null
+++ b/overrides/settings.gaiamobile.org/js/panels/firefox_sync/panel.js
@@ -0,0 +1,505 @@
+
+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
new file mode 100644
index 00000000..cd804001
--- /dev/null
+++ b/overrides/settings.gaiamobile.org/js/panels/help/panel.js
@@ -0,0 +1,171 @@
+
+/**
+ * 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
new file mode 100644
index 00000000..df4ab184
--- /dev/null
+++ b/overrides/settings.gaiamobile.org/js/panels/keyboard_add_layouts/panel.js
@@ -0,0 +1,490 @@
+
+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
new file mode 100644
index 00000000..33fa6a19
--- /dev/null
+++ b/overrides/settings.gaiamobile.org/js/panels/screen_lock/panel.js
@@ -0,0 +1,486 @@
+
+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
new file mode 100644
index 00000000..ace8f3b1
--- /dev/null
+++ b/overrides/settings.gaiamobile.org/js/panels/simcard_manager/panel.js
@@ -0,0 +1,17 @@
+
+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 b9922f32..1ed81789 100644
--- a/overrides/settings.gaiamobile.org/js/panels/wifi/panel.js
+++ b/overrides/settings.gaiamobile.org/js/panels/wifi/panel.js
@@ -259,7 +259,6 @@ 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
new file mode 100644
index 00000000..323143a5
--- /dev/null
+++ b/overrides/settings.gaiamobile.org/js/panels/wifi_select_certificate_file/panel.js
@@ -0,0 +1,485 @@
+
+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
new file mode 100644
index 00000000..00a7a38f
--- /dev/null
+++ b/overrides/settings.gaiamobile.org/js/panels/wifi_status/panel.js
@@ -0,0 +1,50 @@
+
+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
new file mode 100644
index 00000000..ff1e2856
--- /dev/null
+++ b/overrides/settings.gaiamobile.org/resources/support.json
@@ -0,0 +1 @@
+{"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
new file mode 100644
index 00000000..58427d5e
--- /dev/null
+++ b/overrides/settings.gaiamobile.org/shared/js/download/download_helper.js
@@ -0,0 +1,693 @@
+'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/video.gaiamobile.org/gaia_build_defer_index.js b/overrides/video.gaiamobile.org/gaia_build_defer_index.js
new file mode 100644
index 00000000..450001a5
--- /dev/null
+++ b/overrides/video.gaiamobile.org/gaia_build_defer_index.js
@@ -0,0 +1,1177 @@
+;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 0868108a..f9e1bc7d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6,6 +6,7 @@
"": {
"name": "vulpes-maintenance",
"devDependencies": {
+ "esprima": "4.0.1",
"prettier": "3.6.2",
"prettier-plugin-java": "2.7.7"
},
@@ -85,6 +86,20 @@
"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 8eeeef80..187e09ed 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,7 @@
"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 2e7fb88a..7eb7c4a2 100644
--- a/release.json
+++ b/release.json
@@ -1,15 +1,15 @@
{
"gaia": "2.7.0",
"channel": "preview",
- "revision": 17,
- "androidVersionCode": 17,
+ "revision": 20,
+ "androidVersionCode": 20,
"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.17",
+ "version": "2.7-preview.20",
"adapters": {
"desktop": "0.1.0-dev",
"android": "0.1.0-dev"
diff --git a/services/activities.mjs b/services/activities.mjs
index 4072ca49..2344e622 100644
--- a/services/activities.mjs
+++ b/services/activities.mjs
@@ -1,5 +1,7 @@
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');
@@ -14,19 +16,24 @@ function entryPointFor(app, path) {
// Route Gaia's built-in pickers; only the selected provider can finish a request.
export class Activities {
- constructor({identity, apps, launch, notify, uuid}) {
- Object.assign(this, {identity, apps, launch, notify, uuid});
+ 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.some(t=>['webcontacts/contact','webcontacts/tel','webcontacts/email','webcontacts/select'].includes(t))) id='communications';
- else if (types.some(t=>typeof t==='string' && t.startsWith('image/'))) id=caller?.id==='communications'?'gallery':'wallpaper';
- } else if (name === 'dial' && types.includes('webtelephony/number')) id='communications';
+ 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);
@@ -48,9 +55,54 @@ export class Activities {
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 {app:provider,definition,url,entryPoint}=this.route(source,app);
+ 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) => {
@@ -102,8 +154,13 @@ export class Activities {
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) {
diff --git a/services/input-editor.mjs b/services/input-editor.mjs
new file mode 100644
index 00000000..bb529f01
--- /dev/null
+++ b/services/input-editor.mjs
@@ -0,0 +1,68 @@
+// 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
new file mode 100644
index 00000000..6c73c982
--- /dev/null
+++ b/services/input.mjs
@@ -0,0 +1,36 @@
+// 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
new file mode 100644
index 00000000..ff96dc15
--- /dev/null
+++ b/services/mms.mjs
@@ -0,0 +1,29 @@
+// 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/platform.mjs b/services/platform.mjs
index ce8a9dee..0eab2500 100644
--- a/services/platform.mjs
+++ b/services/platform.mjs
@@ -13,6 +13,9 @@ export function authorizePlatform(app, operation, params = {}) {
settings: ['settings', 'system', 'costcontrol'].includes(app.id),
snapshot: ['settings', 'system', 'costcontrol'].includes(app.id),
wifi: ['settings', 'system'].includes(app.id),
+ mobileSettings: app.id === 'settings',
+ deviceSettings: app.id === 'settings',
+ bluetooth: ['settings', 'system', 'bluetooth'].includes(app.id),
notification: !!permissions['desktop-notification'],
capture: app.id === 'camera' && writeMedia('pictures'),
cameraPreview: app.id === 'camera' && writeMedia('pictures'),
@@ -30,6 +33,11 @@ export function authorizePlatform(app, operation, params = {}) {
};
if (!Object.hasOwn(allowed, operation)) throw Error('METHOD_NOT_SUPPORTED');
if (!allowed[operation]) throw Error('PERMISSION_DENIED');
+ if (operation === 'wifi' && ['hotspotStatus','hotspotSave','hotspotPower'].includes(params.operation) && app.id !== 'settings') throw Error('PERMISSION_DENIED');
+ if (operation === 'mobileSettings' && !['sim','smsc','calls','messages','deliveryReports','network','dataEnabled','roaming','radioMode','setRadioMode','apns','editApn','setCallWaiting','setCallerId','forwarding','setForwarding'].includes(params.action)) throw Error('INVALID_VALUE');
+ if (operation === 'mobileSettings' && ['deliveryReports','dataEnabled','roaming','setCallWaiting','setForwarding'].includes(params.action) && typeof params.enabled !== 'boolean') throw Error('INVALID_VALUE');
+ if (operation === 'mobileSettings' && JSON.stringify(params).length > 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))))
@@ -47,7 +55,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 (operation === 'wifi' && JSON.stringify(params).length > 4096)
+ if (['wifi', 'bluetooth'].includes(operation) && 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 c85cd8fc..a3f182ca 100644
--- a/tests/ProbeChild.sys.mjs
+++ b/tests/ProbeChild.sys.mjs
@@ -1,6 +1,21 @@
// 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 ||
diff --git a/tests/activities.test.mjs b/tests/activities.test.mjs
index 0caca7f8..a41dfe14 100644
--- a/tests/activities.test.mjs
+++ b/tests/activities.test.mjs
@@ -1,5 +1,5 @@
import assert from 'node:assert/strict';
-import {Activities} from '../services/activities.mjs';
+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]};
@@ -81,3 +81,41 @@ 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
new file mode 100644
index 00000000..ae1e5893
--- /dev/null
+++ b/tests/android-input-bridge.test.mjs
@@ -0,0 +1,53 @@
+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) {
+ const sent=[],events=new Map();let receive;
+ const page={navigator:{},dispatchEvent(){}};
+ const port={postMessage:m=>sent.push(m),onMessage:{addListener:fn=>receive=fn},onDisconnect:{addListener(){}}};
+ 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},
+ window:{wrappedJSObject:page,Promise,Error,Event,CustomEvent:class extends Event{constructor(name,p){super(name);this.detail=p.detail;}}},
+ cloneInto:x=>x,exportFunction:(fn,obj,p)=>{obj[p.defineAs]=fn;},
+ addEventListener:(name,fn)=>events.set(name,fn),setTimeout,clearTimeout};
+ vm.runInNewContext(source,context);
+ receive({id:sent[0].id,reply:{result:enabled}});
+ await new Promise(resolve=>setImmediate(resolve));
+ return {sent,events,page,receive,context};
+}
+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);
+});
diff --git a/tests/android-sms-handoff.test.mjs b/tests/android-sms-handoff.test.mjs
index 7eea152d..60638c92 100644
--- a/tests/android-sms-handoff.test.mjs
+++ b/tests/android-sms-handoff.test.mjs
@@ -8,8 +8,12 @@ function setup(composer) {
const calls=[];
const recipients={numbers:[]};
const context={console,location:{hostname:'sms.localhost',pathname:'/index.html'},
- document:{readyState:'complete',addEventListener:(name,handler)=>{if(name==='click')click=handler;}},
- VulpesCompat:{call:async(method,params)=>{calls.push({method,params});}},
+ 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'},
diff --git a/tests/bluetooth.test.mjs b/tests/bluetooth.test.mjs
new file mode 100644
index 00000000..5d127c06
--- /dev/null
+++ b/tests/bluetooth.test.mjs
@@ -0,0 +1,61 @@
+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/camera-lifecycle.test.mjs b/tests/camera-lifecycle.test.mjs
index a42577de..89dd441b 100644
--- a/tests/camera-lifecycle.test.mjs
+++ b/tests/camera-lifecycle.test.mjs
@@ -10,15 +10,20 @@ function deferred() {
}
// Execute the shared adapter, with controllable media promises instead of a sensor.
-function setup(native = false) {
+function setup(native = false, platformName = 'desktop') {
const videos = [], streams = [], requests = [], events = new EventTarget();
- const nativeRequests=[];
- let hidden = false, captureGate, playGate, labels = true;
+ 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) { this.stream = value; }
- constructor() { this.dataset = {}; this.style = {}; }
+ 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); }
@@ -27,19 +32,22 @@ function setup(native = false) {
Object.assign(document, {
documentElement: {lang:'en'}, body: {append:video => videos.push(video)},
querySelector: () => null, querySelectorAll: () => [...videos],
- createElement: tag => tag === 'canvas' ? {getContext:()=>({
- createShader:()=>({}), shaderSource(){}, compileShader(){},
- getShaderParameter:()=>false, createProgram:()=>({}),
- deleteShader(){}, deleteProgram(){}, getExtension:()=>null,
- })} : new Video(),
+ 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 = {language:'en', mediaDevices: {
+ 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;
@@ -57,19 +65,23 @@ function setup(native = false) {
const context = {document, Document, navigator, HTMLMediaElement:Video, console, performance,
location:{hostname:'camera.localhost',port:'8765'}, parent:{},
MutationObserver:class {observe() {}},
- Event, DOMException, CustomEvent:class extends Event {constructor(type,opts){super(type);this.detail=opts.detail;}},
+ 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':'desktop',camera:true};
+ if(method==='platform.capabilities')return {platform:native?'tundra':platformName,camera:true};
nativeRequests.push(params);
- if(params.action==='start')return {session:42,width:640,height:480,orientation:90};
+ 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,
+ 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'));},
@@ -90,7 +102,7 @@ test('native preview setup failure releases the sensor before returning an error
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,42);
+ assert.equal(s.nativeRequests[1].session,1);
});
test('switch waits for asynchronous close even after release has detached the current stream', async()=>{
@@ -150,3 +162,95 @@ test('hide/show during preview startup leaves no live abandoned stream', async()
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/input.test.mjs b/tests/input.test.mjs
index 0318b17f..10dcb79f 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 '../host/Input.sys.mjs';
+import {Input} from '../services/input.mjs';
function setup() {
const events=[];
const input=new Input({identity:actor=>actor.app,broadcast:(...args)=>events.push(args)});
@@ -30,3 +30,20 @@ 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/messages.test.mjs b/tests/messages.test.mjs
index 4b0b58c3..68519a97 100644
--- a/tests/messages.test.mjs
+++ b/tests/messages.test.mjs
@@ -97,3 +97,13 @@ 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
new file mode 100644
index 00000000..e8198836
--- /dev/null
+++ b/tests/mms.test.mjs
@@ -0,0 +1,34 @@
+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/platform.test.mjs b/tests/platform.test.mjs
index 31653717..838207ab 100644
--- a/tests/platform.test.mjs
+++ b/tests/platform.test.mjs
@@ -49,3 +49,40 @@ 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-dialogs.test.mjs b/tests/settings-dialogs.test.mjs
new file mode 100644
index 00000000..2972e775
--- /dev/null
+++ b/tests/settings-dialogs.test.mjs
@@ -0,0 +1,53 @@
+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/test_changelog.py b/tests/test_changelog.py
new file mode 100644
index 00000000..53aaa59d
--- /dev/null
+++ b/tests/test_changelog.py
@@ -0,0 +1,115 @@
+"""Changelog exports and remote publication boundaries."""
+import copy
+import importlib.util
+import json
+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('## 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_flat_entry_keeps_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(rendered['groups'][0]['platforms'], rendered['platforms'])
+
+ def test_badges_are_images_with_escaped_attributes(self):
+ rendered = export.inline(' **Camera**')
+ self.assertIn(' Camera', rendered)
+ self.assertNotIn(' r.networks.length === 0));
await tundraWifi({operation:'scan'});
assert.equal(starts, 1);
+ await tundraWifi({operation:'known'});
+ await tundraWifi({operation:'status'});
+ await tundraWifi({operation:'scan'});
+ assert.equal(starts, 3, 'read-only status and saved networks preserve the radio scan cache');
+ starts -= 2;
now += 6000;
reply = {error:'WIFI_UNAVAILABLE'};
const failures = await Promise.allSettled(Array.from({length:1000}, () => tundraWifi({operation:'scan'})));
diff --git a/tools/amd-module-range.cjs b/tools/amd-module-range.cjs
new file mode 100644
index 00000000..c626a586
--- /dev/null
+++ b/tools/amd-module-range.cjs
@@ -0,0 +1,14 @@
+// Locate an AMD definition without swallowing surrounding require() callbacks.
+const fs=require('node:fs'), esprima=require('esprima');
+const [path,name]=process.argv.slice(2), source=fs.readFileSync(path,'utf8');
+let result;
+function visit(node) {
+ if(!node || typeof node!=='object')return;
+ if(node.type==='CallExpression' && node.callee.type==='Identifier' && node.callee.name==='define' && node.arguments[0]?.value===name)result=node.range;
+ for(const [key,value] of Object.entries(node))if(key!=='range') {
+ if(Array.isArray(value))value.forEach(visit);else visit(value);
+ }
+}
+visit(esprima.parseScript(source,{range:true}));
+if(result)while(result[1]e.id);')
+ report={'scope':'panel navigation only; no hardware writes or destructive actions','panels':{}}
+ for id in ids:
+ if id in contextual:
+ report['panels'][id]={'contextRequired':True};continue
+ ev('window.__vulpesDiagnostics=[];require(["modules/settings_service"],s=>s.navigate('+json.dumps(id)+','+json.dumps({'cardIndex':0} if id=='messaging-details' else {})+'));return true;')
+ opened=False
+ for _ in range(25):
+ time.sleep(.15)
+ if ev('return document.querySelector("section.current")?.id;')==id:opened=True;break
+ errors=ev('return window.__vulpesDiagnostics;')
+ if any(e['kind']=='rejection' for e in errors or []):break
+ time.sleep(.35)
+ result=ev('const p=document.getElementById('+json.dumps(id)+');return {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"),e=>({name:e.name||e.id||e.getAttribute("name"),disabled:e.disabled,hidden:!e.checkVisibility()})),errors:window.__vulpesDiagnostics};')
+ result['opened']=opened;report['panels'][id]=result
+ args.output.write_text(json.dumps(report,indent=2,ensure_ascii=False)+'\n')
+ print(id,'OPEN' if opened else 'FAILED',json.dumps(result['errors'],ensure_ascii=False),flush=True)
+ if not opened:
+ ev('location.reload();return true;');time.sleep(.7);ready()
+ print('Recorded',len(report['panels']),'panels',flush=True)
+ ev('require(["modules/settings_service"],s=>s.navigate("root"));return true;')
diff --git a/tools/check-parity.py b/tools/check-parity.py
index 916a4819..51ded613 100644
--- a/tools/check-parity.py
+++ b/tools/check-parity.py
@@ -41,9 +41,9 @@ with source as archive:
"gaia-compat.js",
"settings-codec.js",
"gaia-services.js",
- "gaia-styles.js",
+ "gaia-styles.js", "gaia-theme.js", "gaia-select.js", "gaia-mobile-settings.js", "gaia-theme.css", "gaia-input.js",
"gaia-restoration.js",
- "gaia-wifi.js",
+ "gaia-wifi.js", "gaia-bluetooth.js",
"desktop.css",
]:
expected = translate((ROOT / "host" / name).read_text()).encode()
@@ -60,7 +60,8 @@ with source as archive:
("system", "js/notification_screen.js"),
("system", "style/system/system.css"),
("communications", "dialer/style/call_log.css"),
- ]:
+ ] + [("settings", path.relative_to(ROOT / "overrides/settings.gaiamobile.org").as_posix())
+ for path in sorted((ROOT / "overrides/settings.gaiamobile.org").rglob("*.js"))]:
expected = translate((ROOT / "overrides" / (app + ".gaiamobile.org") / path).read_text())
for old, new in [("-moz-calc(", "calc("), ("-moz-box-sizing:", "box-sizing:"),
("-moz-plaintext", "plaintext"), (":-moz-dir(", ":dir("),
@@ -73,7 +74,11 @@ with source as archive:
for app, path, source in [
("calendar", "gaia_build_defer_index.js", "overrides"),
("calendar", "js/bundle.js", "overrides"),
- ("gallery", "js/metadata_scripts.js", "assets/webapps"),
+ ("gallery", "js/metadata_scripts.js", "overrides"),
+ ("gallery", "js/MetadataParser.js", "overrides"),
+ ("video", "gaia_build_defer_index.js", "overrides"),
+ ("camera", "js/main.js", "overrides"),
+ ("camera", "js/lib/dcf.js", "overrides"),
("gallery", "js/ImageEditor.js", "assets/webapps"),
("communications", "contacts/js/views/form.js", "assets/webapps"),
("communications", "contacts/js/contacts.js", "assets/webapps"),
@@ -89,6 +94,10 @@ with source as archive:
actual = gaia.read(app + "/" + path)
assert actual == expected, "Stale application workflow: " + app + "/" + path
results[app + "/" + path] = hashlib.sha256(actual).hexdigest()
+ helper = "dialer/js/telephony_helper.js"
+ expected = translate((ROOT / "gaia/apps/communications" / helper).read_text()).encode()
+ assert gaia.read("communications/" + helper) == expected, "Stale call-log dial helper"
+ results["communications/" + helper] = hashlib.sha256(expected).hexdigest()
for name in ("marketplace.js", "marketplace.css", "icon.png"):
expected = (ROOT / "assets/webapps/marketplace.gaiamobile.org" / name).read_bytes()
if name.endswith(".js"): expected = translate(expected.decode()).encode()
@@ -99,7 +108,8 @@ with source as archive:
assert gaia.read("search/" + relative.as_posix()) == path.read_bytes(), str(relative)
assert b"vulpes-local-home" in gaia.read("search/newtab.html")
assert archive.read("assets/bridge/Messages.mjs") == (ROOT / "host/Messages.sys.mjs").read_bytes()
- for name in ["platform.mjs", "settings.mjs", "datastores.mjs", "screen-power.mjs", "activities.mjs", "packages.mjs"]:
+ assert archive.read("assets/bridge/input-editor.js") == (ROOT / "services/input-editor.mjs").read_text().replace('export class InputEditor', 'class InputEditor').encode(), "Stale Android input editor"
+ for name in ["platform.mjs", "settings.mjs", "datastores.mjs", "screen-power.mjs", "activities.mjs", "packages.mjs", "input.mjs"]:
assert (
archive.read("assets/bridge/services/" + name)
== (ROOT / "services" / name).read_bytes()
diff --git a/tools/export-changelog.py b/tools/export-changelog.py
new file mode 100644
index 00000000..eeb0ad09
--- /dev/null
+++ b/tools/export-changelog.py
@@ -0,0 +1,249 @@
+#!/usr/bin/env python3
+"""Generate Markdown, website data and Forgejo notes from changelog.json."""
+import argparse
+import hashlib
+import html
+import json
+from pathlib import Path
+import re
+import runpy
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def inline(value):
+ tokens = r'(`[^`]+`|\*\*.+?\*\*|!?\[[^\]]+\]\([^)]+\))'
+ def render(match):
+ text = match.group()
+ if text.startswith('`'):
+ return '' + html.escape(text[1:-1]) + ''
+ if text.startswith('**'):
+ return '' + html.escape(text[2:-2]) + ' '
+ if text.startswith('!['):
+ label, url = re.fullmatch(r'!\[([^\]]+)\]\(([^)]+)\)', text).groups()
+ if url.startswith('https://'):
+ return ' '
+ return html.escape(label)
+ label, url = re.fullmatch(r'\[([^\]]+)\]\(([^)]+)\)', text).groups()
+ # Local documentation paths are not website routes.
+ # Only HTTPS links are exported.
+ if url.startswith('https://'):
+ return '' + html.escape(label) + ' '
+ return html.escape(label)
+ return ''.join(render(re.match(tokens, part)) if re.fullmatch(tokens, part) else html.escape(part)
+ for part in re.split(tokens, value, flags=re.DOTALL) if part)
+
+
+def blocks(lines):
+ result, paragraph, items = [], [], []
+ def flush():
+ if paragraph:
+ text = ' '.join(paragraph)
+ if not text.startswith(('Détails :', 'Details:')):
+ result.append('' + inline(text) + '
')
+ paragraph.clear()
+ if items:
+ result.append('' + ''.join('' + inline(s) + ' ' for s in items) + ' ')
+ items.clear()
+ for line in lines + ['']:
+ if not line.strip():
+ flush()
+ elif line.startswith('- '):
+ if paragraph: flush()
+ items.append(line[2:])
+ elif items:
+ items[-1] += ' ' + line.strip()
+ else:
+ paragraph.append(line.strip())
+ return ''.join(result)
+
+
+def parse(path):
+ return parse_text(path.read_text())
+
+
+def parse_text(markdown):
+ entries = []
+ months = {name: index for names in (
+ ('janvier février mars avril mai juin juillet août septembre octobre novembre décembre'),
+ ('january february march april may june july august september october november december'),
+ ) for index, name in enumerate(names.split(), 1)}
+ for chunk in re.split(r'^## ', markdown, flags=re.MULTILINE)[1:]:
+ heading, _, body = chunk.partition('\n')
+ if heading.startswith(('Tenue du journal', 'Maintaining this journal')): continue
+ version = re.match(r'\d+\.\d+[.\w-]*', heading)
+ key = version.group() if version else ('development' if heading.startswith(('En développement', 'In development')) else 'history')
+ kind = 'version' if version else key
+ stamp = re.search(r'(\d{1,2}) ([A-Za-zéû]+) (\d{4})', heading)
+ date = None
+ if stamp and stamp[2].lower() in months:
+ date = f'{stamp[3]}-{months[stamp[2].lower()]:02d}-{int(stamp[1]):02d}'
+ names = re.findall(r'^### (.+)', body, re.MULTILINE)
+ platforms = []
+ if any(re.search(r'\bPC\b|Desktop', name) for name in names): platforms.append('pc')
+ if any('Android' in name for name in names): platforms.append('apk')
+ if any('Tundra' in name or 'Sargo' in name for name in names): platforms.append('models')
+ if not platforms:
+ platforms = ['pc', 'apk', 'models'] if any(name.startswith(('Commun', 'Shared')) for name in names) else ['apk']
+ status = 'development' if key == 'development' else 'history' if key == 'history' else 'published' if re.search(r'publi[ée]|published', heading, re.I) else 'built'
+ release = re.search(r'^\[Release\]\((https://[^)]+)\)', body, re.MULTILINE)
+ body = re.sub(r'^\[Release\]\([^)]+\)\s*', '', body, flags=re.MULTILINE)
+ parts = re.split(r'^### (.+)\n', body, flags=re.MULTILINE)
+ groups = []
+ intro = blocks(parts[0].splitlines())
+ if len(parts) == 1:
+ groups.append({'name': '', 'platforms': platforms, 'common': False, 'html': intro})
+ intro = ''
+ for name, text in zip(parts[1::2], parts[2::2]):
+ common = name.startswith(('Commun', 'Shared'))
+ scope = platforms if common else []
+ if not common:
+ if re.search(r'\bPC\b|Desktop', name): scope.append('pc')
+ if 'Android' in name: scope.append('apk')
+ if 'Sargo' in name or 'Tundra' in name: scope.append('models')
+ if not scope: scope = ['pc']
+ # Closing limits belong to the whole entry, not to the last group.
+ split = re.split(r'(?=^\*\*(?:Limites|Limitations) :)|(?=^\*\*Limitations:)', text, maxsplit=1, flags=re.MULTILINE)
+ groups.append({'name': name, 'platforms': scope, 'common': common, 'html': blocks(split[0].splitlines())})
+ if len(split)>1: intro += blocks(split[1].splitlines())
+ entries.append({'id': key, 'kind': kind, 'date': date, 'title': heading, 'status': status, 'platforms': platforms,
+ 'models': ['sargo'] if 'models' in platforms else [], 'notes': intro,
+ 'groups': groups, 'release': release.group(1) if release else None})
+ # Number same-day development entries from oldest to newest. Adding a new
+ # entry at the top preserves older anchors and avoids duplicate HTML IDs.
+ counts = {}
+ for entry in reversed(entries):
+ if entry['kind'] == 'development':
+ base = 'development-' + (entry['date'] or 'undated')
+ counts[base] = counts.get(base, 0) + 1
+ entry['id'] = base + '-' + str(counts[base])
+ return entries
+
+
+LANGUAGES = ('fr', 'en')
+NOTICE = ''
+
+
+def require(condition, message):
+ if not condition:
+ raise ValueError(message)
+
+
+def load_source(path):
+ source = json.loads(path.read_text())
+ require(source.get('schema') == 1, 'Unsupported changelog schema')
+ require(set(source['preamble']) == set(LANGUAGES), 'Missing preamble translation')
+ require(bool(source['entries']), 'Empty changelog')
+ ids = [entry['id'] for entry in source['entries']]
+ require(len(set(ids)) == len(ids), 'Duplicate entry IDs')
+ languages = {lang: [] for lang in LANGUAGES}
+ for entry in source['entries']:
+ key = entry['id']
+ require(bool(re.fullmatch(r'[a-zA-Z0-9_.-]+', key)), 'Invalid entry ID: ' + key)
+ require(set(entry['text']) == set(LANGUAGES), 'Missing translation: ' + key)
+ metadata = entry.get('metadata')
+ if metadata is not None:
+ require(set(metadata) == {'kind', 'status', 'platforms', 'models'}, 'Invalid metadata fields: ' + key)
+ require(metadata['kind'] in ('version', 'development', 'history'), 'Invalid entry kind: ' + key)
+ require(metadata['status'] in ('development', 'history', 'built', 'published'), 'Invalid entry status: ' + key)
+ require(isinstance(metadata['platforms'], list) and bool(metadata['platforms']) and
+ len(set(metadata['platforms'])) == len(metadata['platforms']) and
+ set(metadata['platforms']) <= {'pc', 'apk', 'models'}, 'Invalid platforms: ' + key)
+ require(isinstance(metadata['models'], list) and
+ all(isinstance(model, str) and re.fullmatch(r'[a-z0-9-]+', model) for model in metadata['models']) and
+ (not metadata['models'] or 'models' in metadata['platforms']), 'Invalid models: ' + key)
+ for lang in LANGUAGES:
+ parsed = parse_text(entry['text'][lang])
+ require(len(parsed) == 1, 'Expected one ## entry: ' + key)
+ item = parsed[0]
+ if metadata is not None:
+ require((item['kind'] == 'version') == (metadata['kind'] == 'version'), 'Version metadata mismatch: ' + key)
+ item.update(metadata)
+ # Flat changelogs keep scope independently of translated prose.
+ if len(item['groups']) == 1 and not item['groups'][0]['name']:
+ item['groups'][0]['platforms'] = metadata['platforms']
+ if item['kind'] == 'version':
+ require(item['id'] == key, 'Version/ID mismatch: ' + key)
+ # Development IDs are fixed in the source, independent of order/date.
+ item['id'] = key
+ languages[lang].append(item)
+ a, b = (languages[lang][-1] for lang in LANGUAGES)
+ fields = ('kind', 'date', 'status', 'platforms', 'models', 'release')
+ require(all(a[f] == b[f] for f in fields), 'Translation metadata differs: ' + key)
+ require([(g['platforms'], g['common'], g['html'].count('')) for g in a['groups']] ==
+ [(g['platforms'], g['common'], g['html'].count(' ')) for g in b['groups']],
+ 'Translation structure differs: ' + key)
+ for tag, release in source['releases'].items():
+ require(bool(re.fullmatch(r'[a-zA-Z0-9_.-]+', tag)), 'Invalid release tag')
+ require(release['version'] in ids and release['after'] in ids, 'Unknown release boundary: ' + tag)
+ require(ids.index(release['version']) < ids.index(release['after']), 'Reversed release range: ' + tag)
+ require(bool(release['summary'].strip()), 'Missing release summary: ' + tag)
+ return source, languages
+
+
+def release_documents(source, tag):
+ release = source['releases'][tag]
+ ids = [e['id'] for e in source['entries']]
+ selected = source['entries'][ids.index(release['version']):ids.index(release['after'])]
+ histories = {}
+ for lang in LANGUAGES:
+ title = 'Historique cumulatif' if lang == 'fr' else 'Cumulative history'
+ note = ('Les sections datées décrivent l’état à chaque étape. Les limites et correctifs ont évolué ; '
+ 'consulter la synthèse de la release pour l’état final. Les chemins de documentation désignent '
+ 'les sources locales, dont la publication est distincte de cet APK.' if lang == 'fr' else
+ 'Dated sections describe each development stage. Limitations and fixes evolved; the release '
+ 'summary describes the final state. Documentation paths refer to local sources; source '
+ 'publication is separate from this APK.')
+ histories[lang] = (f'# {title} — {release["after"]} → {release["version"]}\n\n{note}\n\n' +
+ '\n\n'.join(e['text'][lang].strip() for e in selected) + '\n')
+ body = release['summary'].strip() + '\n\n'
+ for lang, label in [('fr', '🇫🇷 Historique complet, étape par étape'),
+ ('en', '🇬🇧 Full history, stage by stage')]:
+ body += f'\n{label} \n\n{histories[lang]}\n \n\n'
+ return {'release-body.md': body.rstrip() + '\n',
+ **{f'CHANGELOG.{lang}.md': text for lang, text in histories.items()}}
+
+
+def generate(source_path, root, website, release_dir, release_tag=None):
+ source, languages = load_source(source_path)
+ outputs = {}
+ render = runpy.run_path(str(ROOT/'tools/render-changelog.py'))['render']
+ outputs.update({root/name: content for name, content in render(source).items()})
+ if website:
+ data = {'schema': 1, 'source': {'file': 'changelog.json',
+ 'sha256': hashlib.sha256(source_path.read_bytes()).hexdigest()}, 'languages': languages}
+ outputs[website] = json.dumps(data, ensure_ascii=False, indent=2) + '\n'
+ tags = [release_tag] if release_tag else source['releases']
+ for tag in tags:
+ require(tag in source['releases'], 'Unknown release: ' + tag)
+ for name, content in release_documents(source, tag).items():
+ outputs[release_dir/tag/name] = content
+ return outputs
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--source', type=Path, default=ROOT/'changelog.json')
+ parser.add_argument('--output', type=Path, help='Website changelog-data.json destination')
+ parser.add_argument('--release', help='Generate only this Forgejo tag (default: all configured releases)')
+ parser.add_argument('--release-dir', type=Path, default=ROOT/'logs/changelog-export')
+ parser.add_argument('--check', action='store_true', help='Fail on stale/missing exports without writing')
+ args = parser.parse_args()
+ try:
+ outputs = generate(args.source, ROOT, args.output, args.release_dir, args.release)
+ stale = [str(p) for p, content in outputs.items() if not p.exists() or p.read_text() != content]
+ if args.check:
+ if stale:
+ parser.exit(1, 'Stale changelog exports:\n' + '\n'.join(stale) + '\n')
+ print(f'{len(outputs)} changelog exports are current.')
+ else:
+ for path, content in outputs.items():
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(content)
+ print(f'{len(outputs)} exports generated from {args.source}')
+ except (ValueError, KeyError) as error:
+ parser.exit(1, f'Changelog validation failed: {error}\n')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tools/prepare-publication.py b/tools/prepare-publication.py
index 9b9dbb03..62b276c9 100644
--- a/tools/prepare-publication.py
+++ b/tools/prepare-publication.py
@@ -15,7 +15,6 @@ ROOT_FILES = {'.gitignore','.nvmrc','.prettierignore','.prettierrc.json','LICENS
'README.md','README.fr.md','CHANGELOG.md','CHANGELOG.en.md','engine-lock.json','package.json','package-lock.json',
'pyproject.toml','requirements-dev.txt','release.json','changelog.json','run.sh','setup.sh'}
INTERNAL = {'VALIDATION.json','candidate-engine.json',
- 'tools/export-changelog.py','tools/sync-forgejo-changelog.py','tests/test_changelog.py',
'tools/check-checkout.py'}
MEDIA = {'docs/media/vulpes-logo.png','docs/media/vulpes-desktop.png','docs/media/vulpes-gecko.png'}
PRIVATE_PARTS = {'.git','.ssh','profiles','logs','out','downloads','engines','toolchain','keys','.gradle','node_modules','__pycache__'}
diff --git a/tools/project.py b/tools/project.py
index 92f5c7fd..5e5acdf5 100644
--- a/tools/project.py
+++ b/tools/project.py
@@ -6,6 +6,13 @@ import subprocess
ROOT = Path(__file__).resolve().parents[1]
+# Editable Gaia files shared by desktop serving and Android packaging.
+SOURCE_OVERRIDES = {
+ "system": ["js/launcher.js", "js/gaia_scheduler.js", "js/keyboard_manager.js",
+ "js/core.js", "js/rocketbar.js"],
+ "communications": ["dialer/js/telephony_helper.js"],
+}
+
def validate_assets():
"""Require the checked-in Gaia payload instead of a private profile copy."""
diff --git a/tools/refresh-camera-modules.py b/tools/refresh-camera-modules.py
new file mode 100644
index 00000000..19e054dc
--- /dev/null
+++ b/tools/refresh-camera-modules.py
@@ -0,0 +1,16 @@
+#!/usr/bin/env python3
+"""Update changed source modules in the existing Gaia Camera bundle."""
+import json,re,subprocess
+from pathlib import Path
+ROOT=Path(__file__).resolve().parents[1]
+TARGET=ROOT/'overrides/camera.gaiamobile.org/js/main.js'
+dcf=ROOT/'overrides/camera.gaiamobile.org/js/lib/dcf.js'
+dcf.parent.mkdir(parents=True,exist_ok=True)
+dcf.write_text((ROOT/'gaia/apps/camera/js/lib/dcf.js').read_text())
+for name in ['lib/storage','controllers/storage','controllers/activity']:
+ source=(ROOT/'gaia/apps/camera/js'/f'{name}.js').read_text()
+ deps=list(dict.fromkeys(re.findall(r"require\(['\"]([^'\"]+)['\"]\)",source)))
+ source=source.replace('define(function(require, exports, module)', 'define('+json.dumps(name)+','+json.dumps(['require','exports','module',*deps])+',function(require, exports, module)',1)
+ start,end=json.loads(subprocess.check_output(['/usr/bin/node',str(ROOT/'tools/amd-module-range.cjs'),str(TARGET),name],text=True))
+ bundle=TARGET.read_text();TARGET.write_text(bundle[:start]+source[source.index('define('):].rstrip()+'\n'+bundle[end:])
+subprocess.run(['/usr/bin/node','--check',str(TARGET)],check=True)
diff --git a/tools/refresh-settings-panels.py b/tools/refresh-settings-panels.py
index ae19c69c..cb854a6c 100644
--- a/tools/refresh-settings-panels.py
+++ b/tools/refresh-settings-panels.py
@@ -2,17 +2,38 @@
"""Refresh selected AMD modules inside Gaia's existing Settings panel bundles."""
from pathlib import Path
import re
+import json
+import subprocess
+import shutil
ROOT=Path(__file__).resolve().parents[1]
modules = [
+ ('panels/wifi_status/panel', 'js/panels/wifi_status/panel.js'),
('panels/display/slider_handler', 'js/panels/display/panel.js'),
('panels/wifi/wifi_network_list', 'js/panels/wifi/panel.js'),
('modules/page_transitions', 'js/main.js'),
+ ('modules/settings_service', 'js/main.js'),
+ ('modules/dialog_service', 'js/modules/dialog_service.js'),
+ ('modules/dialog_manager', 'js/modules/dialog_service.js'),
+ ('modules/date_time', 'js/modules/date_time.js'),
+ ('panels/downloads/download_api_manager', 'js/panels/downloads/panel.js'),
+ ('panels/help/support', 'js/panels/help/panel.js'),
]
+names=list(dict.fromkeys(name for name,relative in modules))
+base=ROOT/'assets/webapps/settings.gaiamobile.org'
+for script in (base/'js').rglob('*.js'):
+ if script.is_symlink(): continue
+ bundle=script.read_text()
+ for name in names:
+ pair=(name,str(script.relative_to(base)))
+ if pair not in modules and re.search(r"define\(\s*['\"]"+re.escape(name)+r"['\"]",bundle):
+ modules.append(pair)
for name, relative in modules:
original=ROOT/'assets/webapps/settings.gaiamobile.org'/relative
source=ROOT/'overrides/settings.gaiamobile.org'/f'js/{name}.js'
if not source.exists():
source=ROOT/'gaia/apps/settings'/f'js/{name}.js'
+ if name.startswith('modules/') or source == ROOT/'overrides/settings.gaiamobile.org'/relative:
+ source=ROOT/'gaia/apps/settings'/f'js/{name}.js'
text=source.read_text()
dependencies=list(dict.fromkeys(re.findall(r"require\(['\"]([^'\"]+)['\"]\)",text)))
import json
@@ -20,10 +41,15 @@ for name, relative in modules:
text=text.replace('define(function(require)', 'define('+repr(name)+','+json.dumps(['require',*dependencies])+',function(require)',1)
else:
text=text.replace('define(function()', 'define('+repr(name)+',[],function()',1)
- bundle=original.read_text()
- start=bundle.index("define('"+name+"'")
- end=bundle.find("define('",start+8)
- if end<0:end=len(bundle)
target=ROOT/'overrides/settings.gaiamobile.org'/relative
- target.write_text(bundle[:start]+text+'\n'+bundle[end:])
+ bundle=(target if target.exists() else original).read_text()
+ if not re.search(r"define\(\s*['\"]"+re.escape(name)+r"['\"]",bundle):
+ raise ValueError('AMD module missing from target: '+name+' / '+relative)
+ node='/usr/bin/node' if Path('/usr/bin/node').exists() else shutil.which('node')
+ selected=target if target.exists() else original
+ start,end=json.loads(subprocess.check_output([node,str(ROOT/'tools/amd-module-range.cjs'),str(selected),name],text=True))
+ target=ROOT/'overrides/settings.gaiamobile.org'/relative
+ target.parent.mkdir(parents=True,exist_ok=True)
+ definition=text[text.index('define('):].rstrip()
+ target.write_text(bundle[:start]+definition+'\n'+bundle[end:])
print(target.relative_to(ROOT))
diff --git a/tools/serve-gaia.py b/tools/serve-gaia.py
index 03bf60c1..02904e7a 100644
--- a/tools/serve-gaia.py
+++ b/tools/serve-gaia.py
@@ -15,7 +15,7 @@ parser = argparse.ArgumentParser()
parser.add_argument("--port", type=int, default=8765)
parser.add_argument("--legacy-apps", action="store_true")
args = parser.parse_args()
-from project import build_info, validate_assets, release_info, application_manifest
+from project import SOURCE_OVERRIDES, build_info, validate_assets, release_info, application_manifest
from installed_apps import InstalledApps
INSTALLED = InstalledApps(ROOT / "profiles/installed-apps", args.port)
@@ -133,7 +133,7 @@ class Handler(BaseHTTPRequestHandler):
# Only this non-privileged compatibility layer is exposed, never host modules.
if path in [
"/_vulpes/" + name
- for name in ["settings-codec.js", "gaia-compat.js", "gaia-input.js", "gaia-services.js", "gaia-styles.js", "gaia-restoration.js", "gaia-wifi.js", "desktop.css", "platform-ui.js", "gaia-camera.js"]
+ for name in ["settings-codec.js", "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"]
]:
file = ROOT / "host" / path.rsplit("/", 1)[1]
else:
@@ -143,16 +143,7 @@ class Handler(BaseHTTPRequestHandler):
if not file.is_relative_to(root):
self.send_error(403)
return
- overrides = {
- "system.gaiamobile.org": [
- "js/launcher.js",
- "js/gaia_scheduler.js",
- "js/keyboard_manager.js",
- "js/core.js",
- "js/rocketbar.js",
- ]
- }
- if path.lstrip("/") in overrides.get(root.name, []):
+ if path.lstrip("/") in SOURCE_OVERRIDES.get(root.name.removesuffix(".gaiamobile.org"), []):
file = (
ROOT
/ "gaia/apps"
@@ -197,13 +188,13 @@ class Handler(BaseHTTPRequestHandler):
if file.suffix == ".html" and root in LEGACY_ROOTS:
data = data.replace(
b"",
- b'',
+ 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
new file mode 100644
index 00000000..036d67c2
--- /dev/null
+++ b/tools/sync-forgejo-changelog.py
@@ -0,0 +1,114 @@
+#!/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
new file mode 100644
index 00000000..ec17ef9a
--- /dev/null
+++ b/tools/test-appearance.py
@@ -0,0 +1,59 @@
+#!/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 a8f06980..ef5003dd 100644
--- a/tools/test-call-screen.py
+++ b/tools/test-call-screen.py
@@ -31,6 +31,7 @@ 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;}
@@ -53,7 +54,10 @@ 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");')
- 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'
+ 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')
ev('callscreen','document.getElementById("callbar-answer").click();return true;')
wait('callscreen','return navigator.mozTelephony?.calls[0]?.state==="connected";')
calls(None)
@@ -72,6 +76,16 @@ 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')
@@ -82,7 +96,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','return document.getElementById("volume").classList.contains("visible");')
+ 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'
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 685e1308..dfa8d792 100644
--- a/tools/test-camera.py
+++ b/tools/test-camera.py
@@ -65,12 +65,12 @@ with Client(2857) as c:
)
results["unavailable_controls"] = ev(
"camera",
- 'return [".js-flash",".mode-switch"].every(s=>document.querySelector(s).getAttribute("aria-disabled")==="true");',
+ 'return [".js-flash"].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();document.querySelector(".mode-switch .inner").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();return true;',
)
assert ev(
"camera",
diff --git a/tools/test-composer-actions.py b/tools/test-composer-actions.py
new file mode 100644
index 00000000..b72f1161
--- /dev/null
+++ b/tools/test-composer-actions.py
@@ -0,0 +1,56 @@
+#!/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-forgejo-feedback.py b/tools/test-forgejo-feedback.py
new file mode 100644
index 00000000..4f3b29d4
--- /dev/null
+++ b/tools/test-forgejo-feedback.py
@@ -0,0 +1,78 @@
+#!/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
new file mode 100644
index 00000000..eaed21a8
--- /dev/null
+++ b/tools/test-gaia-dialogs.py
@@ -0,0 +1,50 @@
+#!/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 a398a5b2..b0c352c9 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 !!window.Navigation;')
+ wait('sms', "return typeof window.Navigation?.toPanel === 'function';")
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[contenteditable]").textContent;') == number, 'Letter changed the recipient'
+ 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'
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
new file mode 100644
index 00000000..b23a22a0
--- /dev/null
+++ b/tools/test-hotspot-ui.py
@@ -0,0 +1,71 @@
+#!/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
new file mode 100644
index 00000000..2fed42c3
--- /dev/null
+++ b/tools/test-notification-swipe.py
@@ -0,0 +1,32 @@
+#!/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
new file mode 100644
index 00000000..23a54162
--- /dev/null
+++ b/tools/test-settings-followup.py
@@ -0,0 +1,25 @@
+#!/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
new file mode 100644
index 00000000..425ebae2
--- /dev/null
+++ b/tools/test-settings-selector.py
@@ -0,0 +1,46 @@
+#!/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/tundra/droidian/export-runtime.py b/tundra/droidian/export-runtime.py
index 59bb93e7..e52f5433 100644
--- a/tundra/droidian/export-runtime.py
+++ b/tundra/droidian/export-runtime.py
@@ -140,11 +140,12 @@ 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',
+ 'pulseaudio-modules-droid-jb2q', 'pulseaudio-modules-droid-hidl', 'audiosystem-passthrough',
'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 2879a786..dfdcff26 100644
--- a/tundra/droidian/probe.sys.mjs
+++ b/tundra/droidian/probe.sys.mjs
@@ -70,7 +70,8 @@ export async function run(win) {
const prefix=app==='contacts'?'http://communications.localhost:8765/contacts/':
app==='dialer'?'http://communications.localhost:8765/dialer/':`http://${app}.localhost:8765/`;
function find(context) {
- if(context.currentWindowGlobal?.documentURI.spec.startsWith(prefix)) return context;
+ const uri=context.currentWindowGlobal?.documentURI.spec;
+ if(uri?.startsWith(prefix) || (app==='homescreen' && uri?.startsWith('http://verticalhome.localhost:8765/'))) return context;
for(const child of context.children) {const found=find(child); if(found) return found;}
return null;
}
@@ -82,22 +83,22 @@ export async function run(win) {
sleep(30000).then(()=>{throw new Error('Content actor did not answer within 30 seconds');})
]);
}
- async function wait(app, code) {
+ async function wait(app, code, timeout=90000) {
dump(`TUNDRA_DROIDIAN_PROBE waiting ${app}: ${code}\n`);
let last;
- const deadline=Date.now()+90000;
+ const deadline=Date.now()+timeout;
while(Date.now()0 && y>0 && x0;");
report.checks.visible=await wait('system',"return document.visibilityState==='visible';");
- report.checks.bootLogoHidden=await wait('system',"const logo=document.getElementById('os-logo');return !logo || getComputedStyle(logo).opacity==='0';");
+ report.checks.bootLogoHidden=await wait('system',"const logo=document.getElementById('os-logo');return !logo || !logo.checkVisibility({checkOpacity:true,checkVisibilityCSS:true});");
if (Services.env.get('TUNDRA_PERSISTENT') === '1') {
const token = Services.uuid.generateUUID().toString();
report.persistence = await evaluate('system', `
@@ -340,6 +341,113 @@ export async function run(win) {
}
}
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)) {
@@ -423,20 +531,50 @@ export async function run(win) {
}
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 && document.querySelector('video')?.videoWidth>0;");
+ 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 && document.querySelector('.viewfinder video')?.videoWidth>0;");
+ 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=document.querySelector('video');
+ const video=app.camera.mozCamera.vulpesPreviewCanvas;
const camera=app.camera.mozCamera;
window.__cameraBeforeSwitch=camera;
const capabilities=camera.capabilities;
@@ -456,42 +594,62 @@ export async function run(win) {
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,
+ 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 && document.querySelector('.viewfinder video')?.videoWidth>0;");
+ 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.getTracks().every(t=>t.readyState==='ended')};
+ previousStopped:window.__cameraBeforeSwitch.vulpesClosed===true};
`);
cameraReport.roundTrips = [];
- for (let i=0; i<4; i++) {
+ 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 && document.querySelector('.viewfinder video')?.videoWidth>0;");
+ 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;
- await new Promise(r=>setTimeout(r,700));
+ 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;nt.readyState==='ended')};
+ animationFrames:decoded,
+ width:video.getBoundingClientRect().width,
+ meanLight:light/(16*16*3),
+ visible:getComputedStyle(document.querySelector('.viewfinder')).opacity!=='0',
+ previousStopped:window.__switchFrom.vulpesClosed===true};
`));
}
+ await IOUtils.writeJSON(`${root}/logs/camera.json`,cameraReport);
await evaluate('camera', 'window.__cameraBeforeHome=app.camera.mozCamera;return true;');
// Test Gaia's Home action, independent of a held pointer on the shell button.
await evaluate('camera', "window.__cameraFilesBefore=new Set((await VulpesCompat.call('media.request',{type:'pictures',operation:'enumerate'})).map(f=>f.name));return true;");
@@ -511,8 +669,8 @@ export async function run(win) {
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.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.every(r=>r.frames>0 && r.previousStopped);
+ 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);
@@ -571,7 +729,7 @@ export async function run(win) {
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;");
+ 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);
@@ -648,7 +806,7 @@ export async function run(win) {
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 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]');
@@ -656,12 +814,27 @@ export async function run(win) {
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();
+ 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',`return {composer:Navigation.isCurrentPanel('composer'),
- recipientMatch:JSON.stringify(ConversationView.recipients.numbers.map(n=>navigator.mozPhoneNumberService.normalize(n)))===JSON.stringify([${JSON.stringify(request.recipient)}]),
- recipientCount:ConversationView.recipients.numbers.length,
+ 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)
@@ -670,9 +843,14 @@ export async function run(win) {
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();};
+ 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);
diff --git a/tundra/droidian/session.py b/tundra/droidian/session.py
index 19408f45..438651d3 100644
--- a/tundra/droidian/session.py
+++ b/tundra/droidian/session.py
@@ -132,6 +132,7 @@ 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/mms-codec/COPYING b/tundra/mms-codec/COPYING
new file mode 100644
index 00000000..f288702d
--- /dev/null
+++ b/tundra/mms-codec/COPYING
@@ -0,0 +1,674 @@
+ 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
new file mode 100644
index 00000000..c608575e
--- /dev/null
+++ b/tundra/mms-codec/go.mod
@@ -0,0 +1,10 @@
+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
new file mode 100644
index 00000000..538295c0
--- /dev/null
+++ b/tundra/mms-codec/go.sum
@@ -0,0 +1,12 @@
+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
new file mode 100644
index 00000000..eaf8dfd9
--- /dev/null
+++ b/tundra/mms-codec/main.go
@@ -0,0 +1,145 @@
+// 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
new file mode 100644
index 00000000..1ff511b7
--- /dev/null
+++ b/tundra/mms-codec/main_test.go
@@ -0,0 +1,40 @@
+// 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
new file mode 100644
index 00000000..6c55f6a9
--- /dev/null
+++ b/tundra/runtime/audio-devices.py
@@ -0,0 +1,29 @@
+#!/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
new file mode 100644
index 00000000..190e071f
--- /dev/null
+++ b/tundra/runtime/bluetooth-control.py
@@ -0,0 +1,323 @@
+#!/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 848961f4..458cedf5 100644
--- a/tundra/runtime/camera-preview.py
+++ b/tundra/runtime/camera-preview.py
@@ -6,6 +6,7 @@ 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
@@ -214,6 +215,12 @@ 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:
@@ -236,8 +243,12 @@ def main():
last_request = time.monotonic()
if command == 'frame\n':
data = camera.frame or b''
- output.write(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
new file mode 100644
index 00000000..790e9de6
--- /dev/null
+++ b/tundra/runtime/mobile-settings.py
@@ -0,0 +1,316 @@
+# 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/prepare-bluetooth.py b/tundra/runtime/prepare-bluetooth.py
index f69656e5..cfc149a0 100644
--- a/tundra/runtime/prepare-bluetooth.py
+++ b/tundra/runtime/prepare-bluetooth.py
@@ -2,10 +2,34 @@
"""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]
@@ -31,4 +55,5 @@ 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 e5d790c3..c490711d 100644
--- a/tundra/runtime/prepare-hardware.py
+++ b/tundra/runtime/prepare-hardware.py
@@ -10,6 +10,10 @@ 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]
@@ -60,14 +64,39 @@ StateDirectory=tundra/sms
StateDirectoryMode=0700
ReadWritePaths=/run/tundra-radio /var/lib/tundra/sms
''')
- write('etc/pulse/client.conf', 'autospawn = no\n')
+ 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/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.
@@ -77,11 +106,12 @@ load-module module-suspend-on-idle
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
+After=tundra-hal.service systemd-logind.service bluetooth.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
@@ -95,8 +125,10 @@ ExecStart=/usr/bin/python3 /etc/tundra/call-audio.py
RuntimeDirectory=tundra-call-audio
Restart=on-failure
RestartSec=5
-ProtectHome=yes
+ProtectHome=tmpfs
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)
@@ -108,5 +140,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'):
+ for name in ('ofono', 'tundra-audio', 'ModemManager', 'tundra-radio', 'tundra-call-audio', 'tundra-sms', 'tundra-mms'):
(wants/(name+'.service')).symlink_to('../'+name+'.service')
diff --git a/tundra/runtime/prepare-native.py b/tundra/runtime/prepare-native.py
index 4aefaee5..39bea004 100644
--- a/tundra/runtime/prepare-native.py
+++ b/tundra/runtime/prepare-native.py
@@ -118,9 +118,11 @@ for _ in range(120):
time.sleep(.5)
else:raise RuntimeError('Display HAL did not start')
''')
- write('etc/tundra/lxc.conf','''lxc.rootfs.path = /android
+ # 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
lxc.uts.name = tundra-hal
-lxc.net.0.type = empty
+lxc.net.0.type = {'none' if hardware else 'empty'}
lxc.arch = aarch64
lxc.init.cmd = /init
lxc.apparmor.profile = unconfined
@@ -137,7 +139,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','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','mobile-settings.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
@@ -172,6 +174,10 @@ 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 d6ff7d87..7c300127 100644
--- a/tundra/runtime/radio-control.py
+++ b/tundra/runtime/radio-control.py
@@ -16,6 +16,8 @@ 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'
@@ -54,12 +56,20 @@ 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-f_]+',path):
+ if not isinstance(path,str) or not re.fullmatch(r'/ril_0/message[0-9a-fA-F_]+',path):
raise ValueError('INVALID_SMS_PATH')
state = inbox.outgoing_state(path)
if state is None:
@@ -119,9 +129,100 @@ def request(value):
raise ValueError('NOT_SUPPORTED')
-def reply(stream, value):
- stream.write((json.dumps(value, separators=(',', ':')) + '\n').encode())
- stream.flush()
+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 serve():
@@ -139,27 +240,28 @@ def serve():
except (KeyError, OSError):
pass
server.listen(8)
- 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'})
+ 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()
def client(envelope=False):
raw = sys.stdin.read(8192)
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
- sock.settimeout(20)
+ 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.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
index 904db1c0..738850cb 100644
--- a/tundra/runtime/runtime-packages.py
+++ b/tundra/runtime/runtime-packages.py
@@ -12,10 +12,19 @@ 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'),
)
@@ -27,7 +36,8 @@ def stage(root, cache=None):
cache.mkdir(parents=True, exist_ok=True)
records = []
for name, directory, sha in PACKAGES:
- url = 'https://deb.debian.org/debian/pool/main/'+directory+'/'+name
+ 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:
@@ -59,13 +69,28 @@ def stage(root, cache=None):
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() or target.readlink() != path.readlink():
+ 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:
@@ -75,7 +100,7 @@ def stage(root, cache=None):
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})
+ 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')
diff --git a/tundra/runtime/setup-hal.py b/tundra/runtime/setup-hal.py
index c87b67c0..cd930b79 100644
--- a/tundra/runtime/setup-hal.py
+++ b/tundra/runtime/setup-hal.py
@@ -86,6 +86,10 @@ def main():
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 8fdb19d0..e8d46ec2 100644
--- a/tundra/runtime/wifi-control.py
+++ b/tundra/runtime/wifi-control.py
@@ -1,6 +1,8 @@
#!/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
@@ -17,6 +19,7 @@ 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):
@@ -82,6 +85,29 @@ 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'):
@@ -131,14 +157,89 @@ 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 not isinstance(request, dict) or set(request) - {'operation', 'id', 'password', 'enabled'}:
+ 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'}:
raise WifiError('WIFI_INVALID_REQUEST')
operation = request.get('operation')
saved = saved_profiles(profile_dir)
@@ -168,15 +269,25 @@ 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)
- return {'enabled': enabled, 'networks': found}
+ 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}
if operation == 'disconnect':
if nm('device', 'disconnect', DEVICE).returncode:
raise WifiError('WIFI_DISCONNECT_FAILED')
return {'disconnected': True}
- if operation != 'connect':
+ if operation not in ('connect','connectHidden'):
raise WifiError('WIFI_INVALID_REQUEST')
# Resolve an observed AP; clients cannot supply nmcli arguments or arbitrary SSIDs.
- network = next((row for row in networks() if row['id'] == request.get('id')), None)
+ 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)
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)
@@ -184,6 +295,8 @@ 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
@@ -226,7 +339,12 @@ 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')
- result = {'result': dispatch(receive(stream, MAX_REQUEST))}
+ 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)}
except WifiError as error:
result = {'error': str(error)}
except Exception:
@@ -247,7 +365,8 @@ def main():
if len(data) > MAX_REQUEST:
raise WifiError('WIFI_INVALID_REQUEST')
with socket.socket(socket.AF_UNIX) as stream:
- stream.settimeout(90)
+ value=json.loads(data)
+ stream.settimeout(180 if isinstance(value,dict) and value.get('operation')=='hotspotPower' else 90)
stream.connect(SOCKET)
stream.sendall(json.dumps(json.loads(data)).encode() + b'\n')
result = receive(stream, 65536)
@@ -265,10 +384,18 @@ def main():
os.chown(SOCKET, 0, 32011)
path.chmod(0o660)
server.listen(4)
- while True:
- stream, _ = server.accept()
- with stream:
- handle(stream)
+ # 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()
if __name__ == '__main__':
diff --git a/tundra/tests/test_bluetooth_control.py b/tundra/tests/test_bluetooth_control.py
new file mode 100644
index 00000000..dc95c953
--- /dev/null
+++ b/tundra/tests/test_bluetooth_control.py
@@ -0,0 +1,22 @@
+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_hotspot.py b/tundra/tests/test_hotspot.py
new file mode 100644
index 00000000..2139ca8a
--- /dev/null
+++ b/tundra/tests/test_hotspot.py
@@ -0,0 +1,69 @@
+# 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
new file mode 100644
index 00000000..a6c903fd
--- /dev/null
+++ b/tundra/tests/test_mms_control.py
@@ -0,0 +1,69 @@
+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
new file mode 100644
index 00000000..4cec3aa6
--- /dev/null
+++ b/tundra/tests/test_mobile_settings.py
@@ -0,0 +1,115 @@
+# 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_network_clock.py b/tundra/tests/test_network_clock.py
new file mode 100644
index 00000000..9c4e814a
--- /dev/null
+++ b/tundra/tests/test_network_clock.py
@@ -0,0 +1,31 @@
+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_radio_control.py b/tundra/tests/test_radio_control.py
index 7252182e..e89a49fe 100644
--- a/tundra/tests/test_radio_control.py
+++ b/tundra/tests/test_radio_control.py
@@ -1,4 +1,7 @@
import importlib.util
+import json
+import socket
+import subprocess
from pathlib import Path
import unittest
from types import SimpleNamespace
@@ -12,6 +15,107 @@ 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 0b0c5db0..9021849e 100644
--- a/tundra/tests/test_ramdisk.py
+++ b/tundra/tests/test_ramdisk.py
@@ -78,6 +78,21 @@ 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_runtime_packages.py b/tundra/tests/test_runtime_packages.py
index 2f6531aa..c59bf54c 100644
--- a/tundra/tests/test_runtime_packages.py
+++ b/tundra/tests/test_runtime_packages.py
@@ -5,6 +5,7 @@ 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')
@@ -33,9 +34,20 @@ class RuntimePackages(unittest.TestCase):
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'):
diff --git a/tundra/tests/test_telephony_diagnostic.py b/tundra/tests/test_telephony_diagnostic.py
new file mode 100644
index 00000000..fa1c5fb8
--- /dev/null
+++ b/tundra/tests/test_telephony_diagnostic.py
@@ -0,0 +1,65 @@
+import importlib.util
+import json
+from pathlib import Path
+import subprocess
+import tempfile
+from types import SimpleNamespace
+import unittest
+
+spec = importlib.util.spec_from_file_location('diagnostic', Path(__file__).resolve().parents[1]/'tools/diagnose-telephony.py')
+diagnostic = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(diagnostic)
+
+
+class DiagnosticTests(unittest.TestCase):
+ def test_qualcomm_attach_crash_is_visible_without_raw_backtrace(self):
+ report = diagnostic.modem_crashes(
+ 'F libc: Fatal signal 7 (SIGBUS), fault addr 0x123456 in tid 101 (qcrild)\n'
+ 'F DEBUG: qcril_qmi_nas_initial_attach_timeout_handler(void*)\n'
+ 'private subscriber text\n')
+ self.assertEqual(report, {'signals':[{'signal':7, 'name':'SIGBUS'}],
+ 'initialAttachTimeoutInBacktrace':True})
+ self.assertNotIn('0x123456', json.dumps(report))
+ self.assertNotIn('subscriber', json.dumps(report))
+
+ def test_crash_time_distinguishes_old_events_after_a_radio_restart(self):
+ report = diagnostic.modem_crashes(
+ '10-08 12:37:33.483 50 101 F libc: Fatal signal 7 (SIGBUS) in tid 101 (qcrild)\n')
+ self.assertEqual(report['signals'][0]['logTime'], '10-08 12:37:33.483')
+
+ def test_subscriber_and_cell_identifiers_never_leave_report(self):
+ props = {'Present': {'data': True}, 'SubscriberIdentity': {'data': 'private-imsi'},
+ 'SubscriberNumbers': {'data': ['private-number']}, 'CardIdentifier': {'data': 'private-card'}}
+ self.assertEqual(diagnostic.select_properties('SimManager', props), {'Present': True})
+ line = 'src/binder_network.c:binder_network_poll_voice_state_cb() slot1 denied,lte,14,12345,987654321,11'
+ events = diagnostic.registration_events(line+'\n'+line)
+ self.assertEqual(len(events), 1)
+ self.assertEqual(events[0]['denialReason'], 11)
+ self.assertNotIn('12345', json.dumps(events))
+ self.assertNotIn('987654321', json.dumps(events))
+
+ def test_unavailable_services_are_not_reported_as_healthy(self):
+ def unavailable(command):
+ raise subprocess.TimeoutExpired(command, 12)
+ with tempfile.TemporaryDirectory() as folder:
+ report = diagnostic.collect(unavailable, Path(folder))
+ self.assertEqual(len(report['errors']), len(diagnostic.FIELDS))
+ self.assertFalse(report['registrationLogAvailable'])
+ self.assertEqual(report['audio'], {'available': False})
+
+ def test_diagnostic_only_reads_fixed_interfaces(self):
+ calls = []
+ def command(args):
+ calls.append(args)
+ if args[0] == 'busctl':
+ return SimpleNamespace(returncode=0, stdout='{"data":[{"Status":{"data":"denied"}}]}')
+ return SimpleNamespace(returncode=0, stdout='active\n' if args[0] == 'systemctl' else '')
+ with tempfile.TemporaryDirectory() as folder:
+ report = diagnostic.collect(command, Path(folder))
+ self.assertEqual(report['properties']['NetworkRegistration']['Status'], 'denied')
+ for args in calls:
+ if args[0] == 'busctl': self.assertEqual(args[-1], 'GetProperties')
+ if args[0] == 'systemctl': self.assertEqual(args[1], 'is-active')
+
+
+if __name__ == '__main__': unittest.main()
diff --git a/tundra/tests/test_wifi_control.py b/tundra/tests/test_wifi_control.py
index 58bca07f..8b23b0eb 100644
--- a/tundra/tests/test_wifi_control.py
+++ b/tundra/tests/test_wifi_control.py
@@ -28,6 +28,16 @@ class WifiTests(unittest.TestCase):
self.assertTrue(result[0]['connected'])
self.assertEqual(result[1]['security'], 'unsupported')
+ def test_connection_information_uses_validated_address_and_actual_bitrate(self):
+ def nm(*args, **kwargs):
+ value='invalid\n192.0.2.4/24\n' if args[1]=='IP4.ADDRESS' else '/org/freedesktop/NetworkManager/Devices/4\n'
+ return SimpleNamespace(returncode=0,stdout=value)
+ with patch.object(wifi,'nm',side_effect=nm), patch.object(wifi.subprocess,'run',return_value=SimpleNamespace(returncode=0,stdout='{"data":[351000]}')):
+ self.assertEqual(wifi.connection_info(),{'ipAddress':'192.0.2.4','linkSpeed':351})
+ with patch.object(wifi,'nm',return_value=SimpleNamespace(returncode=0,stdout='/invalid/path')), patch.object(wifi.subprocess,'run') as run:
+ self.assertEqual(wifi.connection_info(),{})
+ run.assert_not_called()
+
def test_secret_persists_only_after_success(self):
for fail in (False, True):
with tempfile.TemporaryDirectory() as temporary:
@@ -86,6 +96,18 @@ class WifiTests(unittest.TestCase):
with self.assertRaises(wifi.WifiError):
wifi.profile_text(AP, password, 'test')
+ def test_hidden_network_uses_encoded_ssid_and_private_profile(self):
+ with tempfile.TemporaryDirectory() as folder, patch.object(wifi,'nm',return_value=SimpleNamespace(returncode=0)):
+ root=Path(folder)
+ wifi.dispatch({'operation':'connectHidden','ssid':'Hidden; réseau','security':'wpa-psk','password':'testpassword'},root)
+ saved=next(root.iterdir())
+ self.assertIn('hidden=true',saved.read_text())
+ self.assertEqual(stat.S_IMODE(saved.stat().st_mode),0o600)
+ self.assertEqual(wifi.saved_profiles(root)[0]['ssid'],'Hidden; réseau')
+ for ssid in ('','x'*33,'é'*17):
+ with tempfile.TemporaryDirectory() as folder, self.assertRaises(wifi.WifiError):
+ wifi.dispatch({'operation':'connectHidden','ssid':ssid,'security':'open'},Path(folder))
+
def test_socket_round_trip_and_limits(self):
a, b = socket.socketpair()
with a, b, patch.object(wifi, 'dispatch', return_value={'networks':[]}) as dispatch:
diff --git a/tundra/tools/build-rescue-boot.py b/tundra/tools/build-rescue-boot.py
index 5d2187d9..7279c25e 100644
--- a/tundra/tools/build-rescue-boot.py
+++ b/tundra/tools/build-rescue-boot.py
@@ -181,33 +181,10 @@ 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.
- buffer = io.BytesIO()
- selected = [*sorted((ROOT.parent/'host').rglob('*')),
- *sorted((ROOT.parent/'services').rglob('*.mjs')),
- *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/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/'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']
- 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())
+ from shared_ui import build_payload
+ payload, shared_ui = build_payload()
+ files['shared-ui.tar'] = (0o600, payload)
+ sources.append(ROOT/'tools/shared_ui.py')
if state:
config = ('STATE_IMAGE=/installed/tundra/data/'+state['uuid']+'.ext4\n'
'STATE_UUID='+state['uuid']+'\nSTATE_BYTES='+str(state['bytes'])+'\n')
diff --git a/tundra/tools/diagnose-telephony.py b/tundra/tools/diagnose-telephony.py
new file mode 100644
index 00000000..5d8d7564
--- /dev/null
+++ b/tundra/tools/diagnose-telephony.py
@@ -0,0 +1,116 @@
+#!/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/refresh-boot-ui.py b/tundra/tools/refresh-boot-ui.py
new file mode 100644
index 00000000..69cda87a
--- /dev/null
+++ b/tundra/tools/refresh-boot-ui.py
@@ -0,0 +1,117 @@
+#!/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/shared_ui.py b/tundra/tools/shared_ui.py
new file mode 100644
index 00000000..ddede267
--- /dev/null
+++ b/tundra/tools/shared_ui.py
@@ -0,0 +1,51 @@
+# 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