Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
200d1fb235 | ||
|
|
1c20a0b6d5 | ||
|
|
7c86dbcd33 | ||
|
|
1311bed5e9 | ||
| be2cdce823 | |||
| 631de97a09 | |||
| e14e2b00e1 | |||
| 7839ff24c5 | |||
| be551ac49f | |||
| aeea5ab595 | |||
| 402e5daec8 | |||
|
|
0041cd9660 | ||
|
|
d04824ec89 | ||
|
|
978542bc87 | ||
|
|
d5cfc8a7d7 | ||
|
|
bdbb2e13f3 | ||
|
|
9b606ed5f3 | ||
|
|
a15c2915dc | ||
|
|
18338e44c7 | ||
|
|
4f8cec644e | ||
|
|
b38dea0597 |
26057 changed files with 10010197 additions and 367 deletions
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/engines/
|
||||
/downloads/
|
||||
/profiles/
|
||||
/logs/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
# Installed app payload is versioned; these indexes are generated by the server.
|
||||
/assets/registry.json
|
||||
/assets/build-info.json
|
||||
/assets/settings-current-defaults.json
|
||||
/node_modules/
|
||||
/npm-debug.log*
|
||||
/.venv/
|
||||
*.keystore
|
||||
*.jks
|
||||
.env
|
||||
.env.*
|
||||
/local.properties
|
||||
|
||||
# Old B2G registry is unused by the modern host and contains historical paths.
|
||||
/assets/webapps/webapps.json
|
||||
|
||||
# Local issue tracking and roadmap; not included in publication.
|
||||
/tickets/
|
||||
1
.nvmrc
Normal file
1
.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
22
|
||||
13
.prettierignore
Normal file
13
.prettierignore
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
gaia/
|
||||
assets/
|
||||
engines/
|
||||
downloads/
|
||||
profiles/
|
||||
logs/
|
||||
android/toolchain/
|
||||
android/.gradle/
|
||||
android/build/
|
||||
android/app/build/
|
||||
android/app/src/main/assets/
|
||||
android/dist/
|
||||
android/keys/
|
||||
7
.prettierrc.json
Normal file
7
.prettierrc.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "all",
|
||||
"tabWidth": 2,
|
||||
"plugins": ["prettier-plugin-java"]
|
||||
}
|
||||
904
CHANGELOG.en.md
Normal file
904
CHANGELOG.en.md
Normal file
|
|
@ -0,0 +1,904 @@
|
|||
# Changelog — Vulpes OS
|
||||
|
||||
[Français](CHANGELOG.md)
|
||||
|
||||
Updates by date and platform. Within each group: Add, Change, Fix, Note, Test. Entries describe the state at their date; a fix in development requires an updated installed version.
|
||||
|
||||
<a id="2.7-preview.21"></a>
|
||||
|
||||
## 14/10/2026 : 2.7-preview.21
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Unavailable-feature messages are easier to read in light and dark themes.
|
||||
-   : Contact names now appear when the call history first loads.
|
||||
-   : Additional dictionaries available offline; improved Gaia keyboard activation and recovery. Verification on affected devices is still needed.
|
||||
-   : Storage panels restored. A loading error no longer locks the other menus.
|
||||
|
||||
### 
|
||||
|
||||
-   : Read Android contacts, SMS and call history in Gaia after granting access. Existing local Vulpes data is preserved.
|
||||
-   : Return to Vulpes without a persistent white overlay; a closed catalogue can be reopened.
|
||||
-   : The reported blank keyboard still needs reproduction on the affected devices.
|
||||
-   : First, read-only integration, excluding MMS. Sending and editing Android records use Android apps; new Gaia contacts stay local. SMS and call log access depend on ROM and installer permissions. Tested on Android 9; other versions need verification.
|
||||
|
||||
### 
|
||||
|
||||
-   : Shared Gaia fixes are included in full installation images and boot updates.
|
||||
-   : New installation image built; Pixel boot and flashing must be tested before publication.
|
||||
|
||||
<a id="2.7-preview.20"></a>
|
||||
|
||||
## 13/10/2026 : 2.7-preview.20
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Video mode, with thumbnails and playback in Gallery and Video.
|
||||
-   : Improvements to settings, appearance, pickers, keyboard, contacts and media.
|
||||
-   : More reliable camera switching, shutdown and resume.
|
||||
-   : Mozilla Account, Sync, remote location, extensions and USB transfer remain incomplete.
|
||||
|
||||
### 
|
||||
|
||||
-   : Preview 20 APK available.
|
||||
-   : Calls and SMS use Android. This Preview has not yet been validated in use on Android.
|
||||
|
||||
### 
|
||||
|
||||
-   : Preview runs at around 16 frames per second.
|
||||
-   : Updates to Wi-Fi, Bluetooth, calls, SMS/MMS and network settings.
|
||||
-   : Video sound and two-way call audio still need separate testing.
|
||||
-   : Preview 20 installed on Pixel 3a with data preserved. Wi-Fi connected and 4G registration observed.
|
||||
|
||||
### 
|
||||
|
||||
-   : Simpler French and English history, with badges and platform filters. Shared content for the website and releases.
|
||||
|
||||
[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/v2.7-preview.20)
|
||||
|
||||
<a id="development-2026-10-12-video-clock"></a>
|
||||
|
||||
## 12/10/2026 : Video and time
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Video recording with sound, thumbnails and playback in Gallery and Video.
|
||||
-   : “Improve Vulpes” opens the project’s issues.
|
||||
-   : Date and time pickers restored; Keyboard back navigation and list text fixed.
|
||||
|
||||
### 
|
||||
|
||||
-   : Recording checked with a simulated camera.
|
||||
|
||||
### 
|
||||
|
||||
-   : Changes prepared for a future APK; video not yet tested on Android.
|
||||
|
||||
### 
|
||||
|
||||
-   : Improved preview, at around 16 frames per second.
|
||||
-   : Automatic time depends on network information; time zone remains manual.
|
||||
-   : Video recording and camera switching checked. Video sound still needs listening tests.
|
||||
|
||||
<a id="development-2026-10-12-settings-calls-hotspot"></a>
|
||||
|
||||
## 12/10/2026 : Calls and hotspot
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Dialog closing and retry after an error fixed.
|
||||
|
||||
### 
|
||||
|
||||
-   : Settings for call waiting, caller ID and call forwarding.
|
||||
-   : Hotspot with or without a password.
|
||||
-   : Slow settings no longer block calls and SMS.
|
||||
-   : Hotspot activation checked; Internet access from another device still needs testing.
|
||||
|
||||
<a id="development-2026-10-12-settings-network"></a>
|
||||
|
||||
## 12/10/2026 : Network settings
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : IP address and connection speed displayed correctly.
|
||||
|
||||
### 
|
||||
|
||||
-   : Network type selection and mobile access point editing.
|
||||
-   : More reliable network settings when the modem responds slowly.
|
||||
-   : Adding, deleting and resetting access points remain incomplete.
|
||||
|
||||
<a id="development-2026-10-12-settings-audit"></a>
|
||||
|
||||
## 12/10/2026 : Settings review
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Navigation, accessibility and help fixed. Incomplete panels remain visible.
|
||||
-   : Menus reviewed; some actions still depend on hardware or the carrier.
|
||||
|
||||
### 
|
||||
|
||||
-   : Storage information and downloads connected.
|
||||
|
||||
### 
|
||||
|
||||
-   : Display of storage used by Vulpes.
|
||||
|
||||
### 
|
||||
|
||||
-   : Mobile data, roaming, time, hidden Wi-Fi networks and Sound panel fixed.
|
||||
|
||||
<a id="development-2026-10-12-settings-navigation"></a>
|
||||
|
||||
## 12/10/2026 : Settings navigation
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Light/Dark selection and picker confirmation or cancellation restored.
|
||||
-   : Manager adapted to single-SIM phones.
|
||||
|
||||
### 
|
||||
|
||||
-   : SIM information, message centre and delivery reports.
|
||||
-   : Faster loading of saved networks.
|
||||
-   : Wi-Fi certificates, SIM security and USB storage remain incomplete.
|
||||
|
||||
<a id="development-2026-10-12-sargo-appearance"></a>
|
||||
|
||||
## 12/10/2026 : Appearance on Pixel 3a
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Shared light and dark modes available on Pixel 3a.
|
||||
-   : Settings fixes and Firefox OS fonts included.
|
||||
-   : No new call, SMS or Bluetooth test at this stage.
|
||||
-   : Themes and display checked on the phone.
|
||||
|
||||
<a id="development-2026-10-11-appearance-input"></a>
|
||||
|
||||
## 11/10/2026 : Appearance and keyboard
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Light, Dark and System modes, preserved after restart.
|
||||
-   : Text selection and deletion fixed.
|
||||
|
||||
### 
|
||||
|
||||
-   : Themes and SMS typing checked.
|
||||
|
||||
### 
|
||||
|
||||
-   : Experimental Gaia keyboard; Android keyboard remains the default.
|
||||
-   : Retry available when the camera is busy.
|
||||
-   : Preview 19 built; themes, keyboard and front camera still need testing on Android.
|
||||
|
||||
<a id="development-2026-10-11-settings"></a>
|
||||
|
||||
## 11/10/2026 : Settings and home screen
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Second home screen, wallpaper previews and installation banner fixed.
|
||||
-   : Error message dismissal restored.
|
||||
-   : Ringtones, lock code, permissions and French/English labels fixed.
|
||||
|
||||
### 
|
||||
|
||||
-   : Open the notification tray by tapping the status bar.
|
||||
-   : Preview 18 built locally.
|
||||
|
||||
### 
|
||||
|
||||
-   : Returned to the previous version after an unstable interface restart.
|
||||
-   : Operation confirmed by the user.
|
||||
|
||||
<a id="development-2026-10-09-mms-bluetooth"></a>
|
||||
|
||||
## 09/10/2026 : MMS and Bluetooth
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Gaia panels connected to each platform’s capabilities.
|
||||
|
||||
### 
|
||||
|
||||
-   : Discovery, pairing, connection, forgetting devices and audio output.
|
||||
-   : Sending preserved and resumed after interruption.
|
||||
-   : MMS accepted by the carrier, delivery not confirmed. Headset call audio still needs testing.
|
||||
-   : Bluetooth audio playback to a PC checked.
|
||||
|
||||
<a id="development-2026-10-08-camera-mms"></a>
|
||||
|
||||
## 08/10/2026 : Camera and MMS
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Camera switching, shutdown and display after rotation fixed.
|
||||
|
||||
### 
|
||||
|
||||
-   : Attachments preserved and sending shown as pending until confirmation.
|
||||
|
||||
### 
|
||||
|
||||
-   : Preview measured at around 16 fps on the rear camera and 25–27 on the front.
|
||||
-   : Real sending still blocked by the mobile data connection.
|
||||
-   : Focus, zoom and photo capture checked.
|
||||
|
||||
<a id="development-2026-10-08-call-log-dialogs"></a>
|
||||
|
||||
## 08/10/2026 : Call history and dialogs
|
||||
|
||||
### 
|
||||
|
||||
-   : Readable error messages with usable buttons.
|
||||
|
||||
### 
|
||||
|
||||
-   : Clear message when sending is unavailable, without losing the draft.
|
||||
-   : Calling back from call history restored.
|
||||
|
||||
### 
|
||||
|
||||
-   : Flow checked with simulated calls; real audio not tested.
|
||||
|
||||
<a id="development-2026-10-08-sms-audio"></a>
|
||||
|
||||
## 08/10/2026 : SMS and audio
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Volume display, lock screen and notification swiping fixed.
|
||||
-   : Send with one tap without first closing the keyboard. Attachment menu restored.
|
||||
|
||||
### 
|
||||
|
||||
-   : Call audio routing fixes.
|
||||
-   : Network issues prevented testing a real conversation; two-way audio not confirmed.
|
||||
|
||||
<a id="development-2026-10-08-radio-reliability"></a>
|
||||
|
||||
## 08/10/2026 : Network reliability
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Messages linked to the correct conversation.
|
||||
|
||||
### 
|
||||
|
||||
-   : Improved modem recovery; retries avoid duplicate calls and SMS.
|
||||
-   : Network stability and two-way audio still need confirmation.
|
||||
-   : Return to the 4G network after modem restart checked.
|
||||
|
||||
<a id="2026-10-08-source-publication"></a>
|
||||
|
||||
## 08/10/2026 : Public source code
|
||||
|
||||
### 
|
||||
|
||||
-   : Camera, contacts, SMS and Marketplace fixes published, with updated French/English project overviews.
|
||||
|
||||
### 
|
||||
|
||||
-   : Building from a fresh repository copy fixed.
|
||||
-   : The public APK remains Preview 17; these fixes are in the source code.
|
||||
|
||||
### 
|
||||
|
||||
-   : Network search still unstable at this stage; no new image published.
|
||||
|
||||
<a id="development-2026-10-08-camera-lifecycle"></a>
|
||||
|
||||
## 08/10/2026 : Camera and contacts
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : More reliable opening and camera switching.
|
||||
-   : Photos, dates, edits and number selection from Messages or Phone fixed.
|
||||
-   : Media selection fixed.
|
||||
-   : Fixes in source code; public APK unchanged at this stage.
|
||||
|
||||
### 
|
||||
|
||||
-   : Number and text passed correctly to the Android app.
|
||||
|
||||
### 
|
||||
|
||||
-   : Discovery available; pairing and audio use still need testing.
|
||||
-   : Photo capture, focus, zoom and camera switching checked.
|
||||
|
||||
<a id="development-2026-10-06-marketplace-theme"></a>
|
||||
|
||||
## 06/10/2026 : Marketplace theme
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Integration planned for a future APK.
|
||||
|
||||
### 
|
||||
|
||||
-   : Embedded theme: Fira font, compact navigation, touch buttons and scrollable screenshots.
|
||||
-   : Embedded display preserved during search, filtering and language changes.
|
||||
-   : Theme prepared locally, not yet deployed to the website.
|
||||
-   : French and English pages checked on small screens.
|
||||
|
||||
<a id="development-2026-10-06-system-banner"></a>
|
||||
|
||||
## 06/10/2026 : Notification banners
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : The “Application installed” banner disappears automatically and is replaced correctly.
|
||||
|
||||
### 
|
||||
|
||||
-   : Banner display and dismissal checked.
|
||||
|
||||
### 
|
||||
|
||||
-   : Fix prepared for the next APK.
|
||||
|
||||
### 
|
||||
|
||||
-   : Banner disappearance after five seconds checked.
|
||||
|
||||
<a id="development-2026-10-06-wifi"></a>
|
||||
|
||||
## 06/10/2026 : Wi-Fi and settings
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Panels open even when an animation does not finish normally.
|
||||
|
||||
### 
|
||||
|
||||
-   : Fixes not yet included in the public APK.
|
||||
|
||||
### 
|
||||
|
||||
-   : Detected networks and disconnections displayed correctly.
|
||||
-   : Search, connection and Internet access checked.
|
||||
|
||||
<a id="development-2026-10-06-marketplace-views"></a>
|
||||
|
||||
## 06/10/2026 : Marketplace navigation
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Local message with a Retry button when the catalogue is unavailable.
|
||||
-   : The catalogue stays below the status bar and hides when returning home or closing it.
|
||||
-   : An earlier error no longer hides a new attempt.
|
||||
|
||||
### 
|
||||
|
||||
-   : Navigation, errors, returning home and closing checked.
|
||||
|
||||
### 
|
||||
|
||||
-   : Preview 17 rebuilt locally, without a new Android test.
|
||||
|
||||
### 
|
||||
|
||||
-   : Interface tested with a local page; remote catalogue not checked without Internet.
|
||||
|
||||
<a id="2.7-preview.17"></a>
|
||||
|
||||
## 05/10/2026 : 2.7-preview.17
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Install, launch and uninstall packaged web apps.
|
||||
-   : Vulpes catalogue on the home screen, in French and English, with its original icon.
|
||||
-   : Installed apps kept separate from system apps.
|
||||
-   : Some old Firefox OS apps and APIs are not yet supported.
|
||||
|
||||
### 
|
||||
|
||||
-   : Installation and use of 2048 checked.
|
||||
|
||||
### 
|
||||
|
||||
-   : 2048 installed, preserved after restart and uninstalled on an Android emulator.
|
||||
|
||||
### 
|
||||
|
||||
-   : Home screen checked; app installation still needs validation on Pixel.
|
||||
|
||||
<a id="2.7-preview.16"></a>
|
||||
|
||||
## 05/10/2026 : 2.7-preview.16
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Vulpes startup logo.
|
||||
-   : Unwanted horizontal scrolling removed; test apps hidden.
|
||||
-   : Wallpaper and sound selection restored. Home screen language applied and preserved after restart.
|
||||
-   : APK built, not published; Pixel image tried without permanent installation.
|
||||
|
||||
### 
|
||||
|
||||
-   : Android bars hidden and accessible by swiping; launcher mode checked.
|
||||
-   : Return from sleep and the lock screen camera fixed.
|
||||
|
||||
### 
|
||||
|
||||
-   : Old files no longer overwrite updates at startup.
|
||||
|
||||
<a id="2026-10-04-sargo-standalone"></a>
|
||||
|
||||
## 04/10/2026 : Standalone installation
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Installer with device and file checks before writing.
|
||||
-   : Standalone Vulpes installation without going through Droidian.
|
||||
-   : Package not published; tested on one Pixel only. Installation replaces phone data.
|
||||
-   : Backups checked; full restoration still needs testing.
|
||||
-   : Installed boots, saved settings, camera, keyboard and notifications checked.
|
||||
|
||||
<a id="2026-10-03-sargo-per-device-installation"></a>
|
||||
|
||||
## 03/10/2026 : Per-device installation
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Preparation adapted to each Pixel, with device-specific backups.
|
||||
-   : Focus sometimes times out; issue still open.
|
||||
-   : Direct installation from stock Android not yet available.
|
||||
-   : Temporary boots, saved settings and return to the previous installation checked.
|
||||
|
||||
<a id="2026-10-03-sargo-installation-tools"></a>
|
||||
|
||||
## 03/10/2026 : Installation and recovery
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Package preparation and recovery kit with a boot backup.
|
||||
-   : Restoration possible without booting the diagnostic tool.
|
||||
-   : Preparation checked locally; installation on another Pixel still needs completion.
|
||||
|
||||
### 
|
||||
|
||||
-   : Shared history included in upcoming source releases.
|
||||
|
||||
<a id="2026-10-03-sargo-modem-persistence"></a>
|
||||
|
||||
## 03/10/2026 : Modem and first startup
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Recover the modem without restarting the whole phone.
|
||||
-   : Fonts and modem initialization fixed on a fresh installation.
|
||||
-   : Intermittent modem restarts still need fixing; public package incomplete.
|
||||
-   : Cameras, focus, zoom, keyboard, notifications and Wi-Fi search checked.
|
||||
|
||||
<a id="2026-10-01-sargo-distribution-preparation"></a>
|
||||
|
||||
## 01/10/2026 : Preparing distribution
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Preparation of an image with clean user data.
|
||||
-   : Font display on first boot fixed.
|
||||
-   : Image not published. Calls, SMS and mobile data still need end-to-end testing.
|
||||
-   : Intermittent modem restart identified.
|
||||
-   : SIM, 4G, Wi-Fi search, cameras, keyboard and notifications checked in a temporary boot.
|
||||
|
||||
<a id="2.7-preview.15"></a>
|
||||
|
||||
## 30/09/2026 : 2.7-preview.15
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : First publication of Desktop, Android and Tundra source code, with French/English project overviews.
|
||||
-   : More reliable camera stream shutdown and replacement.
|
||||
-   : Dates and sending statuses fixed in conversations.
|
||||
|
||||
### 
|
||||
|
||||
-   : Navigation, settings, gestures and saved data checked.
|
||||
|
||||
### 
|
||||
|
||||
-   : Update, Camera, Gallery and returns from the background checked on an Android emulator.
|
||||
|
||||
### 
|
||||
|
||||
-   : Source code included; universal installation still unavailable at this stage.
|
||||
-   : Flash, video, rotation, sleep and call audio still incomplete or awaiting tests.
|
||||
|
||||
[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/v2.7-preview.15)
|
||||
|
||||
<a id="development-2026-09-30-sms-camera-followup"></a>
|
||||
|
||||
## 30/09/2026 : SMS and camera follow-up
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Camera release and switching fixed.
|
||||
-   : Empty conversations fixed.
|
||||
|
||||
### 
|
||||
|
||||
-   : Shared sources updated, without a new APK.
|
||||
|
||||
### 
|
||||
|
||||
-   : Vibration now stops correctly.
|
||||
-   : Dialog after sending SMS and camera smoothness still need fixing.
|
||||
-   : Focus and zoom confirmed by the user.
|
||||
-   : Sending and receiving confirmed by the user; conversations displayed.
|
||||
|
||||
<a id="development-2026-09-30-sms-camera"></a>
|
||||
|
||||
## 30/09/2026 : SMS and photos
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Failed messages preserved and sending statuses updated.
|
||||
|
||||
### 
|
||||
|
||||
-   : Preview 14 APK unchanged at this stage.
|
||||
|
||||
### 
|
||||
|
||||
-   : Tap to focus and digital zoom. Photos up to 12 MP at the rear and 8 MP at the front.
|
||||
-   : Preview return after taking a photo fixed.
|
||||
-   : Delivery to the recipient not yet confirmed.
|
||||
|
||||
<a id="2.7-preview.14"></a>
|
||||
|
||||
## 30/09/2026 : 2.7-preview.14
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Fonts, version information, lock screen and notifications aligned.
|
||||
|
||||
### 
|
||||
|
||||
-   : Larger keyboard, improved text editing and recipient number entry.
|
||||
|
||||
### 
|
||||
|
||||
-   : Startup and fonts checked on an Android emulator.
|
||||
|
||||
### 
|
||||
|
||||
-   : Vibration stopping fixed.
|
||||
-   : Notifications preserved, conversation opening and duplicate messages fixed.
|
||||
-   : Saved networks, reconnection and forgetting a network fixed.
|
||||
-   : Audio controls tested outside calls; conversation audio and notification sounds still need testing.
|
||||
|
||||
[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-preview.14)
|
||||
|
||||
<a id="2.7-preview.13"></a>
|
||||
|
||||
## 27/09/2026 : 2.7-preview.13
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Event reminders fixed, including after deletion.
|
||||
-   : Initial display and closing the editor fixed.
|
||||
-   : Conversation filters fixed.
|
||||
|
||||
### 
|
||||
|
||||
-   : Photo, video, calendar and conversations checked.
|
||||
|
||||
### 
|
||||
|
||||
-   : Camera, Gallery, Calendar and background resume checked.
|
||||
|
||||
### 
|
||||
|
||||
-   : Camera, Wi-Fi and brightness checked on the installed version.
|
||||
|
||||
<a id="2.7-preview.12"></a>
|
||||
|
||||
## 27/09/2026 : 2.7-preview.12
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Local start page, in French or English according to language.
|
||||
-   : SMS conversations and Calendar translations fixed.
|
||||
-   : Camera switching and interface zoom fixed; flash disabled when unavailable.
|
||||
-   : Sharper icons, long press on Home and gestures fixed.
|
||||
|
||||
### 
|
||||
|
||||
-   : Smoother preview, at around 23 frames per second in testing.
|
||||
-   : More responsive brightness and Wi-Fi connection fixed.
|
||||
-   : Video, flash, Bluetooth and advanced call settings remain incomplete.
|
||||
|
||||
<a id="development-2026-09-27-4"></a>
|
||||
|
||||
## 27/09/2026 : Interface and hardware
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : First local start page.
|
||||
-   : Original shutdown animation retained, with blue circles.
|
||||
-   : List and Video text fixed; transparent Home bar adapted to landscape.
|
||||
-   : Call history headers and icons fixed.
|
||||
|
||||
### 
|
||||
|
||||
-   : Brightness adjustment connected.
|
||||
-   : Call audio, video, full-resolution photos and rotation still need testing.
|
||||
|
||||
<a id="development-2026-09-27-3"></a>
|
||||
|
||||
## 27/09/2026 : Wi-Fi stability
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Preview 9 built with shared fixes; Wi-Fi handled by Android.
|
||||
|
||||
### 
|
||||
|
||||
-   : Network discovery stabilized to avoid phone freezes.
|
||||
-   : Camera and calls remain incomplete.
|
||||
-   : Lock screen, fonts, date, battery and Wi-Fi checked.
|
||||
|
||||
<a id="development-2026-09-27-2"></a>
|
||||
|
||||
## 27/09/2026 : Restoring Gaia components
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Credits, startup screen and browser name updated.
|
||||
-   : Original Gaia panels and styles restored in Settings, Data Usage, FM Radio and imports.
|
||||
-   : Home button no longer overlaps controls; date, battery and lock screen restored.
|
||||
|
||||
### 
|
||||
|
||||
-   : Launcher mode, access to Android settings and background resume.
|
||||
|
||||
### 
|
||||
|
||||
-   : Initial integration unstable; returned to the previous version.
|
||||
|
||||
<a id="development-2026-09-27-1"></a>
|
||||
|
||||
## 27/09/2026 : Calls and hardware buttons
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Gaia incoming and outgoing call interfaces, with call history.
|
||||
-   : Fira fonts, contacts and battery display fixed.
|
||||
|
||||
### 
|
||||
|
||||
-   : Support for Power and Volume keys, shutdown menu and waking the screen.
|
||||
-   : Native camera still unavailable.
|
||||
-   : Ringing and hanging up checked; conversation audio still needs testing.
|
||||
|
||||
<a id="development-2026-09-26-1"></a>
|
||||
|
||||
## 26/09/2026 : Startup and fonts
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Fira fonts and battery information fixed; test buttons removed from settings.
|
||||
|
||||
### 
|
||||
|
||||
-   : Data preserved in the installed boot.
|
||||
-   : Stable fullscreen, without automatic gestures or app launches.
|
||||
-   : Sound and microphone tested separately; call audio, camera and sleep not validated.
|
||||
|
||||
<a id="development-2026-09-25-1"></a>
|
||||
|
||||
## 25/09/2026 : Home screen and first native trials
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Round button, transparent background on Home and blue background in apps.
|
||||
-   : Pressed button state, gradient, fonts and system information fixed.
|
||||
|
||||
### 
|
||||
|
||||
-   : App resume changed in source code, not yet tested on Android.
|
||||
|
||||
### 
|
||||
|
||||
-   : First native image trials with accelerated display and battery support.
|
||||
-   : Temporary trials; many hardware features still need connecting.
|
||||
|
||||
<a id="2.7-preview.7"></a>
|
||||
|
||||
## 2.7-preview.7
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Vulpes, Gaia and Gecko versions shown separately.
|
||||
|
||||
### 
|
||||
|
||||
-   : Improved permission handling and clearer error messages.
|
||||
|
||||
### 
|
||||
|
||||
-   : Resume after the first permission request fixed.
|
||||
|
||||
<a id="2.7-preview.6"></a>
|
||||
|
||||
## 2.7-preview.6
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Gaia Camera app connected to the webcam.
|
||||
-   : File import and photos available in Gallery.
|
||||
-   : Camera switching depends on hardware; flash and video unavailable.
|
||||
|
||||
### 
|
||||
|
||||
-   : Gaia Camera app connected to Android cameras.
|
||||
-   : File import and photos available in Gallery.
|
||||
-   : Camera switching depends on hardware; flash and video unavailable.
|
||||
|
||||
<a id="2.7-preview.5"></a>
|
||||
|
||||
## 2.7-preview.5
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : System settings, media and contact imports, notifications and webcam test.
|
||||
-   : Interface and version numbering aligned with Android.
|
||||
|
||||
### 
|
||||
|
||||
-   : Interface and version numbering aligned with Desktop.
|
||||
-   : Improved background resume and display recovery.
|
||||
|
||||
<a id="2.7-156.0.2"></a>
|
||||
|
||||
## 2.7-156.0.2
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Android settings, sound, brightness, imports and notifications.
|
||||
-   : Calls, SMS, clock and camera handled by Android apps.
|
||||
-   : White screens and background resume fixed.
|
||||
-   : Historical numbering: this number does not represent a Gecko update.
|
||||
|
||||
<a id="2.7-156.0.1"></a>
|
||||
|
||||
## 2.7-156.0.1
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : “Vulpes OS – Preview” name and project icon.
|
||||
-   : Wallpaper flickering fixed.
|
||||
-   : Historical numbering retained to identify this release.
|
||||
|
||||
[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-156.0.1)
|
||||
|
||||
<a id="2.7-preview.2"></a>
|
||||
|
||||
## 2.7-preview.2
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Wallpaper flickering fixed.
|
||||
|
||||
[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-preview.2)
|
||||
|
||||
<a id="2.7-preview.1"></a>
|
||||
|
||||
## 2.7-preview.1
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : First Android Preview to try Vulpes without flashing the phone.
|
||||
-   : Known wallpaper flickering in this first version.
|
||||
|
||||
[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-preview.1)
|
||||
|
||||
<a id="history"></a>
|
||||
|
||||
## Early milestones
|
||||
|
||||
### 
|
||||
|
||||
-   : Adaptation of contacts, messages, settings, alarms and storage.
|
||||
-   : Creation of Vulpes: visual identity, translations and system information.
|
||||
-   : Move from old Firefox OS engines to a modern Gecko.
|
||||
|
||||
### 
|
||||
|
||||
-   : First native boots and hardware trials on Pixel 3a.
|
||||
904
CHANGELOG.md
Normal file
904
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,904 @@
|
|||
# Journal des modifications — Vulpes OS
|
||||
|
||||
[English](CHANGELOG.en.md)
|
||||
|
||||
Les nouveautés, par date et par plateforme. Dans chaque groupe : Add, Change, Fix, Note, Test. Les entrées décrivent la situation à leur date ; une correction en développement nécessite une nouvelle version installée.
|
||||
|
||||
<a id="2.7-preview.21"></a>
|
||||
|
||||
## 14/10/2026 : 2.7-preview.21
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Messages des fonctions indisponibles plus lisibles en thèmes clair et sombre.
|
||||
-   : Dictionnaires supplémentaires disponibles hors ligne ; activation et reprise du clavier Gaia fiabilisées. La vérification sur les appareils concernés reste nécessaire.
|
||||
-   : Affichage du nom des contacts rétabli au premier chargement du journal.
|
||||
-   : Panneaux Stockage rétablis. Une erreur de chargement ne bloque plus les autres menus.
|
||||
|
||||
### 
|
||||
|
||||
-   : Lecture des contacts, SMS et journaux Android dans Gaia, après autorisation. Les données locales de Vulpes sont conservées.
|
||||
-   : Retour vers Vulpes sans écran blanc persistant ; un catalogue fermé peut être rouvert.
|
||||
-   : Le rectangle blanc signalé reste à reproduire sur les appareils concernés.
|
||||
-   : Première étape en lecture seule, hors MMS. Envoi et édition des données Android dans les applications Android ; les nouveaux contacts Gaia restent locaux. Accès SMS et journal selon les autorisations de la ROM et de l’installateur. Essais sur Android 9 ; autres versions à confirmer.
|
||||
|
||||
### 
|
||||
|
||||
-   : Les corrections communes de Gaia sont incluses dans les images complètes et les mises à jour de démarrage.
|
||||
-   : Nouvelle image d’installation construite ; démarrage et flash sur Pixel à vérifier avant publication.
|
||||
|
||||
<a id="2.7-preview.20"></a>
|
||||
|
||||
## 13/10/2026 : 2.7-preview.20
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Mode vidéo, avec miniature et lecture dans Galerie et Vidéo.
|
||||
-   : Améliorations des paramètres, de l’apparence, des sélecteurs, du clavier, des contacts et des médias.
|
||||
-   : Changement de capteur, arrêt et reprise fiabilisés.
|
||||
-   : Compte Mozilla, Sync, localisation distante, extensions et transfert USB restent incomplets.
|
||||
|
||||
### 
|
||||
|
||||
-   : APK Preview 20 disponible.
|
||||
-   : Appels et SMS utilisent Android. Cette Preview n’a pas encore été validée en utilisation sur Android.
|
||||
|
||||
### 
|
||||
|
||||
-   : Aperçu autour de 16 images par seconde.
|
||||
-   : Mise à jour du Wi-Fi, du Bluetooth, des appels, des SMS/MMS et des réglages réseau.
|
||||
-   : Son des vidéos et conversation téléphonique à vérifier séparément.
|
||||
-   : Preview 20 installée sur le Pixel 3a, données conservées. Wi-Fi connecté et réseau 4G détecté.
|
||||
|
||||
### 
|
||||
|
||||
-   : Historique FR/EN simplifié, badges et filtres par plateforme. Même contenu pour le site et les releases.
|
||||
|
||||
[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/v2.7-preview.20)
|
||||
|
||||
<a id="development-2026-10-12-video-clock"></a>
|
||||
|
||||
## 12/10/2026 : Vidéo et heure
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Enregistrement vidéo avec son, miniature et lecture dans Galerie et Vidéo.
|
||||
-   : « Améliorer Vulpes » mène aux tickets du projet.
|
||||
-   : Sélecteurs de date et d’heure rétablis ; retour du menu Clavier et texte des listes corrigés.
|
||||
|
||||
### 
|
||||
|
||||
-   : Enregistrement vérifié avec une caméra simulée.
|
||||
|
||||
### 
|
||||
|
||||
-   : Changements préparés pour un prochain APK ; vidéo non encore testée sur Android.
|
||||
|
||||
### 
|
||||
|
||||
-   : Aperçu amélioré, autour de 16 images par seconde.
|
||||
-   : Heure automatique selon les informations du réseau ; fuseau horaire encore manuel.
|
||||
-   : Vidéo et changement de capteur vérifiés. Son des vidéos encore à écouter.
|
||||
|
||||
<a id="development-2026-10-12-settings-calls-hotspot"></a>
|
||||
|
||||
## 12/10/2026 : Appels et partage Wi-Fi
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Fermeture des dialogues et nouvelle tentative après une erreur corrigées.
|
||||
|
||||
### 
|
||||
|
||||
-   : Réglages du double appel, de l’affichage du numéro et du renvoi d’appel.
|
||||
-   : Partage de connexion avec ou sans mot de passe.
|
||||
-   : Les réglages lents ne bloquent plus les appels et les SMS.
|
||||
-   : Activation du point d’accès vérifiée ; accès Internet d’un autre appareil encore à tester.
|
||||
|
||||
<a id="development-2026-10-12-settings-network"></a>
|
||||
|
||||
## 12/10/2026 : Paramètres réseau
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Adresse IP et vitesse de connexion affichées correctement.
|
||||
|
||||
### 
|
||||
|
||||
-   : Choix du type de réseau et modification des points d’accès mobiles.
|
||||
-   : Réglages réseau plus fiables lorsque le modem répond lentement.
|
||||
-   : Ajout, suppression et réinitialisation des points d’accès encore incomplets.
|
||||
|
||||
<a id="development-2026-10-12-settings-audit"></a>
|
||||
|
||||
## 12/10/2026 : Revue des paramètres
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Navigation, accessibilité et aide corrigées. Les panneaux incomplets restent visibles.
|
||||
-   : Menus parcourus ; certaines actions dépendent encore du matériel ou de l’opérateur.
|
||||
|
||||
### 
|
||||
|
||||
-   : Informations de stockage et téléchargements raccordés.
|
||||
|
||||
### 
|
||||
|
||||
-   : Affichage de l’espace utilisé par Vulpes.
|
||||
|
||||
### 
|
||||
|
||||
-   : Données mobiles, itinérance, heure, réseaux Wi-Fi masqués et panneau Son corrigés.
|
||||
|
||||
<a id="development-2026-10-12-settings-navigation"></a>
|
||||
|
||||
## 12/10/2026 : Navigation des paramètres
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Choix Clair/Sombre et validation ou annulation des sélecteurs rétablis.
|
||||
-   : Gestionnaire adapté aux téléphones à une seule SIM.
|
||||
|
||||
### 
|
||||
|
||||
-   : Informations SIM, centre de messagerie et accusés de réception.
|
||||
-   : Chargement plus rapide des réseaux enregistrés.
|
||||
-   : Certificats Wi-Fi, sécurité SIM et stockage USB encore incomplets.
|
||||
|
||||
<a id="development-2026-10-12-sargo-appearance"></a>
|
||||
|
||||
## 12/10/2026 : Apparence sur Pixel 3a
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Modes clair et sombre communs disponibles sur le Pixel 3a.
|
||||
-   : Correctifs des paramètres et polices Firefox OS intégrés.
|
||||
-   : Pas de nouvel essai des appels, SMS ou Bluetooth à cette étape.
|
||||
-   : Thèmes et affichage vérifiés sur le téléphone.
|
||||
|
||||
<a id="development-2026-10-11-appearance-input"></a>
|
||||
|
||||
## 11/10/2026 : Apparence et clavier
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Modes Clair, Sombre et Système, conservés au redémarrage.
|
||||
-   : Sélection et suppression de texte corrigées.
|
||||
|
||||
### 
|
||||
|
||||
-   : Thèmes et saisie de SMS vérifiés.
|
||||
|
||||
### 
|
||||
|
||||
-   : Clavier Gaia expérimental ; le clavier Android reste proposé par défaut.
|
||||
-   : Nouvelle tentative possible quand la caméra est occupée.
|
||||
-   : Preview 19 construite ; thèmes, clavier et caméra avant encore à vérifier sur Android.
|
||||
|
||||
<a id="development-2026-10-11-settings"></a>
|
||||
|
||||
## 11/10/2026 : Paramètres et accueil
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Second écran d’accueil, aperçus de fonds d’écran et bandeau d’installation corrigés.
|
||||
-   : Fermeture du message d’erreur rétablie.
|
||||
-   : Sonneries, code de verrouillage, permissions et libellés FR/EN corrigés.
|
||||
|
||||
### 
|
||||
|
||||
-   : Ouverture du volet en touchant la barre de statut.
|
||||
-   : Preview 18 construite localement.
|
||||
|
||||
### 
|
||||
|
||||
-   : Retour à la version précédente après un redémarrage instable de l’interface.
|
||||
-   : Fonctionnement confirmé par l’utilisateur.
|
||||
|
||||
<a id="development-2026-10-09-mms-bluetooth"></a>
|
||||
|
||||
## 09/10/2026 : MMS et Bluetooth
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Panneaux Gaia reliés aux capacités de chaque plateforme.
|
||||
|
||||
### 
|
||||
|
||||
-   : Recherche, association, connexion, oubli d’appareils et sortie audio.
|
||||
-   : Envoi conservé et repris après une interruption.
|
||||
-   : MMS accepté par l’opérateur, réception non confirmée. Audio d’appel sur casque à tester.
|
||||
-   : Lecture audio Bluetooth vers un PC vérifiée.
|
||||
|
||||
<a id="development-2026-10-08-camera-mms"></a>
|
||||
|
||||
## 08/10/2026 : Caméra et MMS
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Changement de capteur, arrêt et affichage après rotation corrigés.
|
||||
|
||||
### 
|
||||
|
||||
-   : Pièces jointes conservées et envoi affiché en attente jusqu’à confirmation.
|
||||
|
||||
### 
|
||||
|
||||
-   : Aperçu mesuré autour de 16 images/s à l’arrière et 25 à 27 à l’avant.
|
||||
-   : Envoi réel encore bloqué par la connexion aux données mobiles.
|
||||
-   : Mise au point, zoom et prise de photo vérifiés.
|
||||
|
||||
<a id="development-2026-10-08-call-log-dialogs"></a>
|
||||
|
||||
## 08/10/2026 : Journal d’appel et dialogues
|
||||
|
||||
### 
|
||||
|
||||
-   : Messages d’erreur lisibles, avec des boutons utilisables.
|
||||
|
||||
### 
|
||||
|
||||
-   : Message clair si l’envoi est indisponible, sans perdre le brouillon.
|
||||
-   : Rappel depuis le journal d’appel rétabli.
|
||||
|
||||
### 
|
||||
|
||||
-   : Parcours vérifié avec des appels simulés ; audio réel non testé.
|
||||
|
||||
<a id="development-2026-10-08-sms-audio"></a>
|
||||
|
||||
## 08/10/2026 : SMS et audio
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Affichage du volume, verrouillage et balayage des notifications corrigés.
|
||||
-   : Envoi en un appui, sans fermer d’abord le clavier. Menu des pièces jointes rétabli.
|
||||
|
||||
### 
|
||||
|
||||
-   : Corrections du routage du son des appels.
|
||||
-   : Le réseau a empêché de vérifier une conversation réelle ; son dans les deux sens non confirmé.
|
||||
|
||||
<a id="development-2026-10-08-radio-reliability"></a>
|
||||
|
||||
## 08/10/2026 : Fiabilité du réseau
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Messages associés à la bonne conversation.
|
||||
|
||||
### 
|
||||
|
||||
-   : Récupération du modem améliorée ; les nouvelles tentatives évitent de doubler appels et SMS.
|
||||
-   : Stabilité du réseau et audio dans les deux sens encore à confirmer.
|
||||
-   : Retour sur le réseau 4G après redémarrage du modem vérifié.
|
||||
|
||||
<a id="2026-10-08-source-publication"></a>
|
||||
|
||||
## 08/10/2026 : Sources publiques
|
||||
|
||||
### 
|
||||
|
||||
-   : Publication des corrections caméra, contacts, SMS et Marketplace, avec présentations FR/EN à jour.
|
||||
|
||||
### 
|
||||
|
||||
-   : Construction depuis une copie neuve du dépôt corrigée.
|
||||
-   : L’APK public reste en Preview 17 ; ces corrections sont dans les sources.
|
||||
|
||||
### 
|
||||
|
||||
-   : Recherche de réseau encore instable à cette étape ; pas de nouvelle image publiée.
|
||||
|
||||
<a id="development-2026-10-08-camera-lifecycle"></a>
|
||||
|
||||
## 08/10/2026 : Caméra et contacts
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Ouverture et changement de capteur fiabilisés.
|
||||
-   : Photos, dates, modifications et choix d’un numéro depuis SMS ou Téléphone corrigés.
|
||||
-   : Sélection des médias corrigée.
|
||||
-   : Correctifs dans les sources ; APK public inchangé à cette étape.
|
||||
|
||||
### 
|
||||
|
||||
-   : Numéro et texte transmis correctement à l’application Android.
|
||||
|
||||
### 
|
||||
|
||||
-   : Recherche disponible ; association et usages audio encore à vérifier.
|
||||
-   : Photo, mise au point, zoom et changements de capteur vérifiés.
|
||||
|
||||
<a id="development-2026-10-06-marketplace-theme"></a>
|
||||
|
||||
## 06/10/2026 : Thème Marketplace
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Intégration prévue dans un prochain APK.
|
||||
|
||||
### 
|
||||
|
||||
-   : Thème intégré : police Fira, navigation compacte, boutons tactiles et captures défilantes.
|
||||
-   : Affichage intégré conservé pendant la recherche, les filtres et les changements de langue.
|
||||
-   : Thème préparé localement, pas encore déployé sur le site.
|
||||
-   : Pages françaises et anglaises vérifiées sur petits écrans.
|
||||
|
||||
<a id="development-2026-10-06-system-banner"></a>
|
||||
|
||||
## 06/10/2026 : Bandeaux de notification
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Le bandeau « Application installée » disparaît automatiquement et se remplace correctement.
|
||||
|
||||
### 
|
||||
|
||||
-   : Affichage et fermeture des bandeaux vérifiés.
|
||||
|
||||
### 
|
||||
|
||||
-   : Correctif préparé pour le prochain APK.
|
||||
|
||||
### 
|
||||
|
||||
-   : Disparition du bandeau après cinq secondes vérifiée.
|
||||
|
||||
<a id="development-2026-10-06-wifi"></a>
|
||||
|
||||
## 06/10/2026 : Wi-Fi et paramètres
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Ouverture des panneaux rétablie quand une animation ne se termine pas normalement.
|
||||
|
||||
### 
|
||||
|
||||
-   : Correctifs encore absents de l’APK public.
|
||||
|
||||
### 
|
||||
|
||||
-   : Réseaux détectés et déconnexions affichés correctement.
|
||||
-   : Recherche, connexion et accès Internet vérifiés.
|
||||
|
||||
<a id="development-2026-10-06-marketplace-views"></a>
|
||||
|
||||
## 06/10/2026 : Navigation Marketplace
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Message local avec bouton Réessayer si le catalogue est inaccessible.
|
||||
-   : Le catalogue reste sous la barre de statut et se masque en revenant à l’accueil ou en le fermant.
|
||||
-   : Une ancienne erreur ne masque plus une nouvelle tentative.
|
||||
|
||||
### 
|
||||
|
||||
-   : Navigation, erreurs, retour à l’accueil et fermeture vérifiés.
|
||||
|
||||
### 
|
||||
|
||||
-   : Preview 17 reconstruite localement, sans nouveau test Android.
|
||||
|
||||
### 
|
||||
|
||||
-   : Interface testée avec une page locale ; catalogue distant non vérifié sans Internet.
|
||||
|
||||
<a id="2.7-preview.17"></a>
|
||||
|
||||
## 05/10/2026 : 2.7-preview.17
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Installation, lancement et désinstallation d’applications web empaquetées.
|
||||
-   : Catalogue Vulpes sur l’accueil, en français et en anglais, avec son icône originale.
|
||||
-   : Applications installées séparées des applications système.
|
||||
-   : Certaines anciennes applications et API Firefox OS ne sont pas encore prises en charge.
|
||||
|
||||
### 
|
||||
|
||||
-   : Installation et utilisation de 2048 vérifiées.
|
||||
|
||||
### 
|
||||
|
||||
-   : 2048 installé, conservé après redémarrage et désinstallé sur un émulateur Android.
|
||||
|
||||
### 
|
||||
|
||||
-   : Accueil vérifié ; installation d’applications encore à valider sur le Pixel.
|
||||
|
||||
<a id="2.7-preview.16"></a>
|
||||
|
||||
## 05/10/2026 : 2.7-preview.16
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Logo Vulpes au démarrage.
|
||||
-   : Défilement horizontal involontaire supprimé ; applications de test masquées.
|
||||
-   : Choix des fonds d’écran et des sons rétabli. Langue appliquée à l’accueil et conservée au redémarrage.
|
||||
-   : APK construit, non publié ; image Pixel essayée sans installation permanente.
|
||||
|
||||
### 
|
||||
|
||||
-   : Barres Android masquées, accessibles par balayage ; mode lanceur vérifié.
|
||||
-   : Retour après veille et depuis la caméra du verrouillage corrigé.
|
||||
|
||||
### 
|
||||
|
||||
-   : Les anciens fichiers ne remplacent plus les mises à jour au démarrage.
|
||||
|
||||
<a id="2026-10-04-sargo-standalone"></a>
|
||||
|
||||
## 04/10/2026 : Installation autonome
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Installateur avec vérification du modèle et des fichiers avant écriture.
|
||||
-   : Installation autonome de Vulpes, sans passer par Droidian.
|
||||
-   : Paquet non publié ; un seul Pixel testé. L’installation remplace les données du téléphone.
|
||||
-   : Sauvegardes vérifiées ; restauration complète encore à essayer.
|
||||
-   : Démarrages installés, conservation des réglages, caméra, clavier et notifications vérifiés.
|
||||
|
||||
<a id="2026-10-03-sargo-per-device-installation"></a>
|
||||
|
||||
## 03/10/2026 : Installation par appareil
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Préparation adaptée à chaque Pixel, avec sauvegardes propres à l’appareil.
|
||||
-   : Mise au point parfois trop lente ; problème encore ouvert.
|
||||
-   : Installation directe depuis Android d’origine pas encore disponible.
|
||||
-   : Démarrages temporaires, conservation des réglages et retour à l’installation précédente vérifiés.
|
||||
|
||||
<a id="2026-10-03-sargo-installation-tools"></a>
|
||||
|
||||
## 03/10/2026 : Installation et secours
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Préparation des paquets et kit de secours avec sauvegarde du démarrage.
|
||||
-   : Restauration possible sans démarrer l’outil de diagnostic.
|
||||
-   : Préparation vérifiée localement ; installation sur un autre Pixel encore à finaliser.
|
||||
|
||||
### 
|
||||
|
||||
-   : Historique commun inclus dans les prochaines publications de sources.
|
||||
|
||||
<a id="2026-10-03-sargo-modem-persistence"></a>
|
||||
|
||||
## 03/10/2026 : Modem et premier démarrage
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Récupération du modem sans redémarrer tout le téléphone.
|
||||
-   : Polices et initialisation du modem corrigées sur une installation neuve.
|
||||
-   : Redémarrages intermittents du modem encore à résoudre ; paquet public incomplet.
|
||||
-   : Caméras, focus, zoom, clavier, notifications et recherche Wi-Fi vérifiés.
|
||||
|
||||
<a id="2026-10-01-sargo-distribution-preparation"></a>
|
||||
|
||||
## 01/10/2026 : Préparation de la distribution
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Préparation d’une image avec des données vierges.
|
||||
-   : Affichage des polices au premier démarrage corrigé.
|
||||
-   : Image non publiée. Appels, SMS et données mobiles encore à vérifier de bout en bout.
|
||||
-   : Redémarrage intermittent du modem identifié.
|
||||
-   : SIM, 4G, recherche Wi-Fi, caméras, clavier et notifications vérifiés en démarrage temporaire.
|
||||
|
||||
<a id="2.7-preview.15"></a>
|
||||
|
||||
## 30/09/2026 : 2.7-preview.15
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Première publication des sources PC, Android et Tundra, avec présentation FR/EN.
|
||||
-   : Arrêt et remplacement du flux caméra fiabilisés.
|
||||
-   : Dates et états d’envoi corrigés dans les conversations.
|
||||
|
||||
### 
|
||||
|
||||
-   : Navigation, réglages, gestes et conservation des données vérifiés.
|
||||
|
||||
### 
|
||||
|
||||
-   : Mise à jour, Photo, Galerie et retours d’arrière-plan vérifiés sur un émulateur Android.
|
||||
|
||||
### 
|
||||
|
||||
-   : Sources incluses ; installation universelle encore indisponible à cette étape.
|
||||
-   : Flash, vidéo, rotation, veille et audio d’appel encore incomplets ou à vérifier.
|
||||
|
||||
[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/v2.7-preview.15)
|
||||
|
||||
<a id="development-2026-09-30-sms-camera-followup"></a>
|
||||
|
||||
## 30/09/2026 : Retours SMS et caméra
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Libération du capteur et changement de caméra corrigés.
|
||||
-   : Conversations vides corrigées.
|
||||
|
||||
### 
|
||||
|
||||
-   : Sources communes mises à jour, sans nouvel APK.
|
||||
|
||||
### 
|
||||
|
||||
-   : Arrêt du vibreur rétabli.
|
||||
-   : Dialogue après envoi SMS et fluidité caméra encore à corriger.
|
||||
-   : Focus et zoom confirmés par l’utilisateur.
|
||||
-   : Envoi et réception confirmés par l’utilisateur ; conversations affichées.
|
||||
|
||||
<a id="development-2026-09-30-sms-camera"></a>
|
||||
|
||||
## 30/09/2026 : SMS et photo
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Messages en échec conservés et états d’envoi mis à jour.
|
||||
|
||||
### 
|
||||
|
||||
-   : APK Preview 14 inchangé à cette étape.
|
||||
|
||||
### 
|
||||
|
||||
-   : Mise au point au toucher et zoom numérique. Photos jusqu’à 12 Mpx à l’arrière et 8 Mpx à l’avant.
|
||||
-   : Retour de l’aperçu après une photo corrigé.
|
||||
-   : Réception par le destinataire non encore confirmée.
|
||||
|
||||
<a id="2.7-preview.14"></a>
|
||||
|
||||
## 30/09/2026 : 2.7-preview.14
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Polices, informations de version, verrouillage et notifications harmonisés.
|
||||
|
||||
### 
|
||||
|
||||
-   : Clavier agrandi, édition de texte et saisie du numéro de destinataire améliorées.
|
||||
|
||||
### 
|
||||
|
||||
-   : Démarrage et polices vérifiés sur un émulateur Android.
|
||||
|
||||
### 
|
||||
|
||||
-   : Arrêt de la vibration corrigé.
|
||||
-   : Notifications conservées, ouverture de la conversation et doublons corrigés.
|
||||
-   : Réseaux enregistrés, reconnexion et oubli d’un réseau corrigés.
|
||||
-   : Contrôles audio testés hors appel ; conversation et son des notifications encore à vérifier.
|
||||
|
||||
[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-preview.14)
|
||||
|
||||
<a id="2.7-preview.13"></a>
|
||||
|
||||
## 27/09/2026 : 2.7-preview.13
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Rappels des événements corrigés, y compris après suppression.
|
||||
-   : Premier affichage et fermeture de l’éditeur corrigés.
|
||||
-   : Filtres des conversations corrigés.
|
||||
|
||||
### 
|
||||
|
||||
-   : Photo, vidéo, calendrier et conversations vérifiés.
|
||||
|
||||
### 
|
||||
|
||||
-   : Caméra, Galerie, Calendrier et reprise après arrière-plan vérifiés.
|
||||
|
||||
### 
|
||||
|
||||
-   : Caméra, Wi-Fi et luminosité vérifiés sur la version installée.
|
||||
|
||||
<a id="2.7-preview.12"></a>
|
||||
|
||||
## 27/09/2026 : 2.7-preview.12
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Page d’accueil locale, en français ou en anglais selon la langue.
|
||||
-   : Conversations SMS et traductions du Calendrier corrigées.
|
||||
-   : Changement de capteur et zoom de l’interface corrigés ; flash grisé s’il est indisponible.
|
||||
-   : Icônes plus nettes, appui long sur Accueil et gestes corrigés.
|
||||
|
||||
### 
|
||||
|
||||
-   : Aperçu plus fluide, autour de 23 images par seconde lors des essais.
|
||||
-   : Luminosité plus réactive et connexion Wi-Fi corrigée.
|
||||
-   : Vidéo, flash, Bluetooth et réglages d’appel avancés encore incomplets.
|
||||
|
||||
<a id="development-2026-09-27-4"></a>
|
||||
|
||||
## 27/09/2026 : Interface et matériel
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Première page d’accueil locale.
|
||||
-   : Animation d’extinction conservée, avec des ronds bleus.
|
||||
-   : Textes des listes et de Vidéo corrigés ; barre Accueil transparente et adaptée au paysage.
|
||||
-   : En-têtes et icônes du journal d’appel corrigés.
|
||||
|
||||
### 
|
||||
|
||||
-   : Réglage de la luminosité raccordé.
|
||||
-   : Appels audio, vidéo, pleine résolution photo et rotation encore à vérifier.
|
||||
|
||||
<a id="development-2026-09-27-3"></a>
|
||||
|
||||
## 27/09/2026 : Stabilité Wi-Fi
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Preview 9 construite avec les corrections communes ; Wi-Fi géré par Android.
|
||||
|
||||
### 
|
||||
|
||||
-   : Recherche des réseaux stabilisée pour éviter les blocages du téléphone.
|
||||
-   : Caméra et appels encore incomplets.
|
||||
-   : Verrouillage, polices, date, batterie et Wi-Fi vérifiés.
|
||||
|
||||
<a id="development-2026-09-27-2"></a>
|
||||
|
||||
## 27/09/2026 : Retour aux composants Gaia
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Remerciements, écran de démarrage et nom du navigateur mis à jour.
|
||||
-   : Panneaux et styles Gaia d’origine rétablis dans Paramètres, Suivi conso, Radio FM et les imports.
|
||||
-   : Bouton Accueil dégagé des commandes ; date, batterie et écran de verrouillage rétablis.
|
||||
|
||||
### 
|
||||
|
||||
-   : Mode lanceur, accès aux paramètres Android et reprise après arrière-plan.
|
||||
|
||||
### 
|
||||
|
||||
-   : Première intégration instable ; retour à la version précédente.
|
||||
|
||||
<a id="development-2026-09-27-1"></a>
|
||||
|
||||
## 27/09/2026 : Appels et boutons physiques
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Interfaces Gaia d’appel entrant et sortant, avec journal d’appel.
|
||||
-   : Polices Fira, contacts et affichage de la batterie corrigés.
|
||||
|
||||
### 
|
||||
|
||||
-   : Prise en charge des touches Marche/Arrêt et Volume, du menu d’extinction et du réveil de l’écran.
|
||||
-   : Caméra native encore indisponible.
|
||||
-   : Sonnerie et raccrochage vérifiés ; conversation audio encore à tester.
|
||||
|
||||
<a id="development-2026-09-26-1"></a>
|
||||
|
||||
## 26/09/2026 : Démarrage et polices
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Polices Fira et informations de batterie corrigées ; boutons de test retirés des paramètres.
|
||||
|
||||
### 
|
||||
|
||||
-   : Conservation des données lors du démarrage installé.
|
||||
-   : Plein écran stable, sans gestes ni ouverture automatique d’applications.
|
||||
-   : Son et micro testés séparément ; audio d’appel, caméra et veille non validés.
|
||||
|
||||
<a id="development-2026-09-25-1"></a>
|
||||
|
||||
## 25/09/2026 : Accueil et premiers essais natifs
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Bouton rond, fond transparent sur l’accueil et bleu dans les applications.
|
||||
-   : État appuyé du bouton, dégradé, polices et informations système corrigés.
|
||||
|
||||
### 
|
||||
|
||||
-   : Reprise de l’application modifiée dans les sources, pas encore testée sur Android.
|
||||
|
||||
### 
|
||||
|
||||
-   : Premiers essais d’une image native avec affichage accéléré et batterie.
|
||||
-   : Essais temporaires ; de nombreuses fonctions matérielles restent à raccorder.
|
||||
|
||||
<a id="2.7-preview.7"></a>
|
||||
|
||||
## 2.7-preview.7
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Versions de Vulpes, Gaia et Gecko distinguées.
|
||||
|
||||
### 
|
||||
|
||||
-   : Gestion des autorisations et messages d’erreur plus précis.
|
||||
|
||||
### 
|
||||
|
||||
-   : Reprise après la première demande d’autorisation corrigée.
|
||||
|
||||
<a id="2.7-preview.6"></a>
|
||||
|
||||
## 2.7-preview.6
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Application Photo Gaia reliée à la webcam.
|
||||
-   : Import de fichiers et photos disponibles dans Galerie.
|
||||
-   : Changement de capteur selon le matériel ; flash et vidéo indisponibles.
|
||||
|
||||
### 
|
||||
|
||||
-   : Application Photo Gaia reliée aux caméras Android.
|
||||
-   : Import de fichiers et photos disponibles dans Galerie.
|
||||
-   : Changement de capteur selon le matériel ; flash et vidéo indisponibles.
|
||||
|
||||
<a id="2.7-preview.5"></a>
|
||||
|
||||
## 2.7-preview.5
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Réglages système, imports de médias et de contacts, notifications et essai webcam.
|
||||
-   : Interface et numérotation des versions alignées avec Android.
|
||||
|
||||
### 
|
||||
|
||||
-   : Interface et numérotation des versions alignées avec le PC.
|
||||
-   : Reprise après arrière-plan et récupération de l’affichage améliorées.
|
||||
|
||||
<a id="2.7-156.0.2"></a>
|
||||
|
||||
## 2.7-156.0.2
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Réglages Android, son, luminosité, imports et notifications.
|
||||
-   : Appels, SMS, horloge et caméra confiés aux applications Android.
|
||||
-   : Écrans blancs et reprise après arrière-plan corrigés.
|
||||
-   : Ancienne numérotation : ce numéro ne correspond pas à une mise à jour de Gecko.
|
||||
|
||||
<a id="2.7-156.0.1"></a>
|
||||
|
||||
## 2.7-156.0.1
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Nom « Vulpes OS – Preview » et icône du projet.
|
||||
-   : Clignotement du fond d’écran corrigé.
|
||||
-   : Ancienne numérotation, conservée pour retrouver cette publication.
|
||||
|
||||
[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-156.0.1)
|
||||
|
||||
<a id="2.7-preview.2"></a>
|
||||
|
||||
## 2.7-preview.2
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Clignotement du fond d’écran corrigé.
|
||||
|
||||
[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-preview.2)
|
||||
|
||||
<a id="2.7-preview.1"></a>
|
||||
|
||||
## 2.7-preview.1
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Première Preview Android pour découvrir Vulpes sans flasher le téléphone.
|
||||
-   : Fond d’écran clignotant connu dans cette première version.
|
||||
|
||||
[Release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-preview.1)
|
||||
|
||||
<a id="history"></a>
|
||||
|
||||
## Premières étapes
|
||||
|
||||
### 
|
||||
|
||||
-   : Adaptation des contacts, messages, paramètres, alarmes et du stockage.
|
||||
-   : Création de Vulpes : identité visuelle, traductions et informations système.
|
||||
-   : Passage des anciens moteurs Firefox OS à un Gecko moderne.
|
||||
|
||||
### 
|
||||
|
||||
-   : Premiers démarrages natifs et essais matériels sur le Pixel 3a.
|
||||
373
LICENSE
Normal file
373
LICENSE
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
Mozilla Public License Version 2.0
|
||||
==================================
|
||||
|
||||
1. Definitions
|
||||
--------------
|
||||
|
||||
1.1. "Contributor"
|
||||
means each individual or legal entity that creates, contributes to
|
||||
the creation of, or owns Covered Software.
|
||||
|
||||
1.2. "Contributor Version"
|
||||
means the combination of the Contributions of others (if any) used
|
||||
by a Contributor and that particular Contributor's Contribution.
|
||||
|
||||
1.3. "Contribution"
|
||||
means Covered Software of a particular Contributor.
|
||||
|
||||
1.4. "Covered Software"
|
||||
means Source Code Form to which the initial Contributor has attached
|
||||
the notice in Exhibit A, the Executable Form of such Source Code
|
||||
Form, and Modifications of such Source Code Form, in each case
|
||||
including portions thereof.
|
||||
|
||||
1.5. "Incompatible With Secondary Licenses"
|
||||
means
|
||||
|
||||
(a) that the initial Contributor has attached the notice described
|
||||
in Exhibit B to the Covered Software; or
|
||||
|
||||
(b) that the Covered Software was made available under the terms of
|
||||
version 1.1 or earlier of the License, but not also under the
|
||||
terms of a Secondary License.
|
||||
|
||||
1.6. "Executable Form"
|
||||
means any form of the work other than Source Code Form.
|
||||
|
||||
1.7. "Larger Work"
|
||||
means a work that combines Covered Software with other material, in
|
||||
a separate file or files, that is not Covered Software.
|
||||
|
||||
1.8. "License"
|
||||
means this document.
|
||||
|
||||
1.9. "Licensable"
|
||||
means having the right to grant, to the maximum extent possible,
|
||||
whether at the time of the initial grant or subsequently, any and
|
||||
all of the rights conveyed by this License.
|
||||
|
||||
1.10. "Modifications"
|
||||
means any of the following:
|
||||
|
||||
(a) any file in Source Code Form that results from an addition to,
|
||||
deletion from, or modification of the contents of Covered
|
||||
Software; or
|
||||
|
||||
(b) any new file in Source Code Form that contains any Covered
|
||||
Software.
|
||||
|
||||
1.11. "Patent Claims" of a Contributor
|
||||
means any patent claim(s), including without limitation, method,
|
||||
process, and apparatus claims, in any patent Licensable by such
|
||||
Contributor that would be infringed, but for the grant of the
|
||||
License, by the making, using, selling, offering for sale, having
|
||||
made, import, or transfer of either its Contributions or its
|
||||
Contributor Version.
|
||||
|
||||
1.12. "Secondary License"
|
||||
means either the GNU General Public License, Version 2.0, the GNU
|
||||
Lesser General Public License, Version 2.1, the GNU Affero General
|
||||
Public License, Version 3.0, or any later versions of those
|
||||
licenses.
|
||||
|
||||
1.13. "Source Code Form"
|
||||
means the form of the work preferred for making modifications.
|
||||
|
||||
1.14. "You" (or "Your")
|
||||
means an individual or a legal entity exercising rights under this
|
||||
License. For legal entities, "You" includes any entity that
|
||||
controls, is controlled by, or is under common control with You. For
|
||||
purposes of this definition, "control" means (a) the power, direct
|
||||
or indirect, to cause the direction or management of such entity,
|
||||
whether by contract or otherwise, or (b) ownership of more than
|
||||
fifty percent (50%) of the outstanding shares or beneficial
|
||||
ownership of such entity.
|
||||
|
||||
2. License Grants and Conditions
|
||||
--------------------------------
|
||||
|
||||
2.1. Grants
|
||||
|
||||
Each Contributor hereby grants You a world-wide, royalty-free,
|
||||
non-exclusive license:
|
||||
|
||||
(a) under intellectual property rights (other than patent or trademark)
|
||||
Licensable by such Contributor to use, reproduce, make available,
|
||||
modify, display, perform, distribute, and otherwise exploit its
|
||||
Contributions, either on an unmodified basis, with Modifications, or
|
||||
as part of a Larger Work; and
|
||||
|
||||
(b) under Patent Claims of such Contributor to make, use, sell, offer
|
||||
for sale, have made, import, and otherwise transfer either its
|
||||
Contributions or its Contributor Version.
|
||||
|
||||
2.2. Effective Date
|
||||
|
||||
The licenses granted in Section 2.1 with respect to any Contribution
|
||||
become effective for each Contribution on the date the Contributor first
|
||||
distributes such Contribution.
|
||||
|
||||
2.3. Limitations on Grant Scope
|
||||
|
||||
The licenses granted in this Section 2 are the only rights granted under
|
||||
this License. No additional rights or licenses will be implied from the
|
||||
distribution or licensing of Covered Software under this License.
|
||||
Notwithstanding Section 2.1(b) above, no patent license is granted by a
|
||||
Contributor:
|
||||
|
||||
(a) for any code that a Contributor has removed from Covered Software;
|
||||
or
|
||||
|
||||
(b) for infringements caused by: (i) Your and any other third party's
|
||||
modifications of Covered Software, or (ii) the combination of its
|
||||
Contributions with other software (except as part of its Contributor
|
||||
Version); or
|
||||
|
||||
(c) under Patent Claims infringed by Covered Software in the absence of
|
||||
its Contributions.
|
||||
|
||||
This License does not grant any rights in the trademarks, service marks,
|
||||
or logos of any Contributor (except as may be necessary to comply with
|
||||
the notice requirements in Section 3.4).
|
||||
|
||||
2.4. Subsequent Licenses
|
||||
|
||||
No Contributor makes additional grants as a result of Your choice to
|
||||
distribute the Covered Software under a subsequent version of this
|
||||
License (see Section 10.2) or under the terms of a Secondary License (if
|
||||
permitted under the terms of Section 3.3).
|
||||
|
||||
2.5. Representation
|
||||
|
||||
Each Contributor represents that the Contributor believes its
|
||||
Contributions are its original creation(s) or it has sufficient rights
|
||||
to grant the rights to its Contributions conveyed by this License.
|
||||
|
||||
2.6. Fair Use
|
||||
|
||||
This License is not intended to limit any rights You have under
|
||||
applicable copyright doctrines of fair use, fair dealing, or other
|
||||
equivalents.
|
||||
|
||||
2.7. Conditions
|
||||
|
||||
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
|
||||
in Section 2.1.
|
||||
|
||||
3. Responsibilities
|
||||
-------------------
|
||||
|
||||
3.1. Distribution of Source Form
|
||||
|
||||
All distribution of Covered Software in Source Code Form, including any
|
||||
Modifications that You create or to which You contribute, must be under
|
||||
the terms of this License. You must inform recipients that the Source
|
||||
Code Form of the Covered Software is governed by the terms of this
|
||||
License, and how they can obtain a copy of this License. You may not
|
||||
attempt to alter or restrict the recipients' rights in the Source Code
|
||||
Form.
|
||||
|
||||
3.2. Distribution of Executable Form
|
||||
|
||||
If You distribute Covered Software in Executable Form then:
|
||||
|
||||
(a) such Covered Software must also be made available in Source Code
|
||||
Form, as described in Section 3.1, and You must inform recipients of
|
||||
the Executable Form how they can obtain a copy of such Source Code
|
||||
Form by reasonable means in a timely manner, at a charge no more
|
||||
than the cost of distribution to the recipient; and
|
||||
|
||||
(b) You may distribute such Executable Form under the terms of this
|
||||
License, or sublicense it under different terms, provided that the
|
||||
license for the Executable Form does not attempt to limit or alter
|
||||
the recipients' rights in the Source Code Form under this License.
|
||||
|
||||
3.3. Distribution of a Larger Work
|
||||
|
||||
You may create and distribute a Larger Work under terms of Your choice,
|
||||
provided that You also comply with the requirements of this License for
|
||||
the Covered Software. If the Larger Work is a combination of Covered
|
||||
Software with a work governed by one or more Secondary Licenses, and the
|
||||
Covered Software is not Incompatible With Secondary Licenses, this
|
||||
License permits You to additionally distribute such Covered Software
|
||||
under the terms of such Secondary License(s), so that the recipient of
|
||||
the Larger Work may, at their option, further distribute the Covered
|
||||
Software under the terms of either this License or such Secondary
|
||||
License(s).
|
||||
|
||||
3.4. Notices
|
||||
|
||||
You may not remove or alter the substance of any license notices
|
||||
(including copyright notices, patent notices, disclaimers of warranty,
|
||||
or limitations of liability) contained within the Source Code Form of
|
||||
the Covered Software, except that You may alter any license notices to
|
||||
the extent required to remedy known factual inaccuracies.
|
||||
|
||||
3.5. Application of Additional Terms
|
||||
|
||||
You may choose to offer, and to charge a fee for, warranty, support,
|
||||
indemnity or liability obligations to one or more recipients of Covered
|
||||
Software. However, You may do so only on Your own behalf, and not on
|
||||
behalf of any Contributor. You must make it absolutely clear that any
|
||||
such warranty, support, indemnity, or liability obligation is offered by
|
||||
You alone, and You hereby agree to indemnify every Contributor for any
|
||||
liability incurred by such Contributor as a result of warranty, support,
|
||||
indemnity or liability terms You offer. You may include additional
|
||||
disclaimers of warranty and limitations of liability specific to any
|
||||
jurisdiction.
|
||||
|
||||
4. Inability to Comply Due to Statute or Regulation
|
||||
---------------------------------------------------
|
||||
|
||||
If it is impossible for You to comply with any of the terms of this
|
||||
License with respect to some or all of the Covered Software due to
|
||||
statute, judicial order, or regulation then You must: (a) comply with
|
||||
the terms of this License to the maximum extent possible; and (b)
|
||||
describe the limitations and the code they affect. Such description must
|
||||
be placed in a text file included with all distributions of the Covered
|
||||
Software under this License. Except to the extent prohibited by statute
|
||||
or regulation, such description must be sufficiently detailed for a
|
||||
recipient of ordinary skill to be able to understand it.
|
||||
|
||||
5. Termination
|
||||
--------------
|
||||
|
||||
5.1. The rights granted under this License will terminate automatically
|
||||
if You fail to comply with any of its terms. However, if You become
|
||||
compliant, then the rights granted under this License from a particular
|
||||
Contributor are reinstated (a) provisionally, unless and until such
|
||||
Contributor explicitly and finally terminates Your grants, and (b) on an
|
||||
ongoing basis, if such Contributor fails to notify You of the
|
||||
non-compliance by some reasonable means prior to 60 days after You have
|
||||
come back into compliance. Moreover, Your grants from a particular
|
||||
Contributor are reinstated on an ongoing basis if such Contributor
|
||||
notifies You of the non-compliance by some reasonable means, this is the
|
||||
first time You have received notice of non-compliance with this License
|
||||
from such Contributor, and You become compliant prior to 30 days after
|
||||
Your receipt of the notice.
|
||||
|
||||
5.2. If You initiate litigation against any entity by asserting a patent
|
||||
infringement claim (excluding declaratory judgment actions,
|
||||
counter-claims, and cross-claims) alleging that a Contributor Version
|
||||
directly or indirectly infringes any patent, then the rights granted to
|
||||
You by any and all Contributors for the Covered Software under Section
|
||||
2.1 of this License shall terminate.
|
||||
|
||||
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
|
||||
end user license agreements (excluding distributors and resellers) which
|
||||
have been validly granted by You or Your distributors under this License
|
||||
prior to termination shall survive termination.
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 6. Disclaimer of Warranty *
|
||||
* ------------------------- *
|
||||
* *
|
||||
* Covered Software is provided under this License on an "as is" *
|
||||
* basis, without warranty of any kind, either expressed, implied, or *
|
||||
* statutory, including, without limitation, warranties that the *
|
||||
* Covered Software is free of defects, merchantable, fit for a *
|
||||
* particular purpose or non-infringing. The entire risk as to the *
|
||||
* quality and performance of the Covered Software is with You. *
|
||||
* Should any Covered Software prove defective in any respect, You *
|
||||
* (not any Contributor) assume the cost of any necessary servicing, *
|
||||
* repair, or correction. This disclaimer of warranty constitutes an *
|
||||
* essential part of this License. No use of any Covered Software is *
|
||||
* authorized under this License except under this disclaimer. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 7. Limitation of Liability *
|
||||
* -------------------------- *
|
||||
* *
|
||||
* Under no circumstances and under no legal theory, whether tort *
|
||||
* (including negligence), contract, or otherwise, shall any *
|
||||
* Contributor, or anyone who distributes Covered Software as *
|
||||
* permitted above, be liable to You for any direct, indirect, *
|
||||
* special, incidental, or consequential damages of any character *
|
||||
* including, without limitation, damages for lost profits, loss of *
|
||||
* goodwill, work stoppage, computer failure or malfunction, or any *
|
||||
* and all other commercial damages or losses, even if such party *
|
||||
* shall have been informed of the possibility of such damages. This *
|
||||
* limitation of liability shall not apply to liability for death or *
|
||||
* personal injury resulting from such party's negligence to the *
|
||||
* extent applicable law prohibits such limitation. Some *
|
||||
* jurisdictions do not allow the exclusion or limitation of *
|
||||
* incidental or consequential damages, so this exclusion and *
|
||||
* limitation may not apply to You. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
8. Litigation
|
||||
-------------
|
||||
|
||||
Any litigation relating to this License may be brought only in the
|
||||
courts of a jurisdiction where the defendant maintains its principal
|
||||
place of business and such litigation shall be governed by laws of that
|
||||
jurisdiction, without reference to its conflict-of-law provisions.
|
||||
Nothing in this Section shall prevent a party's ability to bring
|
||||
cross-claims or counter-claims.
|
||||
|
||||
9. Miscellaneous
|
||||
----------------
|
||||
|
||||
This License represents the complete agreement concerning the subject
|
||||
matter hereof. If any provision of this License is held to be
|
||||
unenforceable, such provision shall be reformed only to the extent
|
||||
necessary to make it enforceable. Any law or regulation which provides
|
||||
that the language of a contract shall be construed against the drafter
|
||||
shall not be used to construe this License against a Contributor.
|
||||
|
||||
10. Versions of the License
|
||||
---------------------------
|
||||
|
||||
10.1. New Versions
|
||||
|
||||
Mozilla Foundation is the license steward. Except as provided in Section
|
||||
10.3, no one other than the license steward has the right to modify or
|
||||
publish new versions of this License. Each version will be given a
|
||||
distinguishing version number.
|
||||
|
||||
10.2. Effect of New Versions
|
||||
|
||||
You may distribute the Covered Software under the terms of the version
|
||||
of the License under which You originally received the Covered Software,
|
||||
or under the terms of any subsequent version published by the license
|
||||
steward.
|
||||
|
||||
10.3. Modified Versions
|
||||
|
||||
If you create software not governed by this License, and you want to
|
||||
create a new license for such software, you may create and use a
|
||||
modified version of this License if you rename the license and remove
|
||||
any references to the name of the license steward (except to note that
|
||||
such modified license differs from this License).
|
||||
|
||||
10.4. Distributing Source Code Form that is Incompatible With Secondary
|
||||
Licenses
|
||||
|
||||
If You choose to distribute Source Code Form that is Incompatible With
|
||||
Secondary Licenses under the terms of this version of the License, the
|
||||
notice described in Exhibit B of this License must be attached.
|
||||
|
||||
Exhibit A - Source Code Form License Notice
|
||||
-------------------------------------------
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
|
||||
If it is not possible or desirable to put the notice in a particular
|
||||
file, then You may include the notice in a location (such as a LICENSE
|
||||
file in a relevant directory) where a recipient would be likely to look
|
||||
for such a notice.
|
||||
|
||||
You may add additional accurate notices of copyright ownership.
|
||||
|
||||
Exhibit B - "Incompatible With Secondary Licenses" Notice
|
||||
---------------------------------------------------------
|
||||
|
||||
This Source Code Form is "Incompatible With Secondary Licenses", as
|
||||
defined by the Mozilla Public License, v. 2.0.
|
||||
24
NOTICE
Normal file
24
NOTICE
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
Vulpes OS — community project by oversu / NNS Production
|
||||
https://vulpes-os.org
|
||||
https://nnsprod.com/git/oversu/VulpesOS
|
||||
|
||||
Original Vulpes host, adapter and tooling additions are licensed under the
|
||||
Mozilla Public License 2.0 (see LICENSE). Preserve the applicable copyright,
|
||||
license and attribution notices when redistributing modified files.
|
||||
|
||||
Inherited components retain their original licenses:
|
||||
- Gaia: Mozilla Foundation and contributors; see gaia/LICENSE (Apache 2.0)
|
||||
and the notices in individual files and bundled components.
|
||||
- Firefox / Gecko / GeckoView: Mozilla and contributors; engine versions,
|
||||
download locations and source archives are pinned in engine-lock.json,
|
||||
release.json and the Android Gradle configuration. Engines are downloaded
|
||||
separately; they are not stored in this Git repository.
|
||||
- Fira fonts and Gaia icons: see gaia/shared/elements/vulpes-fonts/ and the
|
||||
notices included with each font and application.
|
||||
- Tundra references AOSP, Debian, Droidian, libhybris, UBports, Capyloon and
|
||||
other upstream projects. Their original licenses remain applicable.
|
||||
Publishing the Tundra tooling does not relicense proprietary vendor files.
|
||||
|
||||
Vulpes OS is independent of Mozilla. Firefox and other third-party trademarks
|
||||
belong to their respective owners. This project is not an official Firefox OS
|
||||
release or an endorsement by those organizations.
|
||||
242
README.fr.md
242
README.fr.md
|
|
@ -1,219 +1,83 @@
|
|||
<p align="center">
|
||||
<a href="https://nnsprod.com/git/oversu/VulpesOS" lang="en" hreflang="en">🇬🇧 English</a> · <a href="https://nnsprod.com/git/oversu/VulpesOS/src/branch/main/README.fr.md" lang="fr" hreflang="fr"><strong>🇫🇷 Français</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/media/vulpes-logo.png" width="160" alt="Logo Vulpes OS — un renard blanc et bleu">
|
||||
</p>
|
||||
|
||||
<p align="center"><a href="https://nnsprod.com/git/oversu/VulpesOS/src/branch/main/README.md">English</a> · <strong>Français</strong></p>
|
||||
<p align="center"><img src="docs/media/vulpes-logo.png" width="120" alt="Vulpes OS"></p>
|
||||
<h1 align="center">Vulpes OS</h1>
|
||||
<p align="center">Firefox OS fork · Open source</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>L’esprit Firefox OS. Un moteur Gecko d’aujourd’hui.</strong><br>
|
||||
A community continuation of Firefox OS.
|
||||
<a href="https://nnsprod.com/git/oversu/VulpesOS/releases"><img src="https://img.shields.io/badge/Vulpes-2.7%20Preview%2020-0755be?style=flat-square" alt="Vulpes: 2.7 Preview 20"></a>
|
||||
<img src="https://img.shields.io/badge/Gaia-2.7-0755be?style=flat-square" alt="Gaia: 2.7">
|
||||
<img src="https://img.shields.io/badge/Gecko-156.0.1-0099cc?style=flat-square" alt="Gecko: 156.0.1">
|
||||
<img src="https://img.shields.io/badge/Tundra-0.1.0-0099cc?style=flat-square" alt="Tundra: 0.1.0">
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://nnsprod.com/git/oversu/VulpesOS/releases"><img src="https://img.shields.io/badge/Android%20APK-Available-26764a?style=flat-square" alt="Android APK: Available"></a>
|
||||
<a href="#linux"><img src="https://img.shields.io/badge/Desktop-Available-26764a?style=flat-square" alt="Desktop: Available"></a>
|
||||
<a href="https://vulpes-os.org/devices/pixel-3a.php?lang=fr"><img src="https://img.shields.io/badge/Native%20image-In%20development-996516?style=flat-square" alt="Native image: In development"></a>
|
||||
<a href="https://vulpes-os.org/devices/pixel-3a.php?lang=fr"><img src="https://img.shields.io/badge/Pixel%203a-Bootable%20and%20usable-26764a?style=flat-square" alt="Pixel 3a: Bootable and usable"></a>
|
||||
</p>
|
||||
|
||||
[Site](https://vulpes-os.org/?lang=fr) · [Téléchargements](https://nnsprod.com/git/oversu/VulpesOS/releases) · [Changelog](CHANGELOG.md) · [Signaler un bug](https://nnsprod.com/git/oversu/VulpesOS/issues)
|
||||
|
||||
Vulpes OS reprend Firefox OS pour faire fonctionner **Gaia**, son interface et ses applications HTML/CSS/JavaScript, avec un moteur **Gecko** récent.
|
||||
|
||||
Je développe Vulpes principalement seul, sous le nom d’OverSu, avec l’aide des personnes qui testent et contribuent au projet. **Vulpes est libre et gratuit, et le restera.** Aucun abonnement ni fonctionnalité payante n’est prévu.
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Vulpes-2.7-0755be?style=flat-square" alt="Vulpes 2.7">
|
||||
<img src="https://img.shields.io/badge/Gecko_desktop-156.0.1-0099cc?style=flat-square" alt="Gecko desktop 156.0.1">
|
||||
<img src="https://img.shields.io/badge/Statut-Preview-f3a712?style=flat-square" alt="Statut : Preview">
|
||||
<img src="https://img.shields.io/badge/Plateformes-Linux_%C2%B7_Android-334155?style=flat-square" alt="Linux et Android">
|
||||
<a href="https://nnsprod.com/git/oversu/VulpesOS/media/branch/main/docs/media/vulpes-desktop.png"><img src="docs/media/vulpes-desktop.png" width="260" alt="L’accueil de Gaia"></a>
|
||||
<a href="https://nnsprod.com/git/oversu/VulpesOS/media/branch/main/docs/media/vulpes-gecko.png"><img src="docs/media/vulpes-gecko.png" width="260" alt="Les paramètres de Gaia, écran Informations système"></a>
|
||||
</p>
|
||||
<p align="center"><em>Accueil et paramètres de Gaia · captures de la version pour ordinateur.</em></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://vulpes-os.org"><strong>🌐 Découvrir le projet</strong></a>
|
||||
·
|
||||
<a href="#telechargements"><strong>📥 APK Android</strong></a>
|
||||
·
|
||||
<a href="#points-forts">Les points forts</a>
|
||||
·
|
||||
<a href="#architecture">L’architecture</a>
|
||||
·
|
||||
<a href="#suite">La suite</a>
|
||||
</p>
|
||||
## Essayer Vulpes
|
||||
|
||||
---
|
||||
| Plateforme | Comment l’utiliser |
|
||||
| --- | --- |
|
||||
| **Linux** | Lancer Gaia depuis les sources pour découvrir l’interface ou développer les applications. |
|
||||
| **Android** | Installer l’[APK disponible dans les releases](https://nnsprod.com/git/oversu/VulpesOS/releases). Il fonctionne dans Android et ne remplace pas le système du téléphone. |
|
||||
| **Google Pixel 3a · Sargo** | Faire fonctionner Vulpes comme système natif avec Tundra. L’image à télécharger est encore en préparation ; les fonctions testées et les limites sont décrites dans la [fiche de l’appareil](https://vulpes-os.org/devices/pixel-3a.php?lang=fr). |
|
||||
|
||||
## 🦊 Le Web comme plateforme d’applications
|
||||
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.
|
||||
|
||||
Et si l’interface de votre téléphone était faite avec les mêmes technologies que vos sites web ?
|
||||
### Preview 20 · 13 octobre 2026
|
||||
|
||||
**Vulpes redonne vie à cette idée de Firefox OS.** L’accueil, les paramètres et les applications Gaia retrouvent un environnement d’exécution sur un Gecko récent. HTML, CSS et JavaScript restent au cœur de l’expérience : une interface que l’on peut lire, comprendre et modifier.
|
||||
[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.
|
||||
|
||||
Le projet relie deux générations : **les applications et l’interface historiques de Firefox OS**, et **le moteur web maintenu par Mozilla**. Son ambition est de faire évoluer l’un sans devoir réécrire l’autre à chaque version.
|
||||
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é.
|
||||
|
||||
**Un seul projet, deux façons de l’utiliser : l’émulateur desktop Linux et l’application Android Vulpes OS - Preview (APK).** Ils partagent Gaia et les services Vulpes, avec une intégration Gecko adaptée à chaque plateforme.
|
||||
### Linux
|
||||
|
||||
> **Disponible :** les trois Preview Android sont téléchargeables dans les [Releases](https://nnsprod.com/git/oversu/VulpesOS/releases). Les sources complètes, la distribution desktop et leurs instructions d’installation sont en préparation. Les captures ci-dessous montrent le prototype desktop.
|
||||
Prérequis : Linux x86_64, Git, Python 3 et les bibliothèques système nécessaires à Firefox.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://nnsprod.com/git/oversu/VulpesOS/media/branch/main/docs/media/vulpes-desktop.png"><img src="docs/media/vulpes-desktop.png" width="300" alt="Capture réelle de Vulpes : accueil Gaia en anglais"></a>
|
||||
|
||||
<a href="https://nnsprod.com/git/oversu/VulpesOS/media/branch/main/docs/media/vulpes-gecko.png"><img src="docs/media/vulpes-gecko.png" width="300" alt="Paramètres Vulpes en anglais, affichant la version réelle du moteur Gecko"></a>
|
||||
</p>
|
||||
|
||||
<p align="center"><em>Accueil et informations système — captures réelles du prototype desktop en anglais. Le champ « Platform Version » affiche la version Gecko renvoyée par le moteur exécuté. Cliquer sur une image pour l’agrandir.</em></p>
|
||||
|
||||
<a name="telechargements"></a>
|
||||
|
||||
## 📥 Tester la Preview Android
|
||||
|
||||
**Pour commencer, choisissez [Vulpes OS - Preview 2.7-156.0.1](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-156.0.1)**, la plus récente des trois versions. Chaque page contient l’APK, les notes FR/EN et le fichier de contrôle `SHA256SUMS.txt`.
|
||||
|
||||
| Version | Contenu | Usage |
|
||||
| :--- | :--- | :--- |
|
||||
| [2.7-156.0.1](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-156.0.1) | Nom et icône Vulpes OS - Preview ; correctif du fond d’écran inclus | **Preview conseillée** |
|
||||
| [2.7-preview.2](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-preview.2) | Correction du fond d’écran clignotant | Archive |
|
||||
| [2.7-preview.1](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-preview.1) | Première Preview Android | Archive — clignotement du fond d’écran connu |
|
||||
|
||||
Téléchargez l’APK et ouvrez-le sur Android pour l’installer. Les versions successives conservent le même identifiant et la même signature : une mise à jour par-dessus la précédente permet de garder les données.
|
||||
|
||||
- **Android 8.0 / API 26 minimum déclaré.** Validation sur un émulateur Android 16 / API 36 ; les autres configurations restent à tester.
|
||||
- **Environ 592 Mio par APK**, avec les architectures ARM64, ARM 32 bits et x86_64 intégrées.
|
||||
- **Preview de développement :** elle ne remplace pas Android et ne fournit pas les appels/SMS cellulaires. L’import des médias Android reste à porter.
|
||||
- **Desktop Linux :** environnement de développement distinct, encore sans distribution publique prête à installer. Ce simulateur affiche Gaia ; il n’émule pas tout le matériel d’un téléphone.
|
||||
|
||||
Les APK sont joints aux Releases, sans alourdir l’historique Git. Ces tags désignent les APK historiques publiés ; ils ne représentent pas encore leurs sources de compilation complètes.
|
||||
|
||||
<a name="points-forts"></a>
|
||||
|
||||
## ✨ Ce qui rend Vulpes intéressant
|
||||
|
||||
### 🔄 Un moteur qui peut évoluer
|
||||
|
||||
Le passage de Gecko 45 à 52 a servi de première étape. Le prototype desktop utilise maintenant **Gecko 156.0.1** avec une architecture qui sépare l’interface, les services et l’intégration au moteur.
|
||||
|
||||
Un outil automatise le téléchargement d’une version candidate, le contrôle de son empreinte et les tests de compatibilité. Le basculement reste explicite et un retour arrière est prévu. Les incompatibilités sont détectées et documentées ; elles ne sont pas réparées automatiquement.
|
||||
|
||||
### 🧩 Faire vivre les applications Firefox OS
|
||||
|
||||
Vulpes adapte les anciennes API dont Gaia a besoin : réglages, registre des applications, contacts locaux, stockage et communications entre applications. Le travail porte sur leur fonctionnement, leurs permissions et leurs données persistantes.
|
||||
|
||||
Des essais avec des applications historiques accompagnent le portage. **La compatibilité progresse application par application** : la présence d’une ancienne API ne garantit pas encore le fonctionnement de tout le catalogue Firefox OS.
|
||||
|
||||
### 🛠️ Modifier l’interface sans reconstruire Gecko
|
||||
|
||||
Une grande partie de l’interface se travaille dans les fichiers HTML, CSS et JavaScript. Les ressources servies et les surcharges peuvent être modifiées puis rechargées, sans recompiler le moteur.
|
||||
|
||||
Certaines applications conservent des bundles issus de l’ancien système de construction Gaia. Leur modification demande de vérifier quel fichier est réellement chargé : tous les fichiers des sources historiques ne sont pas encore reliés directement à l’interface exécutée.
|
||||
|
||||
### 📱 Deux façons d’explorer le projet
|
||||
|
||||
- **Sur Linux :** une fenêtre au format téléphone, avec Gaia, le multitâche et un navigateur utilisant des vues Gecko séparées.
|
||||
- **Sur Android :** **Vulpes OS – Preview**, une application GeckoView pour découvrir l’expérience sans remplacer le système du téléphone.
|
||||
|
||||
La Preview Android est un terrain d’essai. Elle n’est pas une ROM et ne remplace pas les fonctions de téléphonie d’Android.
|
||||
|
||||
## 🧪 Où en est le prototype ?
|
||||
|
||||
| Fonction | Desktop Linux | Preview Android |
|
||||
| :--- | :--- | :--- |
|
||||
| Accueil Gaia, lancement des applications | ✅ Fonctionnel | ✅ Disponible |
|
||||
| Réglages persistants et interface traduite | ✅ Parcours testés | ✅ Intégrés, validation mobile à poursuivre |
|
||||
| Navigation web | ✅ Vues Gecko séparées | ✅ Sessions GeckoView séparées |
|
||||
| Contacts | ✅ Contacts locaux au profil | ✅ Contacts locaux à Vulpes |
|
||||
| SMS | 🧪 Interface et brouillons locaux | 🧪 Interface locale |
|
||||
| Galerie et musique | ✅ Import et lecture testés | 🚧 Import des médias Android à porter |
|
||||
| Alarmes | ✅ Création, déclenchement et persistance testés | 🧪 Liées à la durée de vie du processus Vulpes |
|
||||
| Appels et SMS sur le réseau mobile | 🚧 Non intégrés | 🚧 Non intégrés |
|
||||
| Installation comme système de téléphone | 🚧 Futur chantier | 🚧 L’APK ne remplace pas Android |
|
||||
|
||||
**Versions de référence — septembre 2026**
|
||||
|
||||
| Élément | Version |
|
||||
| :--- | :--- |
|
||||
| Produit communautaire | Vulpes OS 2.7 |
|
||||
| Moteur desktop | Gecko 156.0.1 |
|
||||
| Application Android | Preview 2.7-156.0.1 |
|
||||
| Dépendance GeckoView | `156.0.20260921121718` |
|
||||
| Minimum Android déclaré | Android 8.0 · API 26 |
|
||||
|
||||
La version de l’APK, celle du moteur et celle du produit sont distinctes. Le `.1` final de la Preview désigne sa révision ; GeckoView rapporte ici **156.0**. Le minimum Android déclaré ne signifie pas que chaque appareil a été testé.
|
||||
|
||||
<a name="architecture"></a>
|
||||
|
||||
## ⚙️ Sous le capot
|
||||
|
||||
```text
|
||||
Gaia + applications web
|
||||
HTML · CSS · JS
|
||||
│
|
||||
Compatibilité des anciennes API moz*
|
||||
Contrats Vulpes versionnés
|
||||
│
|
||||
Réglages · Applications · Contacts · Stockage
|
||||
Messages locaux · Alarmes
|
||||
│
|
||||
┌────────────┴────────────┐
|
||||
│ │
|
||||
Hôte desktop Hôte Android
|
||||
Fenêtre privilégiée GeckoView + passerelle
|
||||
et vues web séparées WebExtension/native
|
||||
│ │
|
||||
Firefox / Gecko GeckoView
|
||||
│ │
|
||||
Linux Android
|
||||
```sh
|
||||
git clone https://nnsprod.com/git/oversu/VulpesOS.git
|
||||
cd VulpesOS
|
||||
./setup.sh
|
||||
./run.sh
|
||||
```
|
||||
|
||||
### Une frontière claire entre les applications et le système
|
||||
`setup.sh` télécharge le moteur Gecko prévu pour cette version et vérifie son empreinte.
|
||||
|
||||
Les applications passent par des services dont les permissions sont contrôlées par l’hôte. Les pages web externes utilisent des contextes de navigation séparés et n’obtiennent pas les API privilégiées de Vulpes.
|
||||
### Compiler l’APK
|
||||
|
||||
Le port desktop actuel s’appuie sur le **binaire officiel de Firefox**, avec un hôte et des adaptateurs Vulpes, sans modification native du moteur. Certaines interfaces utilisées par cet hôte restent internes à Gecko : les tests à chaque changement de version sont donc essentiels.
|
||||
Depuis la racine du dépôt :
|
||||
|
||||
### Des mises à jour vérifiées avant activation
|
||||
|
||||
```text
|
||||
Télécharger → Vérifier l’empreinte → Tester les interfaces
|
||||
↓
|
||||
Ancien moteur conservé ← Activer ← Tester Gaia et la persistance
|
||||
```sh
|
||||
cd android
|
||||
./build-apk.sh
|
||||
```
|
||||
|
||||
Les profils de test sont séparés des données utilisateur. L’outil conserve l’ancien moteur et son profil pour permettre un retour arrière. Le pipeline concerne aujourd’hui le desktop ; les mises à jour Android suivent le cycle de construction de l’APK.
|
||||
L’APK de développement est créé dans `android/dist/`. Une compilation personnelle utilise sa propre clé de signature : elle ne peut pas mettre à jour un APK signé avec la clé du projet.
|
||||
|
||||
### Une base de travail testée
|
||||
## Participer
|
||||
|
||||
Les contrôles automatisés couvrent notamment :
|
||||
Vous pouvez tester Vulpes, [signaler un problème](https://nnsprod.com/git/oversu/VulpesOS/issues), améliorer les traductions ou proposer du code. Pour un bug, indiquez la plateforme, la version utilisée et les étapes pour le reproduire.
|
||||
|
||||
- le démarrage de la vraie interface Gaia et le chargement des icônes ;
|
||||
- les langues, les réglages et la persistance après redémarrage ;
|
||||
- les contacts, le pavé numérique et le compositeur SMS ;
|
||||
- les médias importés, les alarmes et la navigation ;
|
||||
- les refus d’accès lorsque l’origine ou les permissions ne conviennent pas.
|
||||
Les avancées et les comptes rendus d’essais sont dans le [changelog](CHANGELOG.md). Si vous souhaitez contribuer aux frais du projet, la page [Me soutenir](https://vulpes-os.org/support.php?lang=fr) explique à quoi sert ce soutien.
|
||||
|
||||
Un export des fichiers prévus pour publication a également démarré et passé les tests desktop sans les anciens dossiers de développement. Le packaging Android et la compilation de l’APK ont été vérifiés. Cela ne constitue pas une validation exhaustive de Firefox OS ni de tous les téléphones Android.
|
||||
## Crédits et licences
|
||||
|
||||
<a name="suite"></a>
|
||||
Merci à Mozilla, aux contributeurs de Gaia, à Capyloon, Droidian, UBports, libhybris, Debian et AOSP pour le code, les outils et les travaux qui ont aidé le projet.
|
||||
|
||||
## 🗺️ La suite
|
||||
Les nouveaux fichiers Vulpes sont sous **MPL 2.0**. Gaia conserve sa licence **Apache 2.0** ; les autres composants conservent leurs licences et mentions. Voir [LICENSE](LICENSE), [NOTICE](NOTICE) et [la licence de Gaia](gaia/LICENSE).
|
||||
|
||||
- **Publier une base autonome :** sources, licences, documentation et procédure de démarrage.
|
||||
- **Consolider la compatibilité :** poursuivre les essais d’applications historiques et les corrections des parcours du quotidien.
|
||||
- **Améliorer la Preview Android :** accès aux médias, intégration à la plateforme et retours d’usage sur appareils réels.
|
||||
- **Réduire le coût des mises à jour Gecko :** maintenir des contrats stables et limiter les adaptations propres au moteur.
|
||||
- **Explorer un véritable port mobile :** matériel, modem, pilotes et intégration système, une fois la base consolidée.
|
||||
|
||||
## 🤝 Participer
|
||||
|
||||
Les retours utiles décrivent **l’appareil ou la distribution Linux, la version utilisée, les étapes pour reproduire le problème et le résultat attendu**. Une capture et un extrait de journal sans données personnelles aident beaucoup.
|
||||
|
||||
Le projet a aussi besoin de contributions en JavaScript, CSS, intégration Gecko/GeckoView, tests, traduction et documentation. Les consignes de contribution accompagneront la publication des sources.
|
||||
|
||||
## 📜 Origines et licences
|
||||
|
||||
Vulpes est construit à partir du travail de Mozilla et des contributeurs de **Boot to Gecko / Firefox OS**, notamment Gaia.
|
||||
|
||||
La licence retenue pour les nouveaux fichiers Vulpes est la **Mozilla Public License 2.0 (`MPL-2.0`)**. Gaia et les autres composants hérités conservent leurs licences, notamment **Apache 2.0** pour la base Gaia, ainsi que leurs mentions d’attribution. Les textes de licence et le détail des composants accompagneront la publication des sources.
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<strong>VULPES</strong><br>
|
||||
Firefox OS Community Project<br><br>
|
||||
<a href="https://vulpes-os.org">vulpes-os.org</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub>Vulpes est un projet communautaire indépendant, sans affiliation ni approbation de Mozilla.<br>
|
||||
Vulpes is an independent community project and is not affiliated with or endorsed by Mozilla.</sub>
|
||||
</p>
|
||||
Vulpes OS est un projet indépendant d’**OverSu / NNS Production**, sans affiliation avec Mozilla.
|
||||
|
|
|
|||
241
README.md
241
README.md
|
|
@ -1,218 +1,83 @@
|
|||
<p align="center">
|
||||
<a href="https://nnsprod.com/git/oversu/VulpesOS" lang="en" hreflang="en"><strong>🇬🇧 English</strong></a> · <a href="https://nnsprod.com/git/oversu/VulpesOS/src/branch/main/README.fr.md" lang="fr" hreflang="fr">🇫🇷 Français</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/media/vulpes-logo.png" width="160" alt="Vulpes OS logo — a white and blue fox">
|
||||
</p>
|
||||
|
||||
<p align="center"><strong>English</strong> · <a href="https://nnsprod.com/git/oversu/VulpesOS/src/branch/main/README.fr.md">Français</a></p>
|
||||
<p align="center"><img src="docs/media/vulpes-logo.png" width="120" alt="Vulpes OS"></p>
|
||||
<h1 align="center">Vulpes OS</h1>
|
||||
<p align="center">Firefox OS fork · Open source</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>The spirit of Firefox OS. Powered by a modern Gecko.</strong><br>
|
||||
A community continuation of Firefox OS.
|
||||
<a href="https://nnsprod.com/git/oversu/VulpesOS/releases"><img src="https://img.shields.io/badge/Vulpes-2.7%20Preview%2020-0755be?style=flat-square" alt="Vulpes: 2.7 Preview 20"></a>
|
||||
<img src="https://img.shields.io/badge/Gaia-2.7-0755be?style=flat-square" alt="Gaia: 2.7">
|
||||
<img src="https://img.shields.io/badge/Gecko-156.0.1-0099cc?style=flat-square" alt="Gecko: 156.0.1">
|
||||
<img src="https://img.shields.io/badge/Tundra-0.1.0-0099cc?style=flat-square" alt="Tundra: 0.1.0">
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://nnsprod.com/git/oversu/VulpesOS/releases"><img src="https://img.shields.io/badge/Android%20APK-Available-26764a?style=flat-square" alt="Android APK: Available"></a>
|
||||
<a href="#linux"><img src="https://img.shields.io/badge/Desktop-Available-26764a?style=flat-square" alt="Desktop: Available"></a>
|
||||
<a href="https://vulpes-os.org/devices/pixel-3a.php?lang=en"><img src="https://img.shields.io/badge/Native%20image-In%20development-996516?style=flat-square" alt="Native image: In development"></a>
|
||||
<a href="https://vulpes-os.org/devices/pixel-3a.php?lang=en"><img src="https://img.shields.io/badge/Pixel%203a-Bootable%20and%20usable-26764a?style=flat-square" alt="Pixel 3a: Bootable and usable"></a>
|
||||
</p>
|
||||
|
||||
[Website](https://vulpes-os.org/?lang=en) · [Downloads](https://nnsprod.com/git/oversu/VulpesOS/releases) · [Changelog](CHANGELOG.en.md) · [Report a bug](https://nnsprod.com/git/oversu/VulpesOS/issues)
|
||||
|
||||
Vulpes OS picks up where Firefox OS left off, adapting **Gaia**, its interface and HTML/CSS/JavaScript applications, to run on a recent **Gecko** engine.
|
||||
|
||||
I’m OverSu, and I develop Vulpes mostly on my own, with help from people who test and contribute to the project. **Vulpes is free and open source, and will stay that way.** There are no plans for subscriptions or paid features.
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Vulpes-2.7-0755be?style=flat-square" alt="Vulpes 2.7">
|
||||
<img src="https://img.shields.io/badge/Desktop_Gecko-156.0.1-0099cc?style=flat-square" alt="Desktop Gecko 156.0.1">
|
||||
<img src="https://img.shields.io/badge/Status-Preview-f3a712?style=flat-square" alt="Status: Preview">
|
||||
<img src="https://img.shields.io/badge/Platforms-Linux_%C2%B7_Android-334155?style=flat-square" alt="Linux and Android">
|
||||
<a href="https://nnsprod.com/git/oversu/VulpesOS/media/branch/main/docs/media/vulpes-desktop.png"><img src="docs/media/vulpes-desktop.png" width="260" alt="The Gaia home screen"></a>
|
||||
<a href="https://nnsprod.com/git/oversu/VulpesOS/media/branch/main/docs/media/vulpes-gecko.png"><img src="docs/media/vulpes-gecko.png" width="260" alt="Gaia Settings, system information screen"></a>
|
||||
</p>
|
||||
<p align="center"><em>Gaia home screen and Settings · screenshots from the desktop version.</em></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://vulpes-os.org"><strong>🌐 Discover the project</strong></a>
|
||||
·
|
||||
<a href="#downloads"><strong>📥 Android APK</strong></a>
|
||||
·
|
||||
<a href="#highlights">Highlights</a>
|
||||
·
|
||||
<a href="#architecture">Architecture</a>
|
||||
·
|
||||
<a href="#roadmap">Roadmap</a>
|
||||
</p>
|
||||
## Try Vulpes
|
||||
|
||||
---
|
||||
| Platform | How to use it |
|
||||
| --- | --- |
|
||||
| **Linux** | Run Gaia from source to explore the interface or develop applications. |
|
||||
| **Android** | Install the [APK from the releases page](https://nnsprod.com/git/oversu/VulpesOS/releases). It runs as an Android application and does not replace your phone’s operating system. |
|
||||
| **Google Pixel 3a · Sargo** | Run Vulpes as a native operating system with Tundra. A downloadable image is still being prepared; see the [device page](https://vulpes-os.org/devices/pixel-3a.php?lang=en) for tested features and known limitations. |
|
||||
|
||||
## 🦊 The Web as an application platform
|
||||
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.
|
||||
|
||||
What if your phone’s interface were built with the same technologies as your websites?
|
||||
### Preview 20 · 13 October 2026
|
||||
|
||||
**Vulpes brings that Firefox OS idea back to life.** The Gaia home screen, settings and applications run on a recent Gecko engine. HTML, CSS and JavaScript remain at the heart of the experience: an interface you can read, understand and modify.
|
||||
[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.
|
||||
|
||||
The project connects two generations: **the original Firefox OS interface and applications**, and **the web engine maintained by Mozilla**. The aim is to let one evolve without having to rewrite the other for every release.
|
||||
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.
|
||||
|
||||
**One project, two ways to run it: the Linux desktop emulator and the Vulpes OS - Preview Android application (APK).** They share Gaia and Vulpes services, with Gecko integration adapted to each platform.
|
||||
### Linux
|
||||
|
||||
> **Available now:** all three Android Previews can be downloaded from [Releases](https://nnsprod.com/git/oversu/VulpesOS/releases). Full sources, the desktop distribution and their installation instructions are being prepared. The screenshots below show the desktop prototype.
|
||||
You’ll need Linux x86_64, Git, Python 3 and the system libraries required by Firefox.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://nnsprod.com/git/oversu/VulpesOS/media/branch/main/docs/media/vulpes-desktop.png"><img src="docs/media/vulpes-desktop.png" width="300" alt="Actual Vulpes screenshot: the Gaia home screen in English"></a>
|
||||
|
||||
<a href="https://nnsprod.com/git/oversu/VulpesOS/media/branch/main/docs/media/vulpes-gecko.png"><img src="docs/media/vulpes-gecko.png" width="300" alt="Vulpes Settings in English, showing the actual Gecko engine version"></a>
|
||||
</p>
|
||||
|
||||
<p align="center"><em>Home screen and device information — actual desktop captures in English. The “Platform Version” field shows the Gecko version reported by the running engine. Click either image to enlarge it.</em></p>
|
||||
|
||||
<a name="downloads"></a>
|
||||
|
||||
## 📥 Try the Android Preview
|
||||
|
||||
**Start with [Vulpes OS - Preview 2.7-156.0.1](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-156.0.1)**, the latest of the three builds. Each release page includes the APK, English/French notes and a `SHA256SUMS.txt` checksum file.
|
||||
|
||||
| Version | Changes | Use |
|
||||
| :--- | :--- | :--- |
|
||||
| [2.7-156.0.1](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-156.0.1) | Vulpes OS - Preview name and icon; wallpaper fix included | **Recommended Preview** |
|
||||
| [2.7-preview.2](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-preview.2) | Fixes wallpaper flashing | Archive |
|
||||
| [2.7-preview.1](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-preview.1) | First Android Preview | Archive — known wallpaper flashing |
|
||||
|
||||
Download the APK and open it on Android to install it. Successive versions share the same package ID and signature: installing the newer build over the previous one preserves application data.
|
||||
|
||||
- **Declared minimum: Android 8.0 / API 26.** Validated on an Android 16 / API 36 emulator; other configurations still need testing.
|
||||
- **Approximately 592 MiB per APK**, including ARM64, 32-bit ARM and x86_64 architectures.
|
||||
- **Development Preview:** it does not replace Android or provide cellular calls/SMS. Android media import remains to be ported.
|
||||
- **Linux desktop:** a separate development environment, with no ready-to-install public distribution yet. This simulator runs Gaia; it does not emulate all phone hardware.
|
||||
|
||||
APKs are attached to Releases, keeping binaries out of Git history. These tags identify the historical APK publications; they do not yet contain their complete build sources.
|
||||
|
||||
<a name="highlights"></a>
|
||||
|
||||
## ✨ What makes Vulpes interesting
|
||||
|
||||
### 🔄 An engine that can keep evolving
|
||||
|
||||
Moving from Gecko 45 to 52 was the first step. The desktop prototype now uses **Gecko 156.0.1**, with an architecture that separates the interface, services and engine integration.
|
||||
|
||||
An update tool automates downloading a candidate version, checking its checksum and running compatibility tests. Switching versions remains an explicit action, with rollback available. Incompatibilities are detected and documented; they are not repaired automatically.
|
||||
|
||||
### 🧩 Keeping Firefox OS applications alive
|
||||
|
||||
Vulpes adapts the legacy APIs Gaia needs: settings, the application registry, local contacts, storage and communication between applications. This work covers their behavior, permissions and persistent data.
|
||||
|
||||
Tests with archived applications accompany the porting effort. **Compatibility improves one application at a time**: the presence of a legacy API does not yet guarantee that the entire Firefox OS catalog will work.
|
||||
|
||||
### 🛠️ Edit the interface without rebuilding Gecko
|
||||
|
||||
Much of the interface can be developed directly in HTML, CSS and JavaScript files. Served resources and overrides can be edited and reloaded without recompiling the engine.
|
||||
|
||||
Some applications still use bundles produced by the old Gaia build system. Editing them means checking which file is actually loaded: not every file in the historical sources is directly connected to the running interface yet.
|
||||
|
||||
### 📱 Two ways to explore the project
|
||||
|
||||
- **On Linux:** a phone-sized window with Gaia, multitasking and a browser using separate Gecko views.
|
||||
- **On Android:** **Vulpes OS – Preview**, a GeckoView application for exploring the experience without replacing your phone’s operating system.
|
||||
|
||||
The Android Preview is a testing environment. It is not a ROM and does not replace Android’s telephony features.
|
||||
|
||||
## 🧪 Prototype status
|
||||
|
||||
| Feature | Linux desktop | Android Preview |
|
||||
| :--- | :--- | :--- |
|
||||
| Gaia home screen and application launching | ✅ Working | ✅ Available |
|
||||
| Persistent settings and translated interface | ✅ Tested workflows | ✅ Integrated; further mobile validation needed |
|
||||
| Web browsing | ✅ Separate Gecko views | ✅ Separate GeckoView sessions |
|
||||
| Contacts | ✅ Local to the profile | ✅ Local to Vulpes |
|
||||
| SMS | 🧪 Interface and local drafts | 🧪 Local interface |
|
||||
| Gallery and music | ✅ Import and playback tested | 🚧 Android media import still to be ported |
|
||||
| Alarms | ✅ Creation, delivery and persistence tested | 🧪 Tied to the Vulpes process lifetime |
|
||||
| Cellular calls and SMS | 🚧 Not integrated | 🚧 Not integrated |
|
||||
| Installation as a phone operating system | 🚧 Future work | 🚧 The APK does not replace Android |
|
||||
|
||||
**Reference versions — September 2026**
|
||||
|
||||
| Component | Version |
|
||||
| :--- | :--- |
|
||||
| Community product | Vulpes OS 2.7 |
|
||||
| Desktop engine | Gecko 156.0.1 |
|
||||
| Android application | Preview 2.7-156.0.1 |
|
||||
| GeckoView dependency | `156.0.20260921121718` |
|
||||
| Declared Android minimum | Android 8.0 · API 26 |
|
||||
|
||||
The APK, engine and product have separate version numbers. The final `.1` in the Preview version identifies its revision; GeckoView reports **156.0** here. The declared Android minimum does not mean every device has been tested.
|
||||
|
||||
<a name="architecture"></a>
|
||||
|
||||
## ⚙️ Under the hood
|
||||
|
||||
```text
|
||||
Gaia + web applications
|
||||
HTML · CSS · JS
|
||||
│
|
||||
Legacy moz* API compatibility
|
||||
Versioned Vulpes contracts
|
||||
│
|
||||
Settings · Applications · Contacts · Storage
|
||||
Local messages · Alarms
|
||||
│
|
||||
┌─────────────┴─────────────┐
|
||||
│ │
|
||||
Desktop host Android host
|
||||
Privileged window GeckoView + bridge
|
||||
and separate web views WebExtension/native
|
||||
│ │
|
||||
Firefox / Gecko GeckoView
|
||||
│ │
|
||||
Linux Android
|
||||
```sh
|
||||
git clone https://nnsprod.com/git/oversu/VulpesOS.git
|
||||
cd VulpesOS
|
||||
./setup.sh
|
||||
./run.sh
|
||||
```
|
||||
|
||||
### A clear boundary between applications and the system
|
||||
`setup.sh` downloads the Gecko engine selected for this version and verifies its checksum.
|
||||
|
||||
Applications use services whose permissions are checked by the host. External web pages use separate browsing contexts and do not receive Vulpes’s privileged APIs.
|
||||
### Build the APK
|
||||
|
||||
The current desktop port uses the **official Firefox binary**, with a Vulpes host and adapters, without native engine patches. Some interfaces used by this host remain internal to Gecko, making tests essential whenever the engine version changes.
|
||||
From the repository root:
|
||||
|
||||
### Updates checked before activation
|
||||
|
||||
```text
|
||||
Download → Verify checksum → Test engine interfaces
|
||||
↓
|
||||
Keep old engine ← Activate ← Test Gaia and persistence
|
||||
```sh
|
||||
cd android
|
||||
./build-apk.sh
|
||||
```
|
||||
|
||||
Test profiles are separate from user data. The tool keeps the previous engine and its profile to allow rollback. This pipeline currently covers the desktop; Android updates follow the APK build cycle.
|
||||
The development APK is created in `android/dist/`. A personal build uses its own signing key, so it cannot update an APK signed with the project’s key.
|
||||
|
||||
### A tested development base
|
||||
## Contribute
|
||||
|
||||
Automated checks cover, among other things:
|
||||
You can test Vulpes, [report an issue](https://nnsprod.com/git/oversu/VulpesOS/issues), improve translations or contribute code. When reporting a bug, include your platform, the version you’re using and the steps to reproduce it.
|
||||
|
||||
- booting the actual Gaia interface and loading application icons;
|
||||
- languages, settings and persistence across restarts;
|
||||
- contacts, the dial pad and the SMS composer;
|
||||
- imported media, alarms and web browsing;
|
||||
- rejecting access when the origin or permissions do not match.
|
||||
Development updates and test results are in the [changelog](CHANGELOG.en.md). If you’d like to help cover project costs, the [Support me](https://vulpes-os.org/support.php?lang=en) page explains what your contribution supports.
|
||||
|
||||
An export of the files planned for publication also booted and passed the desktop tests without the old development directories. Android asset packaging and APK compilation have been verified. This is not exhaustive validation of Firefox OS or every Android phone.
|
||||
## Credits and licenses
|
||||
|
||||
<a name="roadmap"></a>
|
||||
Thanks to Mozilla, Gaia contributors, Capyloon, Droidian, UBports, libhybris, Debian and AOSP for the code, tools and work that have helped this project.
|
||||
|
||||
## 🗺️ What’s next
|
||||
New Vulpes code is licensed under **MPL 2.0**. Gaia retains its **Apache 2.0** license; other components retain their own licenses and notices. See [LICENSE](LICENSE), [NOTICE](NOTICE) and [Gaia’s license](gaia/LICENSE).
|
||||
|
||||
- **Publish a self-contained base:** source code, licenses, documentation and startup instructions.
|
||||
- **Strengthen compatibility:** continue testing historical applications and improving everyday workflows.
|
||||
- **Improve the Android Preview:** media access, platform integration and feedback from real devices.
|
||||
- **Reduce Gecko update maintenance:** keep service contracts stable and limit engine-specific adaptations.
|
||||
- **Explore a full mobile port:** hardware, modem, drivers and system integration once the foundation is ready.
|
||||
|
||||
## 🤝 Get involved
|
||||
|
||||
Useful reports include **the device or Linux distribution, the version in use, steps to reproduce the issue and the expected result**. A screenshot and a log excerpt without personal data help a lot.
|
||||
|
||||
The project also welcomes work on JavaScript, CSS, Gecko/GeckoView integration, testing, translation and documentation. Contribution guidelines will accompany the source release.
|
||||
|
||||
## 📜 Origins and licenses
|
||||
|
||||
Vulpes builds on the work of Mozilla and the contributors to **Boot to Gecko / Firefox OS**, including Gaia.
|
||||
|
||||
The license selected for new Vulpes files is the **Mozilla Public License 2.0 (`MPL-2.0`)**. Gaia and other inherited components retain their own licenses, including **Apache 2.0** for the Gaia base, along with their attribution notices. License texts and component details will accompany the source release.
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<strong>VULPES</strong><br>
|
||||
Firefox OS Community Project<br><br>
|
||||
<a href="https://vulpes-os.org">vulpes-os.org</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub>Vulpes is an independent community project and is not affiliated with or endorsed by Mozilla.</sub>
|
||||
</p>
|
||||
Vulpes OS is an independent project by **OverSu / NNS Production**, with no affiliation with Mozilla.
|
||||
|
|
|
|||
72
adapters/datastores-indexeddb.mjs
Normal file
72
adapters/datastores-indexeddb.mjs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
export class DataStoresIndexedDB {
|
||||
constructor(factory, keyRange, uuid) {
|
||||
Object.assign(this, { factory, keyRange, uuid });
|
||||
}
|
||||
open() {
|
||||
return (this.database ||= new Promise((resolve, reject) => {
|
||||
const request = this.factory.open('vulpes-datastores-v1', 1);
|
||||
request.onupgradeneeded = () => {
|
||||
request.result.createObjectStore('values');
|
||||
request.result.createObjectStore('revisions');
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
request.result.onversionchange = () => request.result.close();
|
||||
resolve(request.result);
|
||||
};
|
||||
}));
|
||||
}
|
||||
async run(key, operation, args) {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve, reject) => {
|
||||
const write = ['add', 'put', 'remove', 'clear'].includes(operation);
|
||||
const tx = db.transaction(['values', 'revisions'], write ? 'readwrite' : 'readonly');
|
||||
const store = tx.objectStore('values'),
|
||||
revisions = tx.objectStore('revisions');
|
||||
const range = this.keyRange.bound([key], [key, []]);
|
||||
let value,
|
||||
revisionId = '',
|
||||
entries = [];
|
||||
tx.onabort = () => reject(tx.error || new Error('TRANSACTION_ABORTED'));
|
||||
tx.onerror = () => {};
|
||||
tx.oncomplete = () => resolve({ value, revisionId });
|
||||
if (operation === 'get') {
|
||||
value = [];
|
||||
args.forEach((id, index) => {
|
||||
store.get([key, id]).onsuccess = (event) => {
|
||||
if (args.length === 1) value = event.target.result;
|
||||
else value[index] = event.target.result;
|
||||
};
|
||||
});
|
||||
} else if (operation === 'snapshot') {
|
||||
revisions.get(key).onsuccess = (event) => {
|
||||
revisionId = event.target.result || '';
|
||||
};
|
||||
store.openCursor(range).onsuccess = (event) => {
|
||||
const cursor = event.target.result;
|
||||
if (cursor) {
|
||||
entries.push({ id: cursor.key[1], data: cursor.value });
|
||||
cursor.continue();
|
||||
} else value = { revisionId, entries };
|
||||
};
|
||||
} else if (operation === 'length') {
|
||||
store.count(range).onsuccess = (event) => {
|
||||
value = event.target.result;
|
||||
};
|
||||
} else if (write) {
|
||||
revisionId = this.uuid();
|
||||
revisions.put(revisionId, key);
|
||||
if (operation === 'clear') store.delete(range);
|
||||
else if (operation === 'remove') {
|
||||
store.count([key, args[0]]).onsuccess = (event) => {
|
||||
value = event.target.result > 0;
|
||||
};
|
||||
store.delete([key, args[0]]);
|
||||
} else {
|
||||
value = args[1] === undefined ? this.uuid() : args[1];
|
||||
store[operation === 'add' ? 'add' : 'put'](args[0], [key, value]);
|
||||
}
|
||||
} else tx.abort();
|
||||
});
|
||||
}
|
||||
}
|
||||
39
adapters/indexeddb.mjs
Normal file
39
adapters/indexeddb.mjs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// Web-standard storage adapter. Schema version is independent of Gecko version.
|
||||
export class IndexedDBStorage {
|
||||
constructor(database) {
|
||||
this.database = database;
|
||||
}
|
||||
static open(name = 'vulpes-services-v1') {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(name, 1);
|
||||
req.onupgradeneeded = () => req.result.createObjectStore('settings');
|
||||
req.onsuccess = () => {
|
||||
const db = req.result;
|
||||
db.onversionchange = () => db.close();
|
||||
resolve(new IndexedDBStorage(db));
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
req.onblocked = () => reject(new Error('DATABASE_BLOCKED'));
|
||||
});
|
||||
}
|
||||
get(key) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = this.database.transaction('settings', 'readonly');
|
||||
const req = tx.objectStore('settings').get(key);
|
||||
tx.oncomplete = () => resolve(req.result);
|
||||
tx.onabort = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
setMany(entries) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = this.database.transaction('settings', 'readwrite');
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onabort = () => reject(tx.error);
|
||||
const store = tx.objectStore('settings');
|
||||
for (const [key, value] of entries) store.put(value, key);
|
||||
});
|
||||
}
|
||||
close() {
|
||||
this.database.close();
|
||||
}
|
||||
}
|
||||
9
android/.gitignore
vendored
Normal file
9
android/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/toolchain/
|
||||
/.gradle/
|
||||
/build/
|
||||
/app/build/
|
||||
/app/src/main/assets/
|
||||
/local.properties
|
||||
/dist/
|
||||
/keys/
|
||||
/logs/
|
||||
20
android/app/build.gradle
Normal file
20
android/app/build.gradle
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
plugins { id 'com.android.application' }
|
||||
def vulpesRelease = new groovy.json.JsonSlurper().parse(rootProject.file('../release.json'))
|
||||
def productVersion = vulpesRelease.version ?: "${vulpesRelease.gaia}-${vulpesRelease.channel}.${vulpesRelease.revision}"
|
||||
android {
|
||||
namespace 'org.vulpes_os.preview'
|
||||
compileSdk { version = release(37) { minorApiLevel = 1 } }
|
||||
buildToolsVersion '37.0.0'
|
||||
defaultConfig {
|
||||
applicationId 'org.vulpes_os.preview'
|
||||
minSdk 26
|
||||
targetSdk 37
|
||||
versionCode vulpesRelease.androidVersionCode as int
|
||||
versionName productVersion
|
||||
ndk { abiFilters 'arm64-v8a', 'armeabi-v7a', 'x86_64' }
|
||||
}
|
||||
signingConfigs { debug { storeFile rootProject.file('keys/preview.keystore'); storePassword 'android'; keyAlias 'androiddebugkey'; keyPassword 'android' } }
|
||||
compileOptions { sourceCompatibility JavaVersion.VERSION_17; targetCompatibility JavaVersion.VERSION_17 }
|
||||
packaging { jniLibs { useLegacyPackaging = false } }
|
||||
}
|
||||
dependencies { implementation "org.mozilla.geckoview:geckoview:${vulpesRelease.geckoview}" }
|
||||
35
android/app/src/main/AndroidManifest.xml
Normal file
35
android/app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||
<uses-permission android:name="android.permission.READ_SMS" />
|
||||
<uses-permission android:name="android.permission.READ_CALL_LOG" />
|
||||
<uses-feature android:name="android.hardware.telephony" android:required="false" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
|
||||
<uses-permission android:name="com.android.alarm.permission.SET_ALARM" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<application android:name=".VulpesApplication" android:label="@string/app_name"
|
||||
android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher" android:theme="@android:style/Theme.Material.Light.NoActionBar"
|
||||
android:allowBackup="false" android:supportsRtl="true" android:usesCleartextTraffic="true">
|
||||
<activity android:name=".MainActivity" android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden|smallestScreenSize|uiMode"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.HOME" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<provider android:name=".CaptureProvider" android:authorities="${applicationId}.capture"
|
||||
android:exported="false" android:grantUriPermissions="true" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
package org.vulpes_os.preview;
|
||||
|
||||
import android.app.*;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import java.util.HashMap;
|
||||
import org.json.*;
|
||||
import org.mozilla.geckoview.*;
|
||||
|
||||
final class AndroidNotifications implements WebNotificationDelegate {
|
||||
|
||||
private final VulpesApplication app;
|
||||
private final NotificationManager manager;
|
||||
private final HashMap<String, WebNotification> live = new HashMap<>();
|
||||
private final HashMap<String, Integer> ids = new HashMap<>();
|
||||
private int nextId = 1;
|
||||
|
||||
AndroidNotifications(VulpesApplication app) {
|
||||
this.app = app;
|
||||
manager = app.getSystemService(NotificationManager.class);
|
||||
manager.createNotificationChannel(
|
||||
new NotificationChannel("vulpes", "Vulpes OS", NotificationManager.IMPORTANCE_DEFAULT)
|
||||
);
|
||||
}
|
||||
|
||||
private String key(WebNotification n) {
|
||||
return n.origin + "|" + (n.tag == null || n.tag.isEmpty() ? System.identityHashCode(n) : n.tag);
|
||||
}
|
||||
|
||||
public void onShowNotification(WebNotification n) {
|
||||
if (!manager.areNotificationsEnabled()) {
|
||||
n.dismiss();
|
||||
return;
|
||||
}
|
||||
String key = key(n);
|
||||
int id = ids.computeIfAbsent(key, k -> nextId++);
|
||||
live.put(key, n);
|
||||
Intent open = new Intent(app, MainActivity.class)
|
||||
.setAction("org.vulpes_os.preview.NOTIFICATION")
|
||||
.putExtra("notificationKey", key)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
||||
PendingIntent action = PendingIntent.getActivity(
|
||||
app,
|
||||
id,
|
||||
open,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
|
||||
);
|
||||
Notification notification = new Notification.Builder(app, "vulpes")
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setContentTitle(n.title)
|
||||
.setContentText(n.text)
|
||||
.setStyle(new Notification.BigTextStyle().bigText(n.text))
|
||||
.setContentIntent(action)
|
||||
.setAutoCancel(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.build();
|
||||
try {
|
||||
manager.notify(id, notification);
|
||||
n.show();
|
||||
} catch (SecurityException e) {
|
||||
live.remove(key);
|
||||
n.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
public void onCloseNotification(WebNotification n) {
|
||||
String key = key(n);
|
||||
Integer id = ids.remove(key);
|
||||
live.remove(key);
|
||||
if (id != null) manager.cancel(id);
|
||||
}
|
||||
|
||||
Object local(JSONObject p) throws JSONException {
|
||||
String owner = p.getString("owner"),
|
||||
operation = p.getString("operation");
|
||||
SharedPreferences store = app.getSharedPreferences("notifications", 0);
|
||||
if (operation.equals("query")) return manager.areNotificationsEnabled() ? "granted" : "denied";
|
||||
if (operation.equals("list")) {
|
||||
JSONArray rows = new JSONArray();
|
||||
for (android.service.notification.StatusBarNotification item : manager.getActiveNotifications()) {
|
||||
String raw = store.getString("local." + item.getId(), null);
|
||||
if (raw == null) continue;
|
||||
JSONObject data = new JSONObject(raw);
|
||||
if (data.optString("owner").equals(owner)) rows.put(data);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
if (operation.equals("close")) {
|
||||
int id = p.getInt("id");
|
||||
JSONObject data = new JSONObject(store.getString("local." + id, "{}"));
|
||||
if (!data.optString("owner").equals(owner)) throw new SecurityException("PERMISSION_DENIED");
|
||||
manager.cancel(id);
|
||||
store.edit().remove("local." + id).apply();
|
||||
return null;
|
||||
}
|
||||
if (!operation.equals("show")) throw new IllegalArgumentException(
|
||||
"UNKNOWN_NOTIFICATION_OPERATION"
|
||||
);
|
||||
if (!manager.areNotificationsEnabled()) throw new SecurityException("NOTIFICATIONS_DENIED");
|
||||
String tag = p.optString("tag");
|
||||
int id = 0;
|
||||
if (
|
||||
!tag.isEmpty()
|
||||
) for (android.service.notification.StatusBarNotification item : manager.getActiveNotifications()) {
|
||||
JSONObject saved = new JSONObject(store.getString("local." + item.getId(), "{}"));
|
||||
if (owner.equals(saved.optString("owner")) && tag.equals(saved.optString("tag"))) id =
|
||||
item.getId();
|
||||
}
|
||||
if (id == 0) {
|
||||
id = store.getInt("next", 10000);
|
||||
store.edit().putInt("next", id + 1).apply();
|
||||
}
|
||||
JSONObject data = new JSONObject(p.toString()).put("id", id);
|
||||
store.edit().putString("local." + id, data.toString()).apply();
|
||||
Intent open = new Intent(app, MainActivity.class)
|
||||
.setAction("org.vulpes_os.preview.LOCAL_NOTIFICATION")
|
||||
.putExtra("localNotification", id)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
||||
PendingIntent action = PendingIntent.getActivity(
|
||||
app,
|
||||
id,
|
||||
open,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
|
||||
);
|
||||
Notification n = new Notification.Builder(app, "vulpes")
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setContentTitle(p.optString("title", "Vulpes OS"))
|
||||
.setContentText(p.optString("body"))
|
||||
.setStyle(new Notification.BigTextStyle().bigText(p.optString("body")))
|
||||
.setContentIntent(action)
|
||||
.setAutoCancel(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.build();
|
||||
manager.notify(id, n);
|
||||
return id;
|
||||
}
|
||||
|
||||
void click(Intent intent) {
|
||||
if (intent != null && "org.vulpes_os.preview.LOCAL_NOTIFICATION".equals(intent.getAction())) {
|
||||
int id = intent.getIntExtra("localNotification", -1);
|
||||
SharedPreferences store = app.getSharedPreferences("notifications", 0);
|
||||
String raw = store.getString("local." + id, null);
|
||||
if (raw != null && app.port != null) try {
|
||||
JSONObject data = new JSONObject(raw);
|
||||
app.port.postMessage(new JSONObject().put("type", "notification-click").put("data", data));
|
||||
store.edit().remove("local." + id).apply();
|
||||
intent.removeExtra("localNotification");
|
||||
} catch (Exception e) {
|
||||
android.util.Log.w("Vulpes", "Notification click", e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (intent == null || !"org.vulpes_os.preview.NOTIFICATION".equals(intent.getAction())) return;
|
||||
String key = intent.getStringExtra("notificationKey");
|
||||
WebNotification n = live.remove(key);
|
||||
Integer id = ids.remove(key);
|
||||
if (id != null) manager.cancel(id);
|
||||
if (n != null) n.click();
|
||||
intent.removeExtra("notificationKey");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
package org.vulpes_os.preview;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.ContentUris;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.database.Cursor;
|
||||
import android.database.ContentObserver;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import android.net.Uri;
|
||||
import android.provider.CallLog;
|
||||
import android.provider.ContactsContract;
|
||||
import android.provider.Telephony;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
/** Read-through access. Android remains the owner of contacts and telephony records. */
|
||||
final class AndroidPersonalData {
|
||||
private final Context context;
|
||||
private final Handler main = new Handler(Looper.getMainLooper());
|
||||
private final Map<String, ContentObserver> observers = new HashMap<>();
|
||||
private final Map<String, Runnable> pending = new HashMap<>();
|
||||
void changed(String kind) {
|
||||
Runnable previous = pending.remove(kind);
|
||||
if (previous != null) main.removeCallbacks(previous);
|
||||
Runnable next = () -> {
|
||||
pending.remove(kind);
|
||||
VulpesApplication app = (VulpesApplication) context;
|
||||
if (app.port == null) return;
|
||||
try { app.port.postMessage(new JSONObject().put("type", "android-data-changed").put("kind", kind)); }
|
||||
catch (Exception ignored) { /* The resume event refreshes detached sessions. */ }
|
||||
};
|
||||
pending.put(kind, next);
|
||||
main.postDelayed(next, 250);
|
||||
}
|
||||
void observe() {
|
||||
for (String kind : new String[]{"contacts", "sms", "calls"}) {
|
||||
if (granted(kind) && !observers.containsKey(kind)) {
|
||||
ContentObserver observer = new ContentObserver(main) {
|
||||
@Override public void onChange(boolean selfChange) { changed(kind); }
|
||||
};
|
||||
Uri uri = kind.equals("contacts") ? ContactsContract.Contacts.CONTENT_URI :
|
||||
kind.equals("sms") ? Telephony.Sms.CONTENT_URI : CallLog.Calls.CONTENT_URI;
|
||||
try {
|
||||
context.getContentResolver().registerContentObserver(uri, true, observer);
|
||||
observers.put(kind, observer);
|
||||
} catch (SecurityException ignored) { /* Permission can be revoked during registration. */ }
|
||||
} else if (!granted(kind) && observers.containsKey(kind)) {
|
||||
context.getContentResolver().unregisterContentObserver(observers.remove(kind));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AndroidPersonalData(Context context) { this.context = context; }
|
||||
|
||||
static String permission(String kind) {
|
||||
switch (kind) {
|
||||
case "contacts": return Manifest.permission.READ_CONTACTS;
|
||||
case "sms": return Manifest.permission.READ_SMS;
|
||||
case "calls": return Manifest.permission.READ_CALL_LOG;
|
||||
default: throw new IllegalArgumentException("INVALID_DATA_KIND");
|
||||
}
|
||||
}
|
||||
|
||||
boolean granted(String kind) {
|
||||
return context.getSharedPreferences("personal-data", 0).getBoolean(kind, false) &&
|
||||
context.checkSelfPermission(permission(kind)) == PackageManager.PERMISSION_GRANTED;
|
||||
}
|
||||
|
||||
void enable(String kind, boolean value) {
|
||||
permission(kind);
|
||||
context.getSharedPreferences("personal-data", 0).edit().putBoolean(kind, value).apply();
|
||||
observe();
|
||||
changed(kind);
|
||||
}
|
||||
|
||||
JSONObject status() throws Exception {
|
||||
JSONObject result = new JSONObject();
|
||||
for (String kind : new String[]{"contacts", "sms", "calls"}) result.put(kind, granted(kind));
|
||||
return result;
|
||||
}
|
||||
|
||||
JSONObject page(JSONObject params) throws Exception {
|
||||
String kind = params.getString("kind");
|
||||
if (!granted(kind)) throw new SecurityException("ANDROID_DATA_PERMISSION_DENIED");
|
||||
long after = params.optLong("after", 0);
|
||||
if (after < 0) throw new IllegalArgumentException("INVALID_CURSOR");
|
||||
Uri uri;
|
||||
String[] columns;
|
||||
switch (kind) {
|
||||
case "contacts":
|
||||
uri = ContactsContract.Contacts.CONTENT_URI;
|
||||
columns = new String[]{"_id", "display_name", "starred", "contact_last_updated_timestamp"};
|
||||
break;
|
||||
case "sms":
|
||||
uri = Telephony.Sms.CONTENT_URI;
|
||||
columns = new String[]{"_id", "thread_id", "address", "body", "date", "type", "read", "status"};
|
||||
break;
|
||||
default:
|
||||
uri = CallLog.Calls.CONTENT_URI;
|
||||
columns = new String[]{"_id", "number", "date", "duration", "type"};
|
||||
}
|
||||
JSONArray rows = new JSONArray();
|
||||
long last = after;
|
||||
boolean more = false;
|
||||
ContentResolver resolver = context.getContentResolver();
|
||||
// A key cursor avoids SQL LIMIT syntax differences between Android providers.
|
||||
try (Cursor cursor = resolver.query(uri, columns, "_id > ?", new String[]{Long.toString(after)}, "_id ASC")) {
|
||||
if (cursor == null) throw new IllegalStateException("ANDROID_PROVIDER_UNAVAILABLE");
|
||||
int bytes = 0;
|
||||
while (cursor.moveToNext()) {
|
||||
if (rows.length() >= 100 || bytes > 512 * 1024) { more = true; break; }
|
||||
JSONObject row = new JSONObject();
|
||||
for (int i = 0; i < columns.length; i++) {
|
||||
row.put(columns[i], cursor.isNull(i) ? JSONObject.NULL :
|
||||
cursor.getType(i) == Cursor.FIELD_TYPE_INTEGER ? cursor.getLong(i) : cursor.getString(i));
|
||||
}
|
||||
last = cursor.getLong(0);
|
||||
if (kind.equals("contacts")) contactDetails(last, row);
|
||||
rows.put(row);
|
||||
bytes += row.toString().length() * 2;
|
||||
}
|
||||
}
|
||||
return new JSONObject().put("rows", rows).put("next", more ? last : JSONObject.NULL);
|
||||
}
|
||||
|
||||
private void contactDetails(long id, JSONObject contact) throws Exception {
|
||||
JSONArray phones = new JSONArray(), emails = new JSONArray();
|
||||
contact.put("tel", phones).put("email", emails);
|
||||
try (Cursor c = context.getContentResolver().query(ContactsContract.Data.CONTENT_URI,
|
||||
new String[]{"mimetype", "data1", "data2", "data3", "is_primary"},
|
||||
"contact_id = ? AND mimetype IN (?, ?, ?)", new String[]{Long.toString(id),
|
||||
ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE,
|
||||
ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE,
|
||||
ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE}, "_id ASC")) {
|
||||
if (c == null) throw new IllegalStateException("ANDROID_PROVIDER_UNAVAILABLE");
|
||||
while (c.moveToNext()) {
|
||||
String mime = c.getString(0);
|
||||
if (mime.equals(ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)) {
|
||||
if (!contact.has("givenName")) contact.put("givenName", new JSONArray().put(c.getString(2)))
|
||||
.put("familyName", new JSONArray().put(c.getString(3)));
|
||||
} else {
|
||||
boolean phone = mime.equals(ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE);
|
||||
int type = c.getInt(2);
|
||||
String label = type == 0 ? c.getString(3) : phone ?
|
||||
(type == 2 ? "mobile" : type == 1 ? "home" : type == 3 ? "work" : "other") :
|
||||
(type == 1 ? "home" : type == 2 ? "work" : "other");
|
||||
(phone ? phones : emails).put(new JSONObject().put("value", c.getString(1))
|
||||
.put("type", new JSONArray().put(label == null ? "other" : label)).put("pref", c.getInt(4) != 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Intent open(JSONObject params) {
|
||||
String kind = params.optString("kind");
|
||||
if (kind.equals("contacts")) {
|
||||
String id = params.optString("id");
|
||||
if (!id.matches("[1-9][0-9]{0,15}")) throw new IllegalArgumentException("INVALID_CONTACT");
|
||||
if (!granted(kind)) throw new SecurityException("ANDROID_DATA_PERMISSION_DENIED");
|
||||
return new Intent(Intent.ACTION_VIEW, ContentUris.withAppendedId(ContactsContract.Contacts.CONTENT_URI, Long.parseLong(id)));
|
||||
}
|
||||
if (kind.equals("sms")) return new Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_APP_MESSAGING);
|
||||
if (kind.equals("calls")) return new Intent(Intent.ACTION_VIEW, CallLog.Calls.CONTENT_URI);
|
||||
throw new IllegalArgumentException("INVALID_DATA_KIND");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,555 @@
|
|||
package org.vulpes_os.preview;
|
||||
|
||||
import android.app.*;
|
||||
import android.content.*;
|
||||
import android.database.Cursor;
|
||||
import android.media.AudioManager;
|
||||
import android.net.Uri;
|
||||
import android.os.*;
|
||||
import android.provider.*;
|
||||
import android.util.Base64;
|
||||
import android.webkit.MimeTypeMap;
|
||||
import java.io.*;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import org.json.*;
|
||||
import org.mozilla.geckoview.GeckoResult;
|
||||
|
||||
/** Android operations reachable only from the bundled extension's authorized RPC. */
|
||||
final class AndroidServices {
|
||||
|
||||
static final int PICK = 410,
|
||||
CAPTURE = 411,
|
||||
CONTACT = 412;
|
||||
static final long MAX_MEDIA = 32L * 1024 * 1024;
|
||||
final VulpesApplication app;
|
||||
final AndroidPersonalData personal;
|
||||
final ExecutorService io = Executors.newSingleThreadExecutor();
|
||||
final Handler main = new Handler(Looper.getMainLooper());
|
||||
GeckoResult<Object> pending;
|
||||
String pendingType;
|
||||
|
||||
AndroidServices(VulpesApplication app) {
|
||||
this.app = app;
|
||||
personal = new AndroidPersonalData(app);
|
||||
}
|
||||
|
||||
GeckoResult<Object> request(JSONObject message) {
|
||||
String op = message.optString("type");
|
||||
GeckoResult<Object> result = new GeckoResult<>();
|
||||
try {
|
||||
JSONObject p = message.optJSONObject("params");
|
||||
if (p == null) p = new JSONObject();
|
||||
switch (op) {
|
||||
case "android.personal":
|
||||
JSONObject personalParams = p;
|
||||
String operation = p.optString("operation");
|
||||
if (operation.equals("permission")) {
|
||||
String kind = p.getString("kind");
|
||||
activity().permission(new String[]{AndroidPersonalData.permission(kind)}, granted -> {
|
||||
try { personal.enable(kind, granted); result.complete(personal.status()); } catch (Exception e) { fail(result, e); }
|
||||
});
|
||||
} else if (operation.equals("status")) result.complete(personal.status());
|
||||
else if (operation.equals("disconnect")) {
|
||||
personal.enable(p.getString("kind"), false);
|
||||
result.complete(personal.status());
|
||||
}
|
||||
else if (operation.equals("open")) {
|
||||
activity().startActivity(personal.open(p));
|
||||
result.complete(null);
|
||||
} else if (operation.equals("page")) io.execute(() -> {
|
||||
try {
|
||||
JSONObject data = personal.page(personalParams);
|
||||
main.post(() -> result.complete(data));
|
||||
} catch (Exception e) { fail(result, e); }
|
||||
});
|
||||
else throw new IllegalArgumentException("METHOD_NOT_SUPPORTED");
|
||||
break;
|
||||
case "android.notification":
|
||||
JSONObject request = p;
|
||||
if (p.optString("operation").equals("permission")) {
|
||||
activity().notificationPermission(granted ->
|
||||
result.complete(granted ? "granted" : "denied")
|
||||
);
|
||||
} else result.complete(app.notifications.local(request));
|
||||
break;
|
||||
case "android.settings":
|
||||
openSettings(p.getString("panel"));
|
||||
result.complete(null);
|
||||
break;
|
||||
case "android.snapshot":
|
||||
result.complete(snapshot());
|
||||
break;
|
||||
case "android.set":
|
||||
set(p);
|
||||
result.complete(snapshot());
|
||||
break;
|
||||
case "android.pick":
|
||||
pick(p.getString("type"), result);
|
||||
break;
|
||||
case "android.capture":
|
||||
capture(result);
|
||||
break;
|
||||
case "android.alarms":
|
||||
activity().startActivity(new Intent(AlarmClock.ACTION_SHOW_ALARMS));
|
||||
result.complete(null);
|
||||
break;
|
||||
case "android.contact":
|
||||
if (pending != null) throw new IllegalStateException("PICKER_BUSY");
|
||||
activity().startActivityForResult(
|
||||
new Intent(Intent.ACTION_PICK, ContactsContract.CommonDataKinds.Phone.CONTENT_URI),
|
||||
CONTACT
|
||||
);
|
||||
pending = result;
|
||||
pendingType = "contact";
|
||||
break;
|
||||
case "android.sms":
|
||||
String numbers = p.optString("number"),
|
||||
text = p.optString("body");
|
||||
if (
|
||||
!numbers.matches("[+0-9*# ,;()-]{0,200}") || text.length() > 10000
|
||||
) throw new IllegalArgumentException("INVALID_MESSAGE");
|
||||
activity().startActivity(
|
||||
new Intent(Intent.ACTION_SENDTO, Uri.fromParts("smsto", numbers, null)).putExtra(
|
||||
"sms_body",
|
||||
text
|
||||
)
|
||||
);
|
||||
result.complete(null);
|
||||
break;
|
||||
case "android.dial":
|
||||
String number = p.getString("number");
|
||||
if (!number.matches("[+0-9*# ,;()-]{1,80}")) throw new IllegalArgumentException(
|
||||
"INVALID_NUMBER"
|
||||
);
|
||||
activity().startActivity(
|
||||
new Intent(Intent.ACTION_DIAL, Uri.fromParts("tel", number, null))
|
||||
);
|
||||
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(() -> {
|
||||
try {
|
||||
Object value = media(params);
|
||||
main.post(() -> result.complete(value));
|
||||
} catch (Exception e) {
|
||||
fail(result, e);
|
||||
}
|
||||
});
|
||||
break;
|
||||
default:
|
||||
throw new IllegalArgumentException("METHOD_NOT_SUPPORTED");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
fail(result, e);
|
||||
}
|
||||
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<Object> result, Exception e) {
|
||||
main.post(() -> {
|
||||
try {
|
||||
result.complete(
|
||||
new JSONObject().put(
|
||||
"error",
|
||||
e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()
|
||||
)
|
||||
);
|
||||
} catch (JSONException ignored) {}
|
||||
});
|
||||
}
|
||||
|
||||
private MainActivity activity() {
|
||||
if (app.activity == null || app.activity.isFinishing()) throw new IllegalStateException(
|
||||
"ACTIVITY_UNAVAILABLE"
|
||||
);
|
||||
return app.activity;
|
||||
}
|
||||
|
||||
void openSettings(String panel) {
|
||||
Map<String, String> actions = new HashMap<>();
|
||||
actions.put("wifi", Settings.ACTION_WIFI_SETTINGS);
|
||||
actions.put("bluetooth", Settings.ACTION_BLUETOOTH_SETTINGS);
|
||||
actions.put("data", Settings.ACTION_WIRELESS_SETTINGS);
|
||||
actions.put("airplane-mode", Settings.ACTION_AIRPLANE_MODE_SETTINGS);
|
||||
actions.put("geolocation", Settings.ACTION_LOCATION_SOURCE_SETTINGS);
|
||||
actions.put("display", Settings.ACTION_DISPLAY_SETTINGS);
|
||||
actions.put("sound", Settings.ACTION_SOUND_SETTINGS);
|
||||
actions.put("dateTime", Settings.ACTION_DATE_SETTINGS);
|
||||
actions.put("screenLock", Settings.ACTION_SECURITY_SETTINGS);
|
||||
actions.put("battery", Settings.ACTION_BATTERY_SAVER_SETTINGS);
|
||||
actions.put("home", Settings.ACTION_HOME_SETTINGS);
|
||||
actions.put("storage", Settings.ACTION_INTERNAL_STORAGE_SETTINGS);
|
||||
actions.put("notifications", Settings.ACTION_APP_NOTIFICATION_SETTINGS);
|
||||
actions.put("apps", Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
|
||||
actions.put("root", Settings.ACTION_SETTINGS);
|
||||
if (!actions.containsKey(panel)) throw new IllegalArgumentException("UNKNOWN_PANEL");
|
||||
Intent intent = new Intent(actions.get(panel));
|
||||
if (panel.equals("notifications")) intent.putExtra(
|
||||
Settings.EXTRA_APP_PACKAGE,
|
||||
app.getPackageName()
|
||||
);
|
||||
if (panel.equals("apps")) intent.setData(Uri.parse("package:" + app.getPackageName()));
|
||||
activity().startActivity(intent);
|
||||
}
|
||||
|
||||
JSONObject snapshot() throws Exception {
|
||||
AudioManager audio = app.getSystemService(AudioManager.class);
|
||||
JSONObject data = new JSONObject();
|
||||
Intent battery = app.registerReceiver(null, new android.content.IntentFilter(Intent.ACTION_BATTERY_CHANGED));
|
||||
if (battery != null) {
|
||||
int level = battery.getIntExtra(android.os.BatteryManager.EXTRA_LEVEL, -1);
|
||||
int scale = battery.getIntExtra(android.os.BatteryManager.EXTRA_SCALE, -1);
|
||||
int status = battery.getIntExtra(android.os.BatteryManager.EXTRA_STATUS, -1);
|
||||
if (level >= 0 && scale > 0) data.put("battery", new JSONObject()
|
||||
.put("level", (double) level / scale)
|
||||
.put("charging", status == android.os.BatteryManager.BATTERY_STATUS_CHARGING ||
|
||||
status == android.os.BatteryManager.BATTERY_STATUS_FULL));
|
||||
}
|
||||
|
||||
data.put(
|
||||
"screen.brightness",
|
||||
app
|
||||
.getSharedPreferences("android", 0)
|
||||
.getFloat(
|
||||
"brightness",
|
||||
Settings.System.getInt(app.getContentResolver(), Settings.System.SCREEN_BRIGHTNESS, 128) /
|
||||
255f
|
||||
)
|
||||
);
|
||||
data.put(
|
||||
"audio.volume.content",
|
||||
Math.round(
|
||||
(15f * audio.getStreamVolume(AudioManager.STREAM_MUSIC)) /
|
||||
audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
|
||||
)
|
||||
);
|
||||
data.put("audio.volume.content.max", audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC));
|
||||
data.put(
|
||||
"airplaneMode.enabled",
|
||||
Settings.Global.getInt(app.getContentResolver(), Settings.Global.AIRPLANE_MODE_ON, 0) != 0
|
||||
);
|
||||
data.put(
|
||||
"geolocation.enabled",
|
||||
app
|
||||
.getSystemService(android.location.LocationManager.class)
|
||||
.isProviderEnabled(android.location.LocationManager.GPS_PROVIDER)
|
||||
);
|
||||
data.put("wifi.enabled", Settings.Global.getInt(app.getContentResolver(), "wifi_on", 0) != 0);
|
||||
data.put(
|
||||
"bluetooth.enabled",
|
||||
Settings.Global.getInt(app.getContentResolver(), "bluetooth_on", 0) != 0
|
||||
);
|
||||
data.put("deviceinfo.product_model", Build.MANUFACTURER + " " + Build.MODEL);
|
||||
data.put("deviceinfo.hardware", Build.HARDWARE);
|
||||
data.put("deviceinfo.firmware_revision", "Android " + Build.VERSION.RELEASE);
|
||||
data.put("deviceinfo.software", "Vulpes OS " + app.getPackageManager().getPackageInfo(app.getPackageName(), 0).versionName);
|
||||
data.put(
|
||||
"android.notifications.enabled",
|
||||
app.getSystemService(NotificationManager.class).areNotificationsEnabled()
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
void set(JSONObject p) throws JSONException {
|
||||
String key = p.getString("key");
|
||||
if (key.equals("screen.brightness")) {
|
||||
float value = (float) p.getDouble("value");
|
||||
if (!Float.isFinite(value) || value < 0 || value > 1) throw new IllegalArgumentException(
|
||||
"INVALID_VALUE"
|
||||
);
|
||||
android.view.WindowManager.LayoutParams attrs = activity().getWindow().getAttributes();
|
||||
attrs.screenBrightness = Math.max(.02f, value);
|
||||
activity().getWindow().setAttributes(attrs);
|
||||
app.getSharedPreferences("android", 0).edit().putFloat("brightness", value).apply();
|
||||
} else if (key.equals("audio.volume.content")) {
|
||||
AudioManager audio = app.getSystemService(AudioManager.class);
|
||||
int value = p.getInt("value");
|
||||
if (value < 0 || value > 15) throw new IllegalArgumentException("INVALID_VALUE");
|
||||
audio.setStreamVolume(
|
||||
AudioManager.STREAM_MUSIC,
|
||||
Math.round((value * audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC)) / 15f),
|
||||
0
|
||||
);
|
||||
} else throw new IllegalArgumentException("SETTING_REQUIRES_ANDROID_PANEL");
|
||||
}
|
||||
|
||||
void pick(String type, GeckoResult<Object> result) {
|
||||
checkType(type);
|
||||
if (pending != null) throw new IllegalStateException("PICKER_BUSY");
|
||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT)
|
||||
.addCategory(Intent.CATEGORY_OPENABLE)
|
||||
.setType(prefix(type) + "*")
|
||||
.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
|
||||
activity().startActivityForResult(intent, PICK);
|
||||
pending = result;
|
||||
pendingType = type;
|
||||
app
|
||||
.getSharedPreferences("pending-media", 0)
|
||||
.edit()
|
||||
.putString("type", type)
|
||||
.putInt("code", PICK)
|
||||
.commit();
|
||||
}
|
||||
|
||||
void capture(GeckoResult<Object> result) throws IOException {
|
||||
if (pending != null) throw new IllegalStateException("PICKER_BUSY");
|
||||
Files.deleteIfExists(new File(app.getCacheDir(), "capture.jpg").toPath());
|
||||
Uri uri = Uri.parse("content://" + app.getPackageName() + ".capture/capture.jpg");
|
||||
Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE)
|
||||
.putExtra(MediaStore.EXTRA_OUTPUT, uri)
|
||||
.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
intent.setClipData(ClipData.newRawUri("Vulpes photo", uri));
|
||||
activity().startActivityForResult(intent, CAPTURE);
|
||||
pending = result;
|
||||
pendingType = "pictures";
|
||||
app
|
||||
.getSharedPreferences("pending-media", 0)
|
||||
.edit()
|
||||
.putString("type", "pictures")
|
||||
.putInt("code", CAPTURE)
|
||||
.commit();
|
||||
}
|
||||
|
||||
void onResult(int code, int status, Intent intent) {
|
||||
if (code == CONTACT) {
|
||||
GeckoResult<Object> result = pending;
|
||||
pending = null;
|
||||
pendingType = null;
|
||||
if (result == null) return;
|
||||
if (status != Activity.RESULT_OK || intent == null || intent.getData() == null) {
|
||||
fail(result, new IOException("AbortError"));
|
||||
return;
|
||||
}
|
||||
Uri uri = intent.getData();
|
||||
io.execute(() -> {
|
||||
try (
|
||||
Cursor c = app
|
||||
.getContentResolver()
|
||||
.query(
|
||||
uri,
|
||||
new String[] {
|
||||
ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME,
|
||||
ContactsContract.CommonDataKinds.Phone.NUMBER,
|
||||
},
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)
|
||||
) {
|
||||
if (c == null || !c.moveToFirst()) throw new IOException("NotFoundError");
|
||||
JSONObject contact = new JSONObject()
|
||||
.put("name", new JSONArray().put(c.getString(0)))
|
||||
.put("givenName", new JSONArray().put(c.getString(0)))
|
||||
.put(
|
||||
"tel",
|
||||
new JSONArray().put(
|
||||
new JSONObject()
|
||||
.put("value", c.getString(1))
|
||||
.put("type", new JSONArray().put("mobile"))
|
||||
)
|
||||
);
|
||||
main.post(() -> result.complete(contact));
|
||||
} catch (Exception e) {
|
||||
fail(result, e);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (code != PICK && code != CAPTURE) return;
|
||||
SharedPreferences saved = app.getSharedPreferences("pending-media", 0);
|
||||
if (pending == null && saved.getInt("code", 0) != code) return;
|
||||
GeckoResult<Object> result = pending == null ? new GeckoResult<>() : pending;
|
||||
String type = pendingType == null ? saved.getString("type", "pictures") : pendingType;
|
||||
pending = null;
|
||||
pendingType = null;
|
||||
saved.edit().clear().commit();
|
||||
if (status != Activity.RESULT_OK) {
|
||||
fail(result, new IOException("AbortError"));
|
||||
return;
|
||||
}
|
||||
ArrayList<Uri> uris = new ArrayList<>();
|
||||
if (code == CAPTURE) uris.add(Uri.fromFile(new File(app.getCacheDir(), "capture.jpg")));
|
||||
else if (intent != null && intent.getClipData() != null) {
|
||||
for (int i = 0; i < intent.getClipData().getItemCount(); i++) uris.add(
|
||||
intent.getClipData().getItemAt(i).getUri()
|
||||
);
|
||||
} else if (intent != null && intent.getData() != null) uris.add(intent.getData());
|
||||
io.execute(() -> {
|
||||
try {
|
||||
JSONArray names = new JSONArray();
|
||||
for (Uri uri : uris) {
|
||||
String mime = code == CAPTURE ? "image/jpeg" : app.getContentResolver().getType(uri);
|
||||
String name = code == CAPTURE
|
||||
? "Photo-" + System.currentTimeMillis() + ".jpg"
|
||||
: displayName(uri);
|
||||
if (mime == null) mime = mime(name);
|
||||
if (!mime.startsWith(prefix(type))) throw new IOException("TypeMismatchError");
|
||||
name = UUID.randomUUID() + "-" + name.replaceAll("[^a-zA-Z0-9._-]", "_");
|
||||
File target = path(type, name);
|
||||
try (
|
||||
InputStream in = code == CAPTURE
|
||||
? new FileInputStream(new File(uri.getPath()))
|
||||
: app.getContentResolver().openInputStream(uri)
|
||||
) {
|
||||
copy(in, target);
|
||||
}
|
||||
names.put("/sdcard/" + name);
|
||||
}
|
||||
main.post(() -> result.complete(names));
|
||||
} catch (Exception e) {
|
||||
fail(result, e);
|
||||
} finally {
|
||||
if (code == CAPTURE) new File(app.getCacheDir(), "capture.jpg").delete();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
String displayName(Uri uri) {
|
||||
try (
|
||||
Cursor c = app
|
||||
.getContentResolver()
|
||||
.query(uri, new String[] { OpenableColumns.DISPLAY_NAME }, null, null, null)
|
||||
) {
|
||||
if (c != null && c.moveToFirst()) return c.getString(0);
|
||||
} catch (Exception ignored) {}
|
||||
return "media";
|
||||
}
|
||||
|
||||
static void checkType(String type) {
|
||||
if (
|
||||
!Arrays.asList("pictures", "videos", "music").contains(type)
|
||||
) throw new IllegalArgumentException("INVALID_MEDIA_TYPE");
|
||||
}
|
||||
|
||||
static String prefix(String type) {
|
||||
checkType(type);
|
||||
return type.equals("pictures") ? "image/" : type.equals("videos") ? "video/" : "audio/";
|
||||
}
|
||||
|
||||
static String mime(String name) {
|
||||
String ext = name.substring(name.lastIndexOf('.') + 1).toLowerCase(Locale.ROOT);
|
||||
String type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext);
|
||||
return type == null ? "application/octet-stream" : type;
|
||||
}
|
||||
|
||||
File path(String type, String name) throws IOException {
|
||||
checkType(type);
|
||||
if (name.startsWith("/sdcard/")) name = name.substring(8);
|
||||
File root = new File(app.getFilesDir(), "media/" + type);
|
||||
root.mkdirs();
|
||||
File file = new File(root, name);
|
||||
if (
|
||||
!file.getCanonicalPath().startsWith(root.getCanonicalPath() + "/") ||
|
||||
name.contains("..") ||
|
||||
name.startsWith("/")
|
||||
) throw new IOException("SecurityError");
|
||||
return file;
|
||||
}
|
||||
|
||||
void copy(InputStream in, File file) throws IOException {
|
||||
if (in == null) throw new IOException("NotFoundError");
|
||||
file.getParentFile().mkdirs();
|
||||
File temp = new File(file.getPath() + ".tmp");
|
||||
try (OutputStream out = new FileOutputStream(temp)) {
|
||||
byte[] buf = new byte[32768];
|
||||
int n;
|
||||
long total = 0;
|
||||
while ((n = in.read(buf)) != -1) {
|
||||
total += n;
|
||||
if (total > MAX_MEDIA) throw new IOException("MEDIA_TOO_LARGE_32_MIB");
|
||||
out.write(buf, 0, n);
|
||||
}
|
||||
Files.move(temp.toPath(), file.toPath());
|
||||
} finally {
|
||||
temp.delete();
|
||||
}
|
||||
}
|
||||
|
||||
JSONObject info(File root, File f) throws JSONException {
|
||||
return new JSONObject()
|
||||
.put("name", "/sdcard/" + root.toPath().relativize(f.toPath()).toString())
|
||||
.put("size", f.length())
|
||||
.put("lastModified", f.lastModified())
|
||||
.put("type", mime(f.getName()));
|
||||
}
|
||||
|
||||
Object media(JSONObject p) throws Exception {
|
||||
String type = p.getString("type"),
|
||||
op = p.getString("operation");
|
||||
checkType(type);
|
||||
File root = path(type, "dummy").getParentFile();
|
||||
if (op.equals("available")) return "available";
|
||||
if (op.equals("freeSpace")) return root.getUsableSpace();
|
||||
if (op.equals("enumerate") || op.equals("usedSpace")) {
|
||||
JSONArray rows = new JSONArray();
|
||||
long used = 0;
|
||||
try (java.util.stream.Stream<Path> paths = Files.walk(root.toPath())) {
|
||||
for (Path path : (Iterable<Path>) paths.filter(Files::isRegularFile)::iterator) {
|
||||
File f = path.toFile();
|
||||
if (f.getName().endsWith(".tmp")) continue;
|
||||
used += f.length();
|
||||
String name = root.toPath().relativize(path).toString();
|
||||
if (
|
||||
name.startsWith(p.optString("name", "").replaceFirst("^/sdcard/", "")) &&
|
||||
f.lastModified() >= p.optLong("since", 0)
|
||||
) rows.put(info(root, f));
|
||||
}
|
||||
}
|
||||
return op.equals("usedSpace") ? used : rows;
|
||||
}
|
||||
File f = path(type, p.getString("name"));
|
||||
if (op.equals("get")) {
|
||||
if (!f.isFile()) throw new IOException("NotFoundError");
|
||||
if (f.length() > MAX_MEDIA) throw new IOException("MEDIA_TOO_LARGE_32_MIB");
|
||||
return info(root, f).put(
|
||||
"blob",
|
||||
new JSONObject()
|
||||
.put("__vulpesBlobV1", true)
|
||||
.put("type", mime(f.getName()))
|
||||
.put("base64", Base64.encodeToString(Files.readAllBytes(f.toPath()), Base64.NO_WRAP))
|
||||
);
|
||||
}
|
||||
if (op.equals("delete")) {
|
||||
if (!f.isFile() || !f.delete()) throw new IOException("NotFoundError");
|
||||
return "/sdcard/" + root.toPath().relativize(f.toPath());
|
||||
}
|
||||
if (op.equals("add")) {
|
||||
JSONObject blob = p.getJSONObject("blob");
|
||||
if (
|
||||
!blob.optBoolean("__vulpesBlobV1") || !blob.getString("type").startsWith(prefix(type))
|
||||
) throw new IOException("TypeMismatchError");
|
||||
if (f.exists()) throw new IOException("NoModificationAllowedError");
|
||||
String encoded = blob.getString("base64");
|
||||
if (encoded.length() > (MAX_MEDIA * 4) / 3 + 4) throw new IOException(
|
||||
"MEDIA_TOO_LARGE_32_MIB"
|
||||
);
|
||||
copy(new ByteArrayInputStream(Base64.decode(encoded, Base64.DEFAULT)), f);
|
||||
return "/sdcard/" + root.toPath().relativize(f.toPath());
|
||||
}
|
||||
throw new IllegalArgumentException("NotSupportedError");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
package org.vulpes_os.preview;
|
||||
|
||||
import android.content.*;
|
||||
import android.database.Cursor;
|
||||
import android.database.MatrixCursor;
|
||||
import android.net.Uri;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
import android.provider.OpenableColumns;
|
||||
import java.io.*;
|
||||
|
||||
/** Grants the selected camera access to one temporary output, never the profile. */
|
||||
public final class CaptureProvider extends ContentProvider {
|
||||
|
||||
public boolean onCreate() {
|
||||
return true;
|
||||
}
|
||||
|
||||
private File file(Uri uri) throws FileNotFoundException {
|
||||
if (!"/capture.jpg".equals(uri.getPath())) throw new FileNotFoundException();
|
||||
return new File(getContext().getCacheDir(), "capture.jpg");
|
||||
}
|
||||
|
||||
public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
|
||||
return ParcelFileDescriptor.open(file(uri), ParcelFileDescriptor.parseMode(mode));
|
||||
}
|
||||
|
||||
public String getType(Uri uri) {
|
||||
return "image/jpeg";
|
||||
}
|
||||
|
||||
public Cursor query(Uri uri, String[] columns, String selection, String[] args, String order) {
|
||||
try {
|
||||
File f = file(uri);
|
||||
if (columns == null) columns = new String[] {
|
||||
OpenableColumns.DISPLAY_NAME,
|
||||
OpenableColumns.SIZE,
|
||||
};
|
||||
MatrixCursor cursor = new MatrixCursor(columns);
|
||||
Object[] row = new Object[columns.length];
|
||||
for (int i = 0; i < columns.length; i++) row[i] = OpenableColumns.SIZE.equals(columns[i])
|
||||
? f.length()
|
||||
: f.getName();
|
||||
cursor.addRow(row);
|
||||
return cursor;
|
||||
} catch (IOException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public Uri insert(Uri uri, ContentValues values) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
public int delete(Uri uri, String selection, String[] args) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
public int update(Uri uri, ContentValues values, String selection, String[] args) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
}
|
||||
173
android/app/src/main/java/org/vulpes_os/preview/GaiaServer.java
Normal file
173
android/app/src/main/java/org/vulpes_os/preview/GaiaServer.java
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
package org.vulpes_os.preview;
|
||||
|
||||
import android.content.Context;
|
||||
import android.webkit.MimeTypeMap;
|
||||
import java.io.*;
|
||||
import java.net.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.zip.*;
|
||||
|
||||
/** Read-only APK assets, loopback only. Never serves profile data or native RPC. */
|
||||
final class GaiaServer implements Closeable {
|
||||
|
||||
static final int PORT = 18765;
|
||||
final InstalledApps installed;
|
||||
private final ZipFile zip;
|
||||
private final ServerSocket socket;
|
||||
private final ExecutorService workers = Executors.newFixedThreadPool(4);
|
||||
|
||||
GaiaServer(Context context) throws IOException {
|
||||
try {installed=new InstalledApps(context);}catch(Exception e){throw new IOException(e);}
|
||||
File archive = new File(context.getCacheDir(), "gaia.zip");
|
||||
// Atomic refresh on every process start; the APK is the source of truth.
|
||||
File temp = new File(context.getCacheDir(), "gaia.zip.tmp");
|
||||
try (
|
||||
InputStream in = context.getAssets().open("gaia.zip");
|
||||
OutputStream out = new FileOutputStream(temp)
|
||||
) {
|
||||
copy(in, out);
|
||||
}
|
||||
Files.move(temp.toPath(), archive.toPath(), StandardCopyOption.REPLACE_EXISTING);
|
||||
zip = new ZipFile(archive);
|
||||
socket = new ServerSocket();
|
||||
socket.setReuseAddress(true);
|
||||
socket.bind(new InetSocketAddress(InetAddress.getByName("127.0.0.1"), PORT));
|
||||
Thread accept = new Thread(
|
||||
() -> {
|
||||
while (!socket.isClosed()) try {
|
||||
Socket client = socket.accept();
|
||||
workers.submit(() -> serve(client));
|
||||
} catch (IOException e) {
|
||||
if (!socket.isClosed()) android.util.Log.e("Vulpes", "HTTP accept", e);
|
||||
}
|
||||
},
|
||||
"Vulpes-assets"
|
||||
);
|
||||
accept.setDaemon(true);
|
||||
accept.start();
|
||||
}
|
||||
|
||||
private static void copy(InputStream in, OutputStream out) throws IOException {
|
||||
byte[] buf = new byte[32768];
|
||||
int n;
|
||||
while ((n = in.read(buf)) != -1) out.write(buf, 0, n);
|
||||
}
|
||||
|
||||
private static String line(InputStream in) throws IOException {
|
||||
ByteArrayOutputStream b = new ByteArrayOutputStream();
|
||||
int c;
|
||||
while ((c = in.read()) != -1 && c != '\n') {
|
||||
if (b.size() > 8192) throw new IOException("Header too long");
|
||||
if (c != '\r') b.write(c);
|
||||
}
|
||||
return b.toString("UTF-8");
|
||||
}
|
||||
|
||||
private void serve(Socket client) {
|
||||
try (Socket c = client) {
|
||||
c.setSoTimeout(5000);
|
||||
InputStream in = c.getInputStream();
|
||||
OutputStream out = c.getOutputStream();
|
||||
String[] start = line(in).split(" ");
|
||||
String host = "",
|
||||
origin = "";
|
||||
int total = 0;
|
||||
for (String h; (h = line(in)).length() > 0; ) {
|
||||
total += h.length();
|
||||
if (total > 32768) throw new IOException("Headers too large");
|
||||
int i = h.indexOf(':');
|
||||
if (i > 0) {
|
||||
String key = h.substring(0, i);
|
||||
String value = h.substring(i + 1).trim();
|
||||
if (key.equalsIgnoreCase("Host")) host = value.toLowerCase(Locale.ROOT);
|
||||
if (key.equalsIgnoreCase("Origin")) origin = value;
|
||||
}
|
||||
}
|
||||
if (
|
||||
start.length < 2 ||
|
||||
!start[0].equals("GET") ||
|
||||
!host.matches("[a-z0-9_-]+\\.localhost:" + PORT)
|
||||
) {
|
||||
error(out, 403);
|
||||
return;
|
||||
}
|
||||
String app = host.substring(0, host.indexOf('.'));
|
||||
if (app.equals("theme")) app = "default_theme";
|
||||
if (!InstalledApps.valid(app) && zip.getEntry(app + "/manifest.webapp") == null) {
|
||||
error(out, 404);
|
||||
return;
|
||||
}
|
||||
String path = URLDecoder.decode(start[1].split("\\?", 2)[0].replace("+", "%2B"), "UTF-8");
|
||||
if (
|
||||
!path.startsWith("/") ||
|
||||
path.contains("\\") ||
|
||||
path.indexOf('\0') >= 0 ||
|
||||
Arrays.asList(path.split("/")).contains("..")
|
||||
) {
|
||||
error(out, 400);
|
||||
return;
|
||||
}
|
||||
if (path.endsWith("/")) path += "index.html";
|
||||
if (InstalledApps.valid(app)) {
|
||||
byte[] body=installed.read(app,path.substring(1));
|
||||
if(body==null){error(out,404);return;}
|
||||
String ext=path.substring(path.lastIndexOf('.')+1).toLowerCase(Locale.ROOT);
|
||||
String mime=MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext);
|
||||
if(ext.equals("js"))mime="text/javascript";
|
||||
if(ext.equals("webapp"))mime="application/json";
|
||||
if(mime==null)mime="application/octet-stream";
|
||||
out.write(("HTTP/1.1 200 OK\r\nContent-Type: "+mime+"\r\nContent-Length: "+body.length+"\r\nConnection: close\r\nX-Content-Type-Options: nosniff\r\nCache-Control: no-store\r\nAccess-Control-Allow-Origin: *\r\nContent-Security-Policy: object-src 'none'; frame-ancestors http://*.localhost:"+PORT+"\r\n\r\n").getBytes(StandardCharsets.US_ASCII));out.write(body);return;
|
||||
}
|
||||
String key = path.startsWith("/_vulpes/") ? path.substring(1) : app + path;
|
||||
ZipEntry entry = zip.getEntry(key);
|
||||
if (entry == null && path.startsWith("/shared/")) entry = zip.getEntry(
|
||||
"_shared/" + path.substring(8)
|
||||
);
|
||||
if (entry == null || entry.isDirectory()) {
|
||||
error(out, 404);
|
||||
return;
|
||||
}
|
||||
String ext = path.substring(path.lastIndexOf('.') + 1).toLowerCase(Locale.ROOT);
|
||||
String mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext);
|
||||
if (ext.equals("js") || ext.equals("mjs")) mime = "text/javascript";
|
||||
if (ext.equals("webapp") || ext.equals("json")) mime = "application/json";
|
||||
if (ext.equals("properties")) mime = "text/plain";
|
||||
if (mime == null) mime = "application/octet-stream";
|
||||
String headers =
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: " +
|
||||
mime +
|
||||
"\r\nContent-Length: " +
|
||||
entry.getSize() +
|
||||
"\r\nConnection: close\r\nX-Content-Type-Options: nosniff\r\nCache-Control: no-cache\r\nContent-Security-Policy: script-src 'self' 'unsafe-eval'; object-src 'none'; base-uri 'self'; worker-src 'self' blob:; frame-ancestors http://*.localhost:" +
|
||||
PORT +
|
||||
"\r\n";
|
||||
if (
|
||||
origin.matches("http://[a-z0-9_-]+\\.localhost:" + PORT) &&
|
||||
zip.getEntry(new URI(origin).getHost().split("\\.")[0] + "/manifest.webapp") != null
|
||||
) headers += "Access-Control-Allow-Origin: " + origin + "\r\nVary: Origin\r\n";
|
||||
out.write((headers + "\r\n").getBytes(StandardCharsets.US_ASCII));
|
||||
try (InputStream body = zip.getInputStream(entry)) {
|
||||
copy(body, out);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
android.util.Log.d("Vulpes", "HTTP closed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static void error(OutputStream out, int code) throws IOException {
|
||||
out.write(
|
||||
("HTTP/1.1 " + code + " Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").getBytes(
|
||||
StandardCharsets.US_ASCII
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public void close() throws IOException {
|
||||
socket.close();
|
||||
workers.shutdownNow();
|
||||
zip.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package org.vulpes_os.preview;
|
||||
|
||||
import android.content.Context;
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import org.json.*;
|
||||
|
||||
final class InstalledApps {
|
||||
private final File root;
|
||||
private final Map<String,JSONObject> items = new HashMap<>();
|
||||
InstalledApps(Context context) throws Exception {
|
||||
root=new File(context.getFilesDir(),"installed-apps");root.mkdirs();
|
||||
File[] files=root.listFiles((d,n)->n.matches("user-[a-f0-9]{24}\\.json"));
|
||||
if(files!=null)for(File file:files){JSONObject item=new JSONObject(new String(Files.readAllBytes(file.toPath()),StandardCharsets.UTF_8));items.put(item.getString("id"),item);}
|
||||
}
|
||||
static boolean valid(String id){return id!=null && id.matches("user-[a-f0-9]{24}");}
|
||||
private JSONObject record(JSONObject item) throws Exception {
|
||||
String id=item.getString("id"),origin="http://"+id+".localhost:"+GaiaServer.PORT;
|
||||
JSONObject manifest=new JSONObject(item.getJSONObject("manifest").toString());manifest.put("permissions",new JSONObject());manifest.put("type","web");
|
||||
return new JSONObject().put("id",id).put("origin",origin).put("manifestURL",origin+"/manifest.webapp").put("manifest",manifest).put("removable",true).put("installed",true);
|
||||
}
|
||||
synchronized JSONArray list() throws Exception {JSONArray result=new JSONArray();for(JSONObject item:items.values())result.put(record(item));return result;}
|
||||
synchronized void save(JSONObject item) throws Exception {
|
||||
String id=item.getString("id");if(!valid(id) || !item.has("files"))throw new IOException("INVALID_PACKAGE");
|
||||
byte[] data=item.toString().getBytes(StandardCharsets.UTF_8);if(data.length>70*1024*1024)throw new IOException("PACKAGE_TOO_LARGE");
|
||||
File stage=new File(root,id+".tmp");try(FileOutputStream out=new FileOutputStream(stage)){out.write(data);out.getFD().sync();}
|
||||
Files.move(stage.toPath(),new File(root,id+".json").toPath(),StandardCopyOption.REPLACE_EXISTING);items.put(id,item);
|
||||
}
|
||||
synchronized void remove(String id) throws Exception {if(!valid(id))throw new IOException("NOT_REMOVABLE");Files.deleteIfExists(new File(root,id+".json").toPath());items.remove(id);}
|
||||
synchronized byte[] read(String id,String path) throws Exception {
|
||||
JSONObject item=items.get(id);if(item==null)return null;
|
||||
if(path.equals("manifest.webapp"))return record(item).getJSONObject("manifest").toString().getBytes(StandardCharsets.UTF_8);
|
||||
String data=item.getJSONObject("files").optString(path,null);return data==null?null:android.util.Base64.decode(data,android.util.Base64.DEFAULT);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,680 @@
|
|||
package org.vulpes_os.preview;
|
||||
|
||||
import android.app.*;
|
||||
import android.content.*;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.graphics.Color;
|
||||
import android.net.Uri;
|
||||
import android.os.*;
|
||||
import android.text.InputType;
|
||||
import android.view.*;
|
||||
import android.widget.*;
|
||||
import java.net.URI;
|
||||
import java.util.ArrayList;
|
||||
import org.mozilla.geckoview.*;
|
||||
|
||||
public final class MainActivity extends Activity {
|
||||
|
||||
private VulpesApplication app;
|
||||
private GeckoSession gaia, web;
|
||||
private GeckoView gaiaView, webView;
|
||||
private LinearLayout root, webPanel;
|
||||
private TextView notice;
|
||||
private EditText address;
|
||||
private Button home;
|
||||
private boolean webBack;
|
||||
private boolean resumed, recoverGaia, recoverWeb;
|
||||
private long lastCrash;
|
||||
private int crashCount;
|
||||
private final java.util.Map<Integer, java.util.function.Consumer<Boolean>> permissionResults =
|
||||
new java.util.HashMap<>();
|
||||
private final java.util.List<java.util.function.Consumer<Boolean>> resumedCameraPermissions =
|
||||
new java.util.ArrayList<>();
|
||||
private int nextPermission = 500;
|
||||
private GeckoResult<GeckoSession.PromptDelegate.PromptResponse> fileResult;
|
||||
private GeckoSession.PromptDelegate.FilePrompt filePrompt;
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle state) {
|
||||
super.onCreate(state);
|
||||
app = (VulpesApplication) getApplication();
|
||||
app.activity = this;
|
||||
root = new LinearLayout(this);
|
||||
root.setOrientation(LinearLayout.VERTICAL);
|
||||
root.setBackgroundColor(Color.rgb(18, 35, 50));
|
||||
root.setOnApplyWindowInsetsListener((v, insets) -> {
|
||||
if (Build.VERSION.SDK_INT >= 30) {
|
||||
android.graphics.Insets bars = insets.getInsets(
|
||||
WindowInsets.Type.ime() |
|
||||
WindowInsets.Type.displayCutout()
|
||||
);
|
||||
v.setPadding(bars.left, bars.top, bars.right, bars.bottom);
|
||||
} else v.setPadding(
|
||||
insets.getSystemWindowInsetLeft(),
|
||||
insets.getSystemWindowInsetTop(),
|
||||
insets.getSystemWindowInsetRight(),
|
||||
insets.getSystemWindowInsetBottom() > dp(100) ? insets.getSystemWindowInsetBottom() : 0
|
||||
);
|
||||
return insets;
|
||||
});
|
||||
setContentView(root);
|
||||
immersive();
|
||||
// Respect Android's screen timeout; Gaia must not keep the phone awake indefinitely.
|
||||
float brightness = app.getSharedPreferences("android", 0).getFloat("brightness", -1);
|
||||
if (brightness >= 0) {
|
||||
WindowManager.LayoutParams attrs = getWindow().getAttributes();
|
||||
attrs.screenBrightness = Math.max(.02f, brightness);
|
||||
getWindow().setAttributes(attrs);
|
||||
}
|
||||
notice = new TextView(this);
|
||||
notice.setText("VULPES\nChargement de Firefox OS…");
|
||||
notice.setTextColor(Color.WHITE);
|
||||
notice.setGravity(Gravity.CENTER);
|
||||
root.addView(notice, new LinearLayout.LayoutParams(-1, dp(90)));
|
||||
gaiaView = new GeckoView(this);
|
||||
root.addView(gaiaView, new LinearLayout.LayoutParams(-1, 0, 1));
|
||||
webPanel = new LinearLayout(this);
|
||||
webPanel.setOrientation(LinearLayout.VERTICAL);
|
||||
webPanel.setBackgroundColor(Color.WHITE);
|
||||
webPanel.setVisibility(View.GONE);
|
||||
root.addView(webPanel, new LinearLayout.LayoutParams(-1, 0, 1));
|
||||
LinearLayout nav = new LinearLayout(this);
|
||||
webPanel.addView(nav, new LinearLayout.LayoutParams(-1, dp(52)));
|
||||
Button back = new Button(this);
|
||||
back.setText("‹");
|
||||
back.setContentDescription("Retour");
|
||||
back.setOnClickListener(v -> back());
|
||||
nav.addView(back, new LinearLayout.LayoutParams(dp(55), -1));
|
||||
address = new EditText(this);
|
||||
address.setSingleLine(true);
|
||||
address.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
|
||||
address.setTextSize(14);
|
||||
nav.addView(address, new LinearLayout.LayoutParams(0, -1, 1));
|
||||
address.setOnEditorActionListener((v, id, event) -> {
|
||||
String url = address.getText().toString().trim();
|
||||
openBrowser(url.contains("://") ? url : "https://" + url);
|
||||
((android.view.inputmethod.InputMethodManager) getSystemService(
|
||||
INPUT_METHOD_SERVICE
|
||||
)).hideSoftInputFromWindow(address.getWindowToken(), 0);
|
||||
return true;
|
||||
});
|
||||
webView = new GeckoView(this);
|
||||
// The web panel is layered over Gaia and hidden when returning to an app.
|
||||
// A TextureView follows that visibility without leaving a separate surface.
|
||||
webView.setViewBackend(GeckoView.BACKEND_TEXTURE_VIEW);
|
||||
webPanel.addView(webView, new LinearLayout.LayoutParams(-1, 0, 1));
|
||||
home = new Button(this);
|
||||
home.setText("◯");
|
||||
home.setTextColor(Color.WHITE);
|
||||
home.setBackgroundColor(Color.rgb(18, 35, 50));
|
||||
home.setContentDescription("Accueil Vulpes. Appui long : applications ouvertes.");
|
||||
root.addView(home, new LinearLayout.LayoutParams(-1, dp(44)));
|
||||
home.setOnClickListener(v -> {
|
||||
closeBrowser();
|
||||
app.home(false);
|
||||
});
|
||||
home.setOnLongClickListener(v -> {
|
||||
closeBrowser();
|
||||
app.home(true);
|
||||
return true;
|
||||
});
|
||||
setHomeSurface(app.homeVisible);
|
||||
if (Build.VERSION.SDK_INT >= 33) getOnBackInvokedDispatcher().registerOnBackInvokedCallback(
|
||||
0,
|
||||
this::back
|
||||
);
|
||||
try {
|
||||
app.prepare();
|
||||
boolean retained = app.gaiaSession != null;
|
||||
gaia = retained
|
||||
? app.gaiaSession
|
||||
: new GeckoSession(
|
||||
new GeckoSessionSettings.Builder()
|
||||
.displayMode(GeckoSessionSettings.DISPLAY_MODE_STANDALONE)
|
||||
.build()
|
||||
);
|
||||
app.gaiaSession = gaia;
|
||||
gaia.setContentDelegate(content);
|
||||
gaia.setPromptDelegate(prompts);
|
||||
gaia.setPermissionDelegate(permissions);
|
||||
gaia.setNavigationDelegate(
|
||||
new GeckoSession.NavigationDelegate() {
|
||||
@Override
|
||||
public GeckoResult<AllowOrDeny> onLoadRequest(GeckoSession s, LoadRequest r) {
|
||||
if (r.uri.startsWith("http://") && isGaia(r.uri)) return GeckoResult.fromValue(
|
||||
AllowOrDeny.ALLOW
|
||||
);
|
||||
if (r.uri.equals("about:blank")) return GeckoResult.fromValue(AllowOrDeny.ALLOW);
|
||||
openBrowser(r.uri);
|
||||
return GeckoResult.fromValue(AllowOrDeny.DENY);
|
||||
}
|
||||
}
|
||||
);
|
||||
if (!gaia.isOpen()) gaia.open(app.runtime);
|
||||
gaiaView.setSession(gaia);
|
||||
app.extension.accept(
|
||||
ext -> {
|
||||
notice.setVisibility(View.GONE);
|
||||
if (isDestroyed()) return;
|
||||
if (!retained) gaia.loadUri(gaiaUrl());
|
||||
app.notifications.click(getIntent());
|
||||
},
|
||||
e -> {
|
||||
notice.setText("Impossible de démarrer Vulpes : " + e.getMessage());
|
||||
}
|
||||
);
|
||||
} catch (Exception e) {
|
||||
notice.setText("Impossible de démarrer Vulpes : " + e.getMessage());
|
||||
android.util.Log.e("Vulpes", "Startup", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void immersive() {
|
||||
if (Build.VERSION.SDK_INT >= 30) {
|
||||
getWindow().setDecorFitsSystemWindows(false);
|
||||
WindowInsetsController controller = getWindow().getInsetsController();
|
||||
if (controller != null) {
|
||||
controller.setSystemBarsBehavior(WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
|
||||
controller.hide(WindowInsets.Type.systemBars());
|
||||
}
|
||||
} else {
|
||||
getWindow().getDecorView().setSystemUiVisibility(
|
||||
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY | View.SYSTEM_UI_FLAG_FULLSCREEN |
|
||||
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_LAYOUT_STABLE |
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onWindowFocusChanged(boolean focused) {
|
||||
super.onWindowFocusChanged(focused);
|
||||
if (focused) immersive();
|
||||
}
|
||||
|
||||
private int dp(int value) {
|
||||
return Math.round(value * getResources().getDisplayMetrics().density);
|
||||
}
|
||||
|
||||
private boolean isGaia(String url) {
|
||||
try {
|
||||
URI u = new URI(url);
|
||||
return (
|
||||
u.getHost() != null && u.getHost().endsWith(".localhost") && u.getPort() == GaiaServer.PORT
|
||||
);
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void openBrowser(String url) {
|
||||
try {
|
||||
URI uri = new URI(url);
|
||||
if (
|
||||
!("https".equals(uri.getScheme()) || "http".equals(uri.getScheme())) || isGaia(url)
|
||||
) return;
|
||||
} catch (Exception e) {
|
||||
return;
|
||||
}
|
||||
if (web == null) {
|
||||
web = new GeckoSession();
|
||||
web.setContentDelegate(content);
|
||||
web.setPromptDelegate(prompts);
|
||||
web.setPermissionDelegate(permissions);
|
||||
web.setNavigationDelegate(
|
||||
new GeckoSession.NavigationDelegate() {
|
||||
@Override
|
||||
public void onCanGoBack(GeckoSession s, boolean can) {
|
||||
webBack = can;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLocationChange(
|
||||
GeckoSession s,
|
||||
String url,
|
||||
java.util.List<GeckoSession.PermissionDelegate.ContentPermission> permissions,
|
||||
Boolean gesture
|
||||
) {
|
||||
address.setText(url);
|
||||
}
|
||||
|
||||
@Override
|
||||
public GeckoResult<AllowOrDeny> onLoadRequest(GeckoSession s, LoadRequest r) {
|
||||
return GeckoResult.fromValue(
|
||||
(r.uri.startsWith("https://") || r.uri.startsWith("http://")) && !isGaia(r.uri)
|
||||
? AllowOrDeny.ALLOW
|
||||
: AllowOrDeny.DENY
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public GeckoResult<GeckoSession> onNewSession(GeckoSession s, String uri) {
|
||||
openBrowser(uri);
|
||||
return GeckoResult.fromValue(null);
|
||||
}
|
||||
}
|
||||
);
|
||||
web.open(app.runtime);
|
||||
webView.setSession(web);
|
||||
}
|
||||
gaiaView.setVisibility(View.GONE);
|
||||
webPanel.setVisibility(View.VISIBLE);
|
||||
setHomeSurface(false);
|
||||
address.setText(url);
|
||||
web.loadUri(url);
|
||||
syncActive();
|
||||
}
|
||||
|
||||
void setHomeSurface(boolean visible) {
|
||||
runOnUiThread(() -> {
|
||||
boolean overlay = visible && webPanel.getVisibility() != View.VISIBLE;
|
||||
LinearLayout.LayoutParams params = (LinearLayout.LayoutParams) home.getLayoutParams();
|
||||
boolean landscape = getResources().getConfiguration().orientation ==
|
||||
android.content.res.Configuration.ORIENTATION_LANDSCAPE;
|
||||
root.setOrientation(landscape ? LinearLayout.HORIZONTAL : LinearLayout.VERTICAL);
|
||||
gaiaView.setLayoutParams(landscape ? new LinearLayout.LayoutParams(0, -1, 1) :
|
||||
new LinearLayout.LayoutParams(-1, 0, 1));
|
||||
webPanel.setLayoutParams(landscape ? new LinearLayout.LayoutParams(0, -1, 1) :
|
||||
new LinearLayout.LayoutParams(-1, 0, 1));
|
||||
params.width = landscape ? dp(44) : -1;
|
||||
params.height = landscape ? -1 : dp(44);
|
||||
// Only Home overlays Gaia wallpaper. Apps keep their full usable area.
|
||||
params.topMargin = !landscape && overlay ? -dp(44) : 0;
|
||||
params.leftMargin = landscape && overlay ? -dp(44) : 0;
|
||||
home.setLayoutParams(params);
|
||||
if (overlay && app.homeAtBottom) home.setBackgroundColor(Color.TRANSPARENT);
|
||||
else if (overlay) home.setBackground(
|
||||
new android.graphics.drawable.GradientDrawable(
|
||||
landscape ? android.graphics.drawable.GradientDrawable.Orientation.RIGHT_LEFT :
|
||||
android.graphics.drawable.GradientDrawable.Orientation.BOTTOM_TOP,
|
||||
new int[] { Color.argb(204, 0, 0, 0), Color.TRANSPARENT }
|
||||
)
|
||||
);
|
||||
else home.setBackgroundColor(Color.rgb(18, 35, 50));
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onConfigurationChanged(android.content.res.Configuration config) {
|
||||
super.onConfigurationChanged(config);
|
||||
setHomeSurface(app.homeVisible);
|
||||
}
|
||||
|
||||
void closeBrowser() {
|
||||
boolean wasVisible = webPanel.getVisibility() == View.VISIBLE;
|
||||
webPanel.setVisibility(View.GONE);
|
||||
gaiaView.setVisibility(View.VISIBLE);
|
||||
setHomeSurface(app.homeVisible);
|
||||
syncActive();
|
||||
if (wasVisible) app.browserClosed();
|
||||
}
|
||||
|
||||
private void back() {
|
||||
if (webPanel.getVisibility() == View.VISIBLE) {
|
||||
if (webBack) web.goBack();
|
||||
else closeBrowser();
|
||||
} else app.home(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
back();
|
||||
}
|
||||
|
||||
private String gaiaUrl() {
|
||||
return "http://system.localhost:" + GaiaServer.PORT + "/index.html";
|
||||
}
|
||||
|
||||
private void syncActive() {
|
||||
boolean browsing = webPanel != null && webPanel.getVisibility() == View.VISIBLE;
|
||||
if (gaia != null && gaia.isOpen()) gaia.setActive(resumed && !browsing);
|
||||
if (web != null && web.isOpen()) web.setActive(resumed && browsing);
|
||||
}
|
||||
|
||||
private void recover(GeckoSession session, boolean crashed) {
|
||||
if (isDestroyed()) return;
|
||||
if (crashed) {
|
||||
long now = SystemClock.elapsedRealtime();
|
||||
crashCount = now - lastCrash < 30000 ? crashCount + 1 : 1;
|
||||
lastCrash = now;
|
||||
if (crashCount > 2) {
|
||||
notice.setText("Vulpes s’est arrêté. Touchez ici pour réessayer.");
|
||||
notice.setVisibility(View.VISIBLE);
|
||||
notice.setOnClickListener(v -> {
|
||||
crashCount = 0;
|
||||
recover(session, false);
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!resumed) {
|
||||
if (session == gaia) recoverGaia = true;
|
||||
else recoverWeb = true;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!session.isOpen()) session.open(app.runtime);
|
||||
session.loadUri(session == gaia ? gaiaUrl() : address.getText().toString());
|
||||
syncActive();
|
||||
android.util.Log.i(
|
||||
"Vulpes",
|
||||
"Recovered " + (session == gaia ? "Gaia" : "browser") + " session"
|
||||
);
|
||||
} catch (Exception e) {
|
||||
notice.setText("Reprise impossible : " + e.getMessage());
|
||||
notice.setVisibility(View.VISIBLE);
|
||||
}
|
||||
}
|
||||
|
||||
private final GeckoSession.ContentDelegate content = new GeckoSession.ContentDelegate() {
|
||||
public void onCrash(GeckoSession session) {
|
||||
recover(session, true);
|
||||
}
|
||||
|
||||
public void onKill(GeckoSession session) {
|
||||
recover(session, false);
|
||||
}
|
||||
|
||||
public void onFirstComposite(GeckoSession session) {
|
||||
if (session == gaia) notice.setVisibility(View.GONE);
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
resumed = true;
|
||||
immersive();
|
||||
app.activity = this;
|
||||
if (recoverGaia) {
|
||||
recoverGaia = false;
|
||||
recover(gaia, false);
|
||||
}
|
||||
if (recoverWeb && web != null) {
|
||||
recoverWeb = false;
|
||||
recover(web, false);
|
||||
}
|
||||
syncActive();
|
||||
// Android delivers permission results before onResume. Start the sensor only
|
||||
// after GeckoView has been made active again.
|
||||
java.util.List<java.util.function.Consumer<Boolean>> grants = new java.util.ArrayList<>(
|
||||
resumedCameraPermissions
|
||||
);
|
||||
resumedCameraPermissions.clear();
|
||||
for (java.util.function.Consumer<Boolean> grant : grants) grant.accept(true);
|
||||
gaiaView.requestLayout();
|
||||
app.resumed();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPause() {
|
||||
resumed = false;
|
||||
syncActive();
|
||||
super.onPause();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
setIntent(intent);
|
||||
if (intent.hasCategory(Intent.CATEGORY_HOME)) { closeBrowser(); app.home(false); }
|
||||
if ("org.vulpes_os.preview.LOCAL_NOTIFICATION".equals(intent.getAction())) closeBrowser();
|
||||
if (app.notifications != null) app.notifications.click(intent);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
gaiaView.releaseSession();
|
||||
webView.releaseSession();
|
||||
if (gaia != null) {
|
||||
gaia.setContentDelegate(null);
|
||||
gaia.setNavigationDelegate(null);
|
||||
gaia.setPromptDelegate(null);
|
||||
gaia.setPermissionDelegate(null);
|
||||
if (isFinishing()) {
|
||||
gaia.close();
|
||||
app.gaiaSession = null;
|
||||
}
|
||||
}
|
||||
if (web != null) web.close();
|
||||
if (fileResult != null) {
|
||||
fileResult.complete(filePrompt.dismiss());
|
||||
fileResult = null;
|
||||
}
|
||||
for (java.util.function.Consumer<Boolean> callback : permissionResults.values())
|
||||
callback.accept(false);
|
||||
permissionResults.clear();
|
||||
for (java.util.function.Consumer<Boolean> grant : resumedCameraPermissions) grant.accept(false);
|
||||
resumedCameraPermissions.clear();
|
||||
if (app.activity == this) app.activity = null;
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
void notificationPermission(java.util.function.Consumer<Boolean> callback) {
|
||||
if (!resumed) {
|
||||
callback.accept(false);
|
||||
return;
|
||||
}
|
||||
permission(
|
||||
Build.VERSION.SDK_INT >= 33
|
||||
? new String[] { android.Manifest.permission.POST_NOTIFICATIONS }
|
||||
: new String[0],
|
||||
callback
|
||||
);
|
||||
}
|
||||
|
||||
private void finishPermission(
|
||||
String[] names,
|
||||
boolean granted,
|
||||
java.util.function.Consumer<Boolean> callback
|
||||
) {
|
||||
if (
|
||||
granted &&
|
||||
!resumed &&
|
||||
java.util.Arrays.asList(names).contains(android.Manifest.permission.CAMERA)
|
||||
) resumedCameraPermissions.add(callback);
|
||||
else callback.accept(granted);
|
||||
}
|
||||
|
||||
void permission(String[] names, java.util.function.Consumer<Boolean> callback) {
|
||||
boolean granted = true;
|
||||
for (String name : names)
|
||||
granted &= checkSelfPermission(name) == PackageManager.PERMISSION_GRANTED;
|
||||
if (granted) {
|
||||
finishPermission(names, true, callback);
|
||||
return;
|
||||
}
|
||||
int code = nextPermission++;
|
||||
permissionResults.put(code, callback);
|
||||
requestPermissions(names, code);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRequestPermissionsResult(int code, String[] names, int[] grants) {
|
||||
super.onRequestPermissionsResult(code, names, grants);
|
||||
java.util.function.Consumer<Boolean> callback = permissionResults.remove(code);
|
||||
if (callback != null) {
|
||||
boolean granted = grants.length > 0;
|
||||
// Approximate location is a valid choice; do not require fine location as well.
|
||||
if (
|
||||
java.util.Arrays.asList(names).contains(android.Manifest.permission.ACCESS_COARSE_LOCATION)
|
||||
) granted =
|
||||
checkSelfPermission(android.Manifest.permission.ACCESS_COARSE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED;
|
||||
else for (int grant : grants) granted &= grant == PackageManager.PERMISSION_GRANTED;
|
||||
finishPermission(names, granted, callback);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onActivityResult(int code, int status, Intent intent) {
|
||||
super.onActivityResult(code, status, intent);
|
||||
if (code == 420 && fileResult != null) {
|
||||
GeckoResult<GeckoSession.PromptDelegate.PromptResponse> result = fileResult;
|
||||
fileResult = null;
|
||||
ArrayList<Uri> uris = new ArrayList<>();
|
||||
if (status == RESULT_OK && intent != null) {
|
||||
if (intent.getClipData() != null) for (
|
||||
int i = 0;
|
||||
i < intent.getClipData().getItemCount();
|
||||
i++
|
||||
) uris.add(intent.getClipData().getItemAt(i).getUri());
|
||||
else if (intent.getData() != null) uris.add(intent.getData());
|
||||
}
|
||||
result.complete(
|
||||
uris.isEmpty() ? filePrompt.dismiss() : filePrompt.confirm(this, uris.toArray(new Uri[0]))
|
||||
);
|
||||
filePrompt = null;
|
||||
} else if (app.services != null) app.services.onResult(code, status, intent);
|
||||
}
|
||||
|
||||
private final GeckoSession.PermissionDelegate permissions =
|
||||
new GeckoSession.PermissionDelegate() {
|
||||
@Override
|
||||
public void onAndroidPermissionsRequest(
|
||||
GeckoSession session,
|
||||
String[] names,
|
||||
Callback callback
|
||||
) {
|
||||
permission(names, granted -> {
|
||||
if (granted) callback.grant();
|
||||
else callback.reject();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMediaPermissionRequest(
|
||||
GeckoSession session,
|
||||
String uri,
|
||||
MediaSource[] video,
|
||||
MediaSource[] audio,
|
||||
MediaCallback callback
|
||||
) {
|
||||
Uri origin = Uri.parse(uri);
|
||||
boolean camera =
|
||||
session == gaia &&
|
||||
"http".equals(origin.getScheme()) &&
|
||||
"camera.localhost".equals(origin.getHost()) &&
|
||||
origin.getPort() == 18765;
|
||||
boolean hasVideo = video != null && video.length > 0;
|
||||
boolean hasAudio = audio != null && audio.length > 0;
|
||||
if (!camera || (!hasVideo && !hasAudio)) {
|
||||
callback.reject();
|
||||
return;
|
||||
}
|
||||
java.util.ArrayList<String> 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();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public GeckoResult<Integer> onContentPermissionRequest(
|
||||
GeckoSession session,
|
||||
ContentPermission permission
|
||||
) {
|
||||
boolean local = session == gaia && isGaia(permission.uri);
|
||||
if (
|
||||
permission.permission == PERMISSION_DESKTOP_NOTIFICATION ||
|
||||
permission.permission == PERMISSION_GEOLOCATION
|
||||
) {
|
||||
GeckoResult<Integer> result = new GeckoResult<>();
|
||||
Runnable ask = () -> {
|
||||
String[] names = permission.permission == PERMISSION_GEOLOCATION
|
||||
? new String[] {
|
||||
android.Manifest.permission.ACCESS_COARSE_LOCATION,
|
||||
android.Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
}
|
||||
: Build.VERSION.SDK_INT >= 33
|
||||
? new String[] { android.Manifest.permission.POST_NOTIFICATIONS }
|
||||
: new String[0];
|
||||
permission(names, granted ->
|
||||
result.complete(
|
||||
granted ? ContentPermission.VALUE_ALLOW : ContentPermission.VALUE_DENY
|
||||
)
|
||||
);
|
||||
};
|
||||
new AlertDialog.Builder(MainActivity.this)
|
||||
.setTitle("Autorisation Vulpes")
|
||||
.setMessage(
|
||||
permission.uri +
|
||||
(permission.permission == PERMISSION_GEOLOCATION
|
||||
? "\nAccéder à votre position ?"
|
||||
: "\nAfficher des notifications Android ?")
|
||||
)
|
||||
.setPositiveButton("Autoriser", (d, w) -> ask.run())
|
||||
.setNegativeButton("Refuser", (d, w) -> result.complete(ContentPermission.VALUE_DENY))
|
||||
.setOnCancelListener(d -> result.complete(ContentPermission.VALUE_DENY))
|
||||
.show();
|
||||
return result;
|
||||
}
|
||||
boolean allow =
|
||||
local &&
|
||||
(permission.permission == PERMISSION_PERSISTENT_STORAGE ||
|
||||
permission.permission == PERMISSION_AUTOPLAY_INAUDIBLE ||
|
||||
permission.permission == PERMISSION_AUTOPLAY_AUDIBLE);
|
||||
return GeckoResult.fromValue(
|
||||
allow ? ContentPermission.VALUE_ALLOW : ContentPermission.VALUE_DENY
|
||||
);
|
||||
}
|
||||
};
|
||||
private final GeckoSession.PromptDelegate prompts = new GeckoSession.PromptDelegate() {
|
||||
@Override
|
||||
public GeckoResult<PromptResponse> onFilePrompt(GeckoSession session, FilePrompt prompt) {
|
||||
if (fileResult != null) return GeckoResult.fromValue(prompt.dismiss());
|
||||
GeckoResult<PromptResponse> result = new GeckoResult<>();
|
||||
Intent pick = new Intent(Intent.ACTION_OPEN_DOCUMENT)
|
||||
.addCategory(Intent.CATEGORY_OPENABLE)
|
||||
.setType("*/*")
|
||||
.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, prompt.type == FilePrompt.Type.MULTIPLE);
|
||||
if (prompt.mimeTypes != null && prompt.mimeTypes.length > 0) pick.putExtra(
|
||||
Intent.EXTRA_MIME_TYPES,
|
||||
prompt.mimeTypes
|
||||
);
|
||||
try {
|
||||
startActivityForResult(pick, 420);
|
||||
fileResult = result;
|
||||
filePrompt = prompt;
|
||||
} catch (ActivityNotFoundException e) {
|
||||
result.complete(prompt.dismiss());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public GeckoResult<PromptResponse> onAlertPrompt(GeckoSession s, AlertPrompt p) {
|
||||
GeckoResult<PromptResponse> result = new GeckoResult<>();
|
||||
new AlertDialog.Builder(MainActivity.this)
|
||||
.setTitle(p.title)
|
||||
.setMessage(p.message)
|
||||
.setPositiveButton("OK", (d, w) -> result.complete(p.dismiss()))
|
||||
.setOnCancelListener(d -> result.complete(p.dismiss()))
|
||||
.show();
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public GeckoResult<PromptResponse> onChoicePrompt(GeckoSession s, ChoicePrompt p) {
|
||||
ArrayList<ChoicePrompt.Choice> choices = new ArrayList<>();
|
||||
flatten(p.choices, choices);
|
||||
String[] labels = new String[choices.size()];
|
||||
for (int i = 0; i < labels.length; i++) labels[i] = choices.get(i).label;
|
||||
GeckoResult<PromptResponse> result = new GeckoResult<>();
|
||||
new AlertDialog.Builder(MainActivity.this)
|
||||
.setTitle(p.title)
|
||||
.setItems(labels, (d, n) -> result.complete(p.confirm(choices.get(n))))
|
||||
.setOnCancelListener(d -> result.complete(p.dismiss()))
|
||||
.show();
|
||||
return result;
|
||||
}
|
||||
|
||||
private void flatten(ChoicePrompt.Choice[] items, ArrayList<ChoicePrompt.Choice> out) {
|
||||
for (ChoicePrompt.Choice c : items) {
|
||||
if (c.items != null) flatten(c.items, out);
|
||||
else if (!c.disabled && !c.separator) out.add(c);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
package org.vulpes_os.preview;
|
||||
|
||||
import android.app.Application;
|
||||
import android.util.Log;
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import org.json.JSONObject;
|
||||
import org.mozilla.geckoview.*;
|
||||
|
||||
public final class VulpesApplication extends Application {
|
||||
|
||||
GeckoRuntime runtime;
|
||||
GaiaServer server;
|
||||
WebExtension.Port port;
|
||||
MainActivity activity;
|
||||
GeckoResult<WebExtension> extension;
|
||||
GeckoSession gaiaSession;
|
||||
AndroidServices services;
|
||||
AndroidNotifications notifications;
|
||||
boolean pendingScreenLock;
|
||||
boolean homeVisible;
|
||||
boolean homeAtBottom;
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
android.content.BroadcastReceiver receiver = new android.content.BroadcastReceiver() {
|
||||
@Override public void onReceive(android.content.Context context, android.content.Intent intent) {
|
||||
pendingScreenLock = true;
|
||||
sendScreenLock();
|
||||
}
|
||||
};
|
||||
android.content.IntentFilter filter = new android.content.IntentFilter(android.content.Intent.ACTION_SCREEN_OFF);
|
||||
if (android.os.Build.VERSION.SDK_INT >= 33) registerReceiver(receiver,filter,RECEIVER_NOT_EXPORTED);
|
||||
else registerReceiver(receiver,filter);
|
||||
}
|
||||
|
||||
void sendScreenLock() {
|
||||
if (port == null || !pendingScreenLock) return;
|
||||
try {
|
||||
port.postMessage(new JSONObject().put("type","android-screen-off"));
|
||||
pendingScreenLock = false;
|
||||
} catch (Exception error) {Log.w("Vulpes","Screen lock pending",error);}
|
||||
}
|
||||
|
||||
void prepare() throws IOException {
|
||||
if (runtime != null) return;
|
||||
server = new GaiaServer(this);
|
||||
File config = new File(getFilesDir(), "geckoview.yaml");
|
||||
try (FileOutputStream out = new FileOutputStream(config)) {
|
||||
out.write(
|
||||
("prefs:\n datareporting.policy.dataSubmissionEnabled: false\n toolkit.telemetry.enabled: false\n browser.tabs.warnOnClose: false\n" +
|
||||
(android.os.Build.HARDWARE.equals("ranchu")
|
||||
? " gfx.webrender.software: true\n"
|
||||
: "")).getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
}
|
||||
runtime = GeckoRuntime.create(
|
||||
this,
|
||||
new GeckoRuntimeSettings.Builder()
|
||||
.configFilePath(config.getAbsolutePath())
|
||||
.consoleOutput(true)
|
||||
.remoteDebuggingEnabled(
|
||||
(getApplicationInfo().flags & android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0
|
||||
)
|
||||
.locales(new String[] { "fr", "en-US" })
|
||||
.automaticFontSizeAdjustment(false)
|
||||
.fontInflation(false)
|
||||
.build()
|
||||
);
|
||||
services = new AndroidServices(this);
|
||||
notifications = new AndroidNotifications(this);
|
||||
runtime.setWebNotificationDelegate(notifications);
|
||||
extension = runtime
|
||||
.getWebExtensionController()
|
||||
.ensureBuiltIn("resource://android/assets/bridge/", "android-host@vulpes-os.org");
|
||||
extension.accept(
|
||||
ext ->
|
||||
ext.setMessageDelegate(
|
||||
new WebExtension.MessageDelegate() {
|
||||
@Override
|
||||
public void onConnect(WebExtension.Port p) {
|
||||
port = p;
|
||||
sendScreenLock();
|
||||
if (activity != null) notifications.click(activity.getIntent());
|
||||
p.setDelegate(
|
||||
new WebExtension.PortDelegate() {
|
||||
@Override
|
||||
public void onDisconnect(WebExtension.Port p) {
|
||||
if (port == p) port = null;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public GeckoResult<Object> onMessage(
|
||||
String app,
|
||||
Object message,
|
||||
WebExtension.MessageSender sender
|
||||
) {
|
||||
if (message instanceof JSONObject) {
|
||||
if (sender.environmentType != WebExtension.MessageSender.ENV_TYPE_EXTENSION) {
|
||||
return GeckoResult.fromException(new SecurityException("EXTENSION_REQUIRED"));
|
||||
}
|
||||
JSONObject json = (JSONObject) message;
|
||||
if (json.optString("type").equals("marketplace.return") && activity!=null) {activity.runOnUiThread(()->activity.closeBrowser());return GeckoResult.fromValue(null);}
|
||||
if (json.optString("type").startsWith("packages.")) {
|
||||
GeckoResult<Object> result=new GeckoResult<>();
|
||||
new Thread(()->{try {
|
||||
String operation=json.getString("type");
|
||||
if(operation.equals("packages.save"))server.installed.save(json.getJSONObject("item"));
|
||||
else if(operation.equals("packages.remove"))server.installed.remove(json.getString("id"));
|
||||
else if(!operation.equals("packages.list"))throw new IOException("INVALID_OPERATION");
|
||||
result.complete(server.installed.list().toString());
|
||||
}catch(Exception error){result.completeExceptionally(error);}},"Vulpes-packages").start();
|
||||
return result;
|
||||
}
|
||||
if (json.optString("type").equals("homeScroll")) {
|
||||
homeAtBottom = json.optBoolean("bottom");
|
||||
if (activity != null) activity.setHomeSurface(homeVisible);
|
||||
return GeckoResult.fromValue(null);
|
||||
}
|
||||
if (json.optString("type").equals("navigation")) {
|
||||
homeVisible = json.optBoolean("home");
|
||||
if (activity != null) activity.setHomeSurface(homeVisible);
|
||||
return GeckoResult.fromValue(null);
|
||||
}
|
||||
if (json.optString("type").startsWith("android.")) return services
|
||||
.request(json)
|
||||
.map(value -> {
|
||||
try {
|
||||
return new JSONObject()
|
||||
.put("result", value == null ? JSONObject.NULL : value)
|
||||
.toString();
|
||||
} catch (org.json.JSONException e) {
|
||||
throw new IllegalArgumentException(e);
|
||||
}
|
||||
});
|
||||
if (json.optString("type").equals("open") && activity != null) activity.openBrowser(
|
||||
json.optString("url")
|
||||
);
|
||||
}
|
||||
return GeckoResult.fromValue(null);
|
||||
}
|
||||
},
|
||||
"vulpes"
|
||||
),
|
||||
error -> Log.e("Vulpes", "Extension failed", error)
|
||||
);
|
||||
}
|
||||
|
||||
void browserClosed() {
|
||||
if (port == null) return;
|
||||
try {
|
||||
port.postMessage(new JSONObject().put("type", "browser-closed"));
|
||||
} catch (Exception e) {
|
||||
Log.w("Vulpes", "Browser close notification", e);
|
||||
}
|
||||
}
|
||||
|
||||
void home(boolean held) {
|
||||
if (port == null) return;
|
||||
try {
|
||||
port.postMessage(new JSONObject().put("type", held ? "holdhome" : "home"));
|
||||
} catch (Exception e) {
|
||||
Log.e("Vulpes", "Home", e);
|
||||
}
|
||||
}
|
||||
|
||||
void resumed() {
|
||||
if (services != null) services.personal.observe();
|
||||
sendScreenLock();
|
||||
if (port == null) return;
|
||||
try {
|
||||
port.postMessage(new JSONObject().put("type", "android-resume"));
|
||||
} catch (Exception e) {
|
||||
Log.w("Vulpes", "Resume notification", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
android/app/src/main/res/drawable-nodpi/vulpes_logo.png
Normal file
BIN
android/app/src/main/res/drawable-nodpi/vulpes_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/launcher_background" />
|
||||
<foreground>
|
||||
<inset android:inset="20%">
|
||||
<bitmap android:src="@drawable/vulpes_logo" android:gravity="fill" android:filter="true" />
|
||||
</inset>
|
||||
</foreground>
|
||||
</adaptive-icon>
|
||||
5
android/app/src/main/res/values/strings.xml
Normal file
5
android/app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Vulpes OS - Preview</string>
|
||||
<color name="launcher_background">#10243A</color>
|
||||
</resources>
|
||||
26
android/build-apk.sh
Executable file
26
android/build-apk.sh
Executable file
|
|
@ -0,0 +1,26 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd -- "$(dirname -- "${BASH_SOURCE[0]}")"
|
||||
python3 tools/prepare-toolchain.py
|
||||
export JAVA_HOME="$PWD/toolchain/jdk-21.0.12.1+1"
|
||||
export ANDROID_HOME="$PWD/toolchain/sdk"
|
||||
export GRADLE_USER_HOME="$PWD/toolchain/gradle-cache"
|
||||
export PATH="$JAVA_HOME/bin:$PATH"
|
||||
mkdir -p keys
|
||||
if [[ ! -f keys/preview.keystore ]]; then
|
||||
keytool -genkeypair -keystore keys/preview.keystore -storepass android -keypass android -alias androiddebugkey -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=Vulpes Preview,O=Vulpes,C=FR"
|
||||
chmod 600 keys/preview.keystore
|
||||
fi
|
||||
python3 tools/pack-assets.py "$@"
|
||||
printf 'sdk.dir=%s\n' "$ANDROID_HOME" > local.properties
|
||||
./toolchain/gradle-9.5.0/bin/gradle --no-daemon :app:assembleDebug
|
||||
mkdir -p dist
|
||||
vulpes_version=$(python3 -c 'import sys; sys.path.insert(0,"../tools"); from project import release_info; print(release_info()["version"])')
|
||||
vulpes_apk="dist/vulpes-os-preview-${vulpes_version}.apk"
|
||||
"$ANDROID_HOME/build-tools/37.0.0/zipalign" -f -P 16 4 app/build/outputs/apk/debug/app-debug.apk "$vulpes_apk"
|
||||
"$ANDROID_HOME/build-tools/37.0.0/apksigner" sign --ks keys/preview.keystore --ks-pass pass:android --key-pass pass:android --ks-key-alias androiddebugkey "$vulpes_apk"
|
||||
"$ANDROID_HOME/build-tools/37.0.0/apksigner" verify --verbose --print-certs "$vulpes_apk" | tee "dist/signature-${vulpes_version}.txt"
|
||||
"$ANDROID_HOME/build-tools/37.0.0/zipalign" -c -P 16 -v 4 "$vulpes_apk" > dist/alignment.txt
|
||||
(cd dist && sha256sum "vulpes-os-preview-${vulpes_version}.apk" > SHA256SUMS)
|
||||
python3 ../tools/check-parity.py
|
||||
printf '\nAPK : %s/%s\n' "$PWD" "$vulpes_apk"
|
||||
1
android/build.gradle
Normal file
1
android/build.gradle
Normal file
|
|
@ -0,0 +1 @@
|
|||
plugins { id 'com.android.application' version '9.3.3' apply false }
|
||||
1
android/extension/background.html
Normal file
1
android/extension/background.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
<!doctype html><meta charset="utf-8"><script type="module" src="background.js"></script>
|
||||
549
android/extension/background.js
Normal file
549
android/extension/background.js
Normal file
|
|
@ -0,0 +1,549 @@
|
|||
import {AndroidPersonalData, authorizePersonal} from './personal-data.mjs';
|
||||
import {Input} from './services/input.mjs';
|
||||
import { Packages, packageURL } from './services/packages.mjs';
|
||||
import { Activities, isGaiaActivity } from './services/activities.mjs';
|
||||
import { authorizePlatform } from './services/platform.mjs';
|
||||
import { SettingsService } from './services/settings.mjs';
|
||||
import { DataStoreService } from './services/datastores.mjs';
|
||||
import { DataStoresIndexedDB } from './adapters/datastores-indexeddb.mjs';
|
||||
import { Contacts } from './Contacts.mjs';
|
||||
import { Messages } from './Messages.mjs';
|
||||
import { Connections } from './Connections.mjs';
|
||||
import { Alarms } from './Alarms.mjs';
|
||||
const peers = new Set();
|
||||
let activities, packages;
|
||||
const input = new Input({identity:peer=>peer.app,broadcast});
|
||||
let editSequence=0;
|
||||
const nativePackages=(operation,data={})=>browser.runtime.sendNativeMessage('vulpes',{type:'packages.'+operation,...data}).then(JSON.parse);
|
||||
async function refreshInstalled(){const installed=await nativePackages('list');apps.splice(0,apps.length,...apps.filter(a=>!a.installed),...installed);}
|
||||
let apps, values, settings, dataStores, contacts, messages, connections, alarms, storage, personal;
|
||||
const identity = (peer) => peer.app;
|
||||
function notify(peer, type, data) {
|
||||
try {
|
||||
peer.port.postMessage({ event: { type, data } });
|
||||
} catch (_) {
|
||||
peers.delete(peer);
|
||||
}
|
||||
}
|
||||
function grants(app) {
|
||||
const access = app.manifest.permissions?.settings?.access || '';
|
||||
return [
|
||||
access.includes('read') && 'settings.read',
|
||||
access.includes('write') && 'settings.write',
|
||||
].filter(Boolean);
|
||||
}
|
||||
function broadcast(type, data, target) {
|
||||
for (const peer of peers) {
|
||||
if (target && peer.app.id !== target) continue;
|
||||
if (type === 'settingchange' && !['language.current', 'vulpes.theme'].includes(data.key) && !grants(peer.app).includes('settings.read')) continue;
|
||||
if (
|
||||
type === 'contactchange' &&
|
||||
!peer.app.manifest.permissions?.contacts?.access?.includes('read')
|
||||
)
|
||||
continue;
|
||||
if (
|
||||
type === 'datastorechange' &&
|
||||
!dataStores.list(peer.app, data.name).some((s) => s.owner === data.owner)
|
||||
)
|
||||
continue;
|
||||
if (
|
||||
type === 'mediachange' &&
|
||||
!peer.app.manifest.permissions?.['device-storage:' + data.type]?.access?.includes('read')
|
||||
)
|
||||
continue;
|
||||
notify(peer, type, data);
|
||||
}
|
||||
}
|
||||
function launch(target, entryPoint, path, reload = false) {
|
||||
path ||=
|
||||
target.manifest.entry_points?.[entryPoint]?.launch_path ||
|
||||
target.manifest.launch_path ||
|
||||
'/index.html';
|
||||
const url = new URL(path, target.origin);
|
||||
if (url.origin !== target.origin) throw Error('INVALID_URL');
|
||||
broadcast(
|
||||
reload ? 'activity-launch' : 'launch',
|
||||
{
|
||||
manifestURL: target.manifestURL,
|
||||
origin: target.origin,
|
||||
url: url.href,
|
||||
entryPoint,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
'system',
|
||||
);
|
||||
}
|
||||
const ready = (async () => {
|
||||
apps = await (await fetch('registry.json')).json();
|
||||
await refreshInstalled();
|
||||
packages=new Packages({save:item=>nativePackages('save',{item}),changed:refreshInstalled});
|
||||
const defaults = await (await fetch('defaults.json')).json();
|
||||
const saved = await browser.storage.local.get('settings');
|
||||
values = { ...defaults, ...saved.settings };
|
||||
if (!values['vulpes.gaia-restoration.v1']) {
|
||||
values['lockscreen.enabled'] = true;
|
||||
values['vulpes.gaia-restoration.v1'] = true;
|
||||
await browser.storage.local.set({settings:values});
|
||||
}
|
||||
// Preview 1 serialized wallpaper Blobs as {}. Repair that one invalid value
|
||||
// on upgrade, retaining all other user settings and valid custom wallpapers.
|
||||
const wallpaper = values['wallpaper.image'];
|
||||
if (wallpaper && typeof wallpaper === 'object' && !Object.keys(wallpaper).length) {
|
||||
values['wallpaper.image'] = defaults['wallpaper.image'];
|
||||
values['wallpaper.image.valid'] = false;
|
||||
await browser.storage.local.set({ settings: values });
|
||||
}
|
||||
activities = new Activities({identity,apps:()=>apps,notify,uuid:()=>crypto.randomUUID(),
|
||||
choose:data=>broadcast('activity-choice',data,'system'),
|
||||
launch:(target,path,{reload=false,entryPoint}={})=>launch(target,entryPoint,path,reload)});
|
||||
let writes = Promise.resolve();
|
||||
settings = new SettingsService(
|
||||
{
|
||||
get: async (key) => values[key],
|
||||
setMany(entries) {
|
||||
const write = writes.then(async () => {
|
||||
const next = { ...values, ...Object.fromEntries(entries) };
|
||||
await browser.storage.local.set({ settings: next });
|
||||
values = next;
|
||||
for (const [key, value] of entries) {
|
||||
broadcast('settingchange', { key, value });
|
||||
if(key==='keyboard.gaia.enabled') {
|
||||
input.hide();broadcast('input-mode',{enabled:value===true});
|
||||
}
|
||||
}
|
||||
});
|
||||
writes = write.catch(() => {});
|
||||
return write;
|
||||
},
|
||||
},
|
||||
{ maxBytes: 32 * 1024 * 1024 },
|
||||
); // Allows the bounded base64 image envelope on high-DPI Android screens.
|
||||
storage = new DataStoresIndexedDB(indexedDB, IDBKeyRange, () => crypto.randomUUID());
|
||||
contacts = new Contacts(storage, (data) => broadcast('contactchange', data));
|
||||
await contacts.init();
|
||||
messages = new Messages(storage);
|
||||
personal = new AndroidPersonalData(params=>android("personal",params), contacts, messages, storage);
|
||||
dataStores = new DataStoreService(apps, storage, (data) => broadcast('datastorechange', data));
|
||||
connections = new Connections(identity, notify);
|
||||
alarms = new Alarms(
|
||||
identity,
|
||||
(id) => {
|
||||
const app = apps.find((a) => a.id === id);
|
||||
if (app) launch(app, undefined, app.manifest.messages?.find((m) => m.alarm)?.alarm);
|
||||
},
|
||||
{
|
||||
load: async () => (await browser.storage.local.get('alarms')).alarms,
|
||||
save: (state) => browser.storage.local.set({ alarms: state }),
|
||||
},
|
||||
(callback, delay) => setInterval(callback, delay),
|
||||
);
|
||||
await alarms.init();
|
||||
console.info('VULPES Android services ready');
|
||||
})();
|
||||
// Java owns this port. Web pages have no access to native messaging.
|
||||
let native;
|
||||
let activeWebView;
|
||||
let systemReady = false;
|
||||
let pendingScreenLock = false;
|
||||
function deliverScreenLock() {
|
||||
if (!systemReady || !pendingScreenLock) return;
|
||||
pendingScreenLock = false;
|
||||
broadcast('screen-off', {}, 'system');
|
||||
}
|
||||
let pendingNotification;
|
||||
function deliverNotification() {
|
||||
if (!systemReady || !pendingNotification) return;
|
||||
const data = pendingNotification;
|
||||
pendingNotification = null;
|
||||
const target = apps.find((a) => a.id === data.owner);
|
||||
if (target) {
|
||||
launch(target);
|
||||
broadcast('notification-click', data, target.id);
|
||||
}
|
||||
}
|
||||
function connectNative() {
|
||||
native = browser.runtime.connectNative('vulpes');
|
||||
native.onDisconnect.addListener(() => setTimeout(connectNative, 1000));
|
||||
native.onMessage.addListener(async (message) => {
|
||||
await ready;
|
||||
if (message.type === 'browser-closed' && activeWebView) {
|
||||
const {peer, contextId} = activeWebView;
|
||||
activeWebView = null;
|
||||
notify(peer, 'view', {contextId, type:'close', detail:{}});
|
||||
}
|
||||
if (['home', 'holdhome'].includes(message.type)) broadcast(message.type, {}, 'system');
|
||||
if (message.type === 'android-screen-off') {
|
||||
pendingScreenLock = true;
|
||||
deliverScreenLock();
|
||||
}
|
||||
if (message.type === 'android-data-changed') {
|
||||
broadcast('android-data-changed', {kind:message.kind}, message.kind === 'sms' ? 'sms' : 'communications');
|
||||
}
|
||||
if (message.type === 'android-resume') {
|
||||
deliverScreenLock();
|
||||
broadcast('screen-on', {}, 'system');
|
||||
await refreshAndroid().catch(console.warn);
|
||||
broadcast('android-data-changed', {kind:'contacts'}, 'communications');
|
||||
broadcast('android-data-changed', {kind:'calls'}, 'communications');
|
||||
broadcast('android-data-changed', {kind:'sms'}, 'sms');
|
||||
}
|
||||
if (message.type === 'notification-click') {
|
||||
pendingNotification = message.data;
|
||||
deliverNotification();
|
||||
}
|
||||
});
|
||||
}
|
||||
connectNative();
|
||||
async function android(type, params = {}) {
|
||||
const reply = await browser.runtime.sendNativeMessage('vulpes', {
|
||||
type: 'android.' + type,
|
||||
params,
|
||||
});
|
||||
const result = typeof reply === 'string' ? JSON.parse(reply).result : reply;
|
||||
if (result?.error) throw Error(result.error);
|
||||
return result;
|
||||
}
|
||||
async function refreshAndroid() {
|
||||
const snapshot = await android('snapshot');
|
||||
for (const [key, value] of Object.entries(snapshot)) {
|
||||
if (values[key] === value) continue;
|
||||
values[key] = value;
|
||||
broadcast('settingchange', { key, value });
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
ready.then(refreshAndroid).catch(console.warn);
|
||||
async function handle(peer, request) {
|
||||
const app = peer.app;
|
||||
if (
|
||||
!request ||
|
||||
request.version !== 1 ||
|
||||
!Number.isSafeInteger(request.id) ||
|
||||
request.id < 0 ||
|
||||
typeof request.method !== 'string'
|
||||
)
|
||||
throw Error('INVALID_REQUEST');
|
||||
const { params = {} } = request;
|
||||
let method = request.method;
|
||||
if (method.startsWith('platform.')) {
|
||||
authorizePlatform(app, method.slice(9), params);
|
||||
method = 'android.' + method.slice(9);
|
||||
}
|
||||
if (method === 'android.personal') {
|
||||
authorizePersonal(app, params.kind, params.operation);
|
||||
if (params.operation === 'page') return personal.rows(params.kind);
|
||||
if (!['status','permission','disconnect','open'].includes(params.operation)) throw Error('METHOD_NOT_SUPPORTED');
|
||||
return android('personal', params);
|
||||
}
|
||||
if (method === 'android.capabilities') {
|
||||
const release = await (await fetch('release.json')).json();
|
||||
const device = app.id === 'settings' ? await android('snapshot') : {};
|
||||
return {
|
||||
platform: 'android',
|
||||
platformName: device['deviceinfo.firmware_revision'] || 'Android',
|
||||
model: device['deviceinfo.product_model'],
|
||||
adapter: { version: release.adapters?.android, device: 'android' },
|
||||
version: release.version,
|
||||
gaia: release.gaia,
|
||||
gecko: release.androidEngine,
|
||||
camera: true,
|
||||
media: true,
|
||||
contacts: true,
|
||||
notifications: true,
|
||||
dial: true,
|
||||
sms: true,
|
||||
alarms: true,
|
||||
};
|
||||
}
|
||||
if (method === 'android.systemReady') {
|
||||
if (app.id !== 'system') throw Error('PERMISSION_DENIED');
|
||||
systemReady = true;
|
||||
deliverScreenLock();
|
||||
deliverNotification();
|
||||
return null;
|
||||
}
|
||||
if (method === 'android.notification') {
|
||||
if (!app.manifest.permissions?.['desktop-notification']) throw Error('PERMISSION_DENIED');
|
||||
if (JSON.stringify(params).length > 65536) throw Error('QuotaExceededError');
|
||||
return android('notification', { ...params, owner: app.id });
|
||||
}
|
||||
if (method === 'android.settings' || method === 'android.snapshot') {
|
||||
if (!['settings', 'system', 'costcontrol'].includes(app.id)) throw Error('PERMISSION_DENIED');
|
||||
return method === 'android.snapshot' ? refreshAndroid() : android('settings', params);
|
||||
}
|
||||
if (method === 'android.capture') {
|
||||
if (app.id !== 'camera') throw Error('PERMISSION_DENIED');
|
||||
const names = await android('capture');
|
||||
for (const path of names)
|
||||
broadcast('mediachange', { type: 'pictures', path, reason: 'modified' });
|
||||
return names;
|
||||
}
|
||||
if (method === 'android.pick') {
|
||||
const access = app.manifest.permissions?.['device-storage:' + params.type]?.access || '';
|
||||
if (!access.includes('write')) throw Error('PERMISSION_DENIED');
|
||||
const names = await android('pick', params);
|
||||
for (const path of names)
|
||||
broadcast('mediachange', { type: params.type, path, reason: 'modified' });
|
||||
return names;
|
||||
}
|
||||
if (method === 'android.alarms') {
|
||||
if (app.id !== 'clock') throw Error('PERMISSION_DENIED');
|
||||
return android('alarms');
|
||||
}
|
||||
if (method === 'android.contact') {
|
||||
if (!app.manifest.permissions?.contacts?.access?.includes('write'))
|
||||
throw Error('PERMISSION_DENIED');
|
||||
const contact = await android('contact');
|
||||
return contacts.request(app, { operation: 'save', contact });
|
||||
}
|
||||
if (method === 'android.sms') {
|
||||
if (app.id !== 'sms') throw Error('PERMISSION_DENIED');
|
||||
return android('sms', params);
|
||||
}
|
||||
if (method === 'android.dial') {
|
||||
if (app.id !== 'communications') throw Error('PERMISSION_DENIED');
|
||||
return android('dial', params);
|
||||
}
|
||||
if (method === 'settings.set' && params.values && typeof params.values === 'object') {
|
||||
const validation = await new SettingsService(
|
||||
{ setMany: async () => {} },
|
||||
{ maxBytes: 32 * 1024 * 1024 },
|
||||
).bindSession(
|
||||
app.id,
|
||||
grants(app),
|
||||
)(request);
|
||||
if (validation.error) throw Error(validation.error.code);
|
||||
for (const [key, value] of Object.entries(params.values)) {
|
||||
if (app.id === 'settings' && ['screen.brightness', 'audio.volume.content'].includes(key))
|
||||
await android('set', { key, value });
|
||||
const panel = {
|
||||
'wifi.enabled': 'wifi',
|
||||
'bluetooth.enabled': 'bluetooth',
|
||||
'airplaneMode.enabled': 'airplane-mode',
|
||||
'geolocation.enabled': 'geolocation',
|
||||
'ril.data.enabled': 'data',
|
||||
}[key];
|
||||
if (panel) {
|
||||
// UI clicks open the native panel. Gaia startup writes must not open Android activities.
|
||||
// Android controls these values; never persist a fictitious toggle state.
|
||||
delete params.values[key];
|
||||
}
|
||||
}
|
||||
if (!Object.keys(params.values).length) return null;
|
||||
}
|
||||
if (method === 'input.config') return values['keyboard.gaia.enabled'] === true;
|
||||
if (method === 'input.request') return input.request(peer,params);
|
||||
if (method === 'appearance.current') return values['vulpes.theme'] || 'light';
|
||||
if (method === 'locale.current') return values['language.current'] || 'en-US';
|
||||
if (method === 'settings.snapshot') {
|
||||
if (!grants(app).includes('settings.read')) throw Error('PERMISSION_DENIED');
|
||||
return { ...values };
|
||||
}
|
||||
if (method.startsWith('settings.')) {
|
||||
const reply = await settings.bindSession(app.id, grants(app))(request);
|
||||
if (reply.error) throw Error(reply.error.code);
|
||||
return reply.result;
|
||||
}
|
||||
if(method.startsWith('marketplace.')) {
|
||||
if(app.id!=='marketplace')throw Error('PERMISSION_DENIED');
|
||||
if(method==='marketplace.poll'){const url=marketplaceDownload;marketplaceDownload=null;return url;}
|
||||
if(method==='marketplace.cancel'){packages.cancel();return true;}
|
||||
if(method==='marketplace.prepare')return packages.prepare(params.url);
|
||||
if(method==='marketplace.install'){const id=await packages.install(params.token);const installed=apps.find(a=>a.id===id);broadcast('apps-installed',installed);return installed;}
|
||||
throw Error('METHOD_NOT_SUPPORTED');
|
||||
}
|
||||
if (method === 'apps.self') {
|
||||
console.info('VULPES app ready', app.id);
|
||||
return app;
|
||||
}
|
||||
const manage =
|
||||
app.manifest.permissions?.['webapps-manage'] ||
|
||||
app.manifest.permissions?.['homescreen-webapps-manage'];
|
||||
if (method === 'apps.list') {
|
||||
if (!manage) throw Error('PERMISSION_DENIED');
|
||||
return apps;
|
||||
}
|
||||
if(method==='apps.uninstall') {
|
||||
const installed=apps.find(a=>a.id===params.id && a.installed);if(!manage || !installed)throw Error('PERMISSION_DENIED');
|
||||
await nativePackages('remove',{id:params.id});await refreshInstalled();broadcast('apps-uninstalled',installed);return true;
|
||||
}
|
||||
if (method === 'apps.launch') {
|
||||
const target = apps.find((a) => a.manifestURL === params.manifestURL);
|
||||
if (!target || (!manage && app.id !== target.id)) throw Error('PERMISSION_DENIED');
|
||||
launch(target, params.entryPoint);
|
||||
return null;
|
||||
}
|
||||
if (method === 'datastores.list') return dataStores.list(app, params.name);
|
||||
if (method === 'datastores.run') return dataStores.run(app, params);
|
||||
if (method === 'contacts.request') return personal.contactRequest(app, params);
|
||||
if (method === 'messages.request') return personal.messageRequest(app, params);
|
||||
if (method === 'alarms.request') return alarms.request(peer, app, params);
|
||||
if (method === 'iac.subscribe') {
|
||||
connections.subscribe(peer);
|
||||
return null;
|
||||
}
|
||||
if (method === 'iac.connect') return connections.connect(peer, params.keyword, apps);
|
||||
if (method === 'iac.send') return connections.send(peer, params);
|
||||
if (method === 'iac.close') {
|
||||
connections.close(peer, params.id);
|
||||
return null;
|
||||
}
|
||||
if (method === 'activities.cancel') return activities.cancel(peer);
|
||||
if (method === 'activities.select') return activities.select(peer,params);
|
||||
if (method === 'activities.subscribe') return activities.subscribe(peer);
|
||||
if (method === 'activities.finish') return activities.finish(peer,params);
|
||||
if (method === 'activities.start' && isGaiaActivity(params)) return activities.start(peer,params);
|
||||
if (method === 'activities.start') {
|
||||
if (params.name === 'record' && params.data?.type === 'photos') {
|
||||
if (['gallery', 'system'].includes(app.id)) {
|
||||
const camera = apps.find((a) => a.id === 'camera');
|
||||
if (!camera) throw Error('ACTIVITY_NOT_SUPPORTED');
|
||||
launch(camera);
|
||||
return null;
|
||||
}
|
||||
if (!app.manifest.permissions?.['device-storage:pictures']?.access?.includes('write'))
|
||||
throw Error('PERMISSION_DENIED');
|
||||
const names = await android('capture');
|
||||
for (const path of names)
|
||||
broadcast('mediachange', { type: 'pictures', path, reason: 'modified' });
|
||||
return { filenames: names };
|
||||
}
|
||||
if (params.name !== 'view' || params.data?.type !== 'url')
|
||||
throw Error('ACTIVITY_NOT_SUPPORTED');
|
||||
return openWeb(params.data.url);
|
||||
}
|
||||
if (method === 'views.request') {
|
||||
if (!app.manifest.permissions?.browser && !app.manifest.permissions?.['embed-apps'])
|
||||
throw Error('PERMISSION_DENIED');
|
||||
if (params.operation === 'open') {
|
||||
await openWeb(params.url);
|
||||
activeWebView = {peer, contextId:params.contextId};
|
||||
notify(peer, 'view', {
|
||||
contextId: params.contextId,
|
||||
type: 'loadend',
|
||||
detail: { backgroundColor: 'white' },
|
||||
});
|
||||
return null;
|
||||
}
|
||||
if (['geometry', 'cancelTouch', 'close'].includes(params.operation)) return null;
|
||||
if (['canGoBack', 'canGoForward'].includes(params.operation)) return false;
|
||||
throw Error('NotSupportedError');
|
||||
}
|
||||
if (method === 'desktop.idle') {
|
||||
if (!app.manifest.permissions?.idle) throw Error('PERMISSION_DENIED');
|
||||
return null;
|
||||
}
|
||||
if (method === 'desktop.homeScroll') {
|
||||
if (app.id !== 'homescreen' || typeof params?.bottom !== 'boolean') throw Error('PERMISSION_DENIED');
|
||||
return browser.runtime.sendNativeMessage('vulpes', { type: 'homeScroll', bottom: params.bottom });
|
||||
}
|
||||
if (method === 'desktop.navigation') {
|
||||
if (app.id !== 'system' || typeof params?.home !== 'boolean') throw Error('PERMISSION_DENIED');
|
||||
return browser.runtime.sendNativeMessage('vulpes', { type: 'navigation', home: params.home });
|
||||
}
|
||||
if (method === 'desktop.screen') {
|
||||
if (!app.manifest.permissions?.power) throw Error('PERMISSION_DENIED');
|
||||
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');
|
||||
}
|
||||
async function openWeb(url) {
|
||||
const parsed = new URL(url);
|
||||
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.hostname.endsWith('.localhost'))
|
||||
throw Error('URL_SCHEME_DENIED');
|
||||
return browser.runtime.sendNativeMessage('vulpes', { type: 'open', url: parsed.href });
|
||||
}
|
||||
async function media(app, p) {
|
||||
const access = app.manifest.permissions?.['device-storage:' + p.type]?.access || '';
|
||||
if (
|
||||
!['pictures', 'videos', 'music'].includes(p.type) ||
|
||||
!access.includes(['add', 'delete'].includes(p.operation) ? 'write' : 'read')
|
||||
)
|
||||
throw Error('PERMISSION_DENIED');
|
||||
const result = await android('media', p);
|
||||
if (['add', 'delete'].includes(p.operation))
|
||||
broadcast('mediachange', {
|
||||
type: p.type,
|
||||
path: result,
|
||||
reason: p.operation === 'add' ? 'modified' : 'deleted',
|
||||
});
|
||||
return result;
|
||||
}
|
||||
browser.runtime.onConnect.addListener((port) => {
|
||||
if (port.name !== 'gaia') return;
|
||||
let peer;
|
||||
const attached = ready.then(() => {
|
||||
const url = new URL(port.sender.url);
|
||||
const app = apps.find((a) => a.origin === url.origin);
|
||||
if (!app) throw Error('PERMISSION_DENIED');
|
||||
peer = {
|
||||
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);
|
||||
});
|
||||
port.onMessage.addListener(async (message) => {
|
||||
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' } };
|
||||
console.warn('VULPES RPC', message.request?.method, e.message);
|
||||
}
|
||||
try {
|
||||
port.postMessage({ id: message.id, reply });
|
||||
} catch (_) {}
|
||||
});
|
||||
port.onDisconnect.addListener(() => {
|
||||
attached
|
||||
.then(() => {
|
||||
peers.delete(peer);
|
||||
if (activeWebView?.peer === peer) activeWebView = null;
|
||||
input.cleanup(peer);
|
||||
for(const edit of peer.edits.values()) {clearTimeout(edit.timer);edit.reject(Error('INPUT_CONTEXT_EXPIRED'));}
|
||||
peer.edits.clear();
|
||||
if (peer.app.id === 'system' && ![...peers].some((p) => p.app.id === 'system'))
|
||||
systemReady = false;
|
||||
activities.cleanup(peer);
|
||||
connections.cleanup(peer);
|
||||
alarms.cleanup(peer);
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
let marketplaceDownload=null;
|
||||
browser.runtime.onMessage.addListener(async (message,sender)=>{
|
||||
if(!message.marketplaceDownload)return;
|
||||
const origin=new URL(sender.url);
|
||||
if(origin.origin!=='https://vulpes-os.org' || !origin.pathname.startsWith('/marketplace/'))throw Error('PERMISSION_DENIED');
|
||||
await ready;marketplaceDownload=packageURL(message.marketplaceDownload);
|
||||
const app=apps.find(a=>a.id==='marketplace');launch(app);
|
||||
await browser.runtime.sendNativeMessage('vulpes',{type:'marketplace.return'});
|
||||
return true;
|
||||
});
|
||||
147
android/extension/content.js
Normal file
147
android/extension/content.js
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
/* Extension world: the browser provides origin/tab/frame identity, never Gaia. */
|
||||
(() => {
|
||||
if (location.port !== '18765' || location.hostname.startsWith('user-')) return;
|
||||
const win = window.wrappedJSObject;
|
||||
console.info('VULPES Android bridge', location.origin);
|
||||
let port;
|
||||
let reconnect;
|
||||
function connect() {
|
||||
port = browser.runtime.connect({ name: 'gaia' });
|
||||
port.onMessage.addListener(onMessage);
|
||||
port.onDisconnect.addListener(onDisconnect);
|
||||
}
|
||||
const pending = new Map();
|
||||
let seq = 0;
|
||||
function request(data) {
|
||||
return new window.Promise((resolve, reject) => {
|
||||
const id = ++seq;
|
||||
pending.set(id, { resolve, reject });
|
||||
try {
|
||||
port.postMessage({ id, request: JSON.parse(JSON.stringify(data)) });
|
||||
} catch (error) {
|
||||
pending.delete(id);
|
||||
reject(new window.Error('VULPES_HOST_CLOSED'));
|
||||
}
|
||||
});
|
||||
}
|
||||
function onMessage(message) {
|
||||
if (message.edit) {
|
||||
try {
|
||||
const result=editor.edit(message.edit.params);
|
||||
result.inputMode=originalModes.get(editor.inputElement) ||
|
||||
(editor.inputElement.classList.contains('recipient')?'tel':'');
|
||||
port.postMessage({editReply:message.edit.id, result});
|
||||
}
|
||||
catch (error) { port.postMessage({editReply:message.edit.id, error:String(error.message)}); }
|
||||
return;
|
||||
}
|
||||
if (message.event) {
|
||||
if(message.event.type==='input-mode') {
|
||||
inputConfigurationRevision++;
|
||||
configureInput(message.event.data.enabled===true);
|
||||
}
|
||||
win.dispatchEvent(
|
||||
new window.CustomEvent('vulpes-service-event', { detail: cloneInto(message.event, win) }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const callback = pending.get(message.id);
|
||||
if (!callback) return;
|
||||
pending.delete(message.id);
|
||||
callback.resolve(cloneInto(message.reply, win));
|
||||
}
|
||||
function onDisconnect() {
|
||||
inputConfigurationRevision++;
|
||||
configureInput(false);
|
||||
for (const callback of pending.values())
|
||||
callback.reject(new window.Error('VULPES_HOST_CLOSED'));
|
||||
pending.clear();
|
||||
clearTimeout(reconnect);
|
||||
reconnect = setTimeout(() => {
|
||||
try {
|
||||
connect();
|
||||
refreshInputConfiguration();
|
||||
} catch (_) {
|
||||
onDisconnect();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
const editor = new InputEditor(window);
|
||||
let gaiaKeyboard = false;
|
||||
let inputConfigurationRevision = 0;
|
||||
const originalModes = new WeakMap();
|
||||
function restoreMode() {
|
||||
const field = editor.inputElement;
|
||||
if (!field || !originalModes.has(field)) return;
|
||||
const value = originalModes.get(field);
|
||||
if (value === null) field.removeAttribute('inputmode');
|
||||
else field.setAttribute('inputmode', value);
|
||||
originalModes.delete(field);
|
||||
}
|
||||
function configureInput(enabled) {
|
||||
gaiaKeyboard = enabled;
|
||||
if(!enabled) restoreMode();
|
||||
win.VulpesNativeInput = gaiaKeyboard;
|
||||
win.dispatchEvent(new window.Event('vulpes-input-configured'));
|
||||
if(enabled && !document.hidden && document.hasFocus()) reportFocus(document.activeElement);
|
||||
}
|
||||
function refreshInputConfiguration() {
|
||||
const revision = inputConfigurationRevision;
|
||||
request({version:1,id:0,method:'input.config',params:{}}).then(reply => {
|
||||
if(revision===inputConfigurationRevision) configureInput(reply.result===true);
|
||||
}).catch(error => console.error('Gaia input configuration:', error));
|
||||
}
|
||||
function reportFocus(element) {
|
||||
if (!gaiaKeyboard || editor.editing || location.hostname === 'keyboard.localhost') return false;
|
||||
if (element?.isContentEditable) element=element.closest('[contenteditable=true]') || element;
|
||||
const editable = element?.isContentEditable ||
|
||||
(['input','textarea'].includes(element?.localName) &&
|
||||
!['button','submit','range','checkbox','radio','date','time','datetime-local','file'].includes(element.type));
|
||||
if (!editable || element.disabled || element.readOnly) {
|
||||
return false;
|
||||
}
|
||||
if (element !== editor.inputElement) restoreMode();
|
||||
editor.focus(element);
|
||||
if (!originalModes.has(element)) originalModes.set(element, element.getAttribute('inputmode'));
|
||||
// Suppress Android's IME only in this editor; external web pages keep it.
|
||||
element.setAttribute('inputmode', 'none');
|
||||
const state=editor.state();
|
||||
state.inputMode=originalModes.get(element) || (element.classList.contains('recipient')?'tel':'');
|
||||
port.postMessage({inputFocus:state});
|
||||
return true;
|
||||
}
|
||||
connect();
|
||||
refreshInputConfiguration();
|
||||
for (const type of ['focusin', 'pointerdown', 'click']) addEventListener(type, event => {
|
||||
if (!gaiaKeyboard || editor.editing || location.hostname === 'keyboard.localhost') return;
|
||||
if (!reportFocus(event.target) && type === 'click' &&
|
||||
!event.target?.closest?.('[data-vulpes-keep-input]')) port.postMessage({inputFocus:null});
|
||||
}, true);
|
||||
addEventListener('visibilitychange', () => {
|
||||
if (document.hidden && gaiaKeyboard) { restoreMode(); port.postMessage({inputFocus:null}); }
|
||||
else if (gaiaKeyboard && document.hasFocus()) reportFocus(document.activeElement);
|
||||
});
|
||||
addEventListener('unload', () => clearTimeout(reconnect));
|
||||
exportFunction(request, win.navigator, { defineAs: 'vulpesRequest' });
|
||||
const frames = new Map();
|
||||
let nextFrame = 0;
|
||||
exportFunction(
|
||||
(frame, data) => {
|
||||
if (frame.ownerDocument !== window.document || frame.localName !== 'iframe')
|
||||
throw new window.Error('INVALID_VIEW');
|
||||
let id = [...frames].find(([, f]) => f === frame)?.[0];
|
||||
if (!id) {
|
||||
id = ++nextFrame;
|
||||
frames.set(id, frame);
|
||||
}
|
||||
const params = JSON.parse(JSON.stringify(data));
|
||||
params.contextId = id;
|
||||
return request({ version: 1, id: 0, method: 'views.request', params });
|
||||
},
|
||||
win.navigator,
|
||||
{ defineAs: 'vulpesFrameRequest', allowCrossOriginArguments: true },
|
||||
);
|
||||
exportFunction((id) => frames.get(id) || null, win.navigator, {
|
||||
defineAs: 'vulpesFrameForContext',
|
||||
});
|
||||
})();
|
||||
46
android/extension/manifest.json
Normal file
46
android/extension/manifest.json
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
{
|
||||
"manifest_version": 2,
|
||||
"name": "Vulpes Android host",
|
||||
"version": "1.1",
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "android-host@vulpes-os.org"
|
||||
}
|
||||
},
|
||||
"permissions": [
|
||||
"storage",
|
||||
"unlimitedStorage",
|
||||
"nativeMessaging",
|
||||
"geckoViewAddons",
|
||||
"http://*.localhost/*",
|
||||
"https://vulpes-os.org/*"
|
||||
],
|
||||
"background": {
|
||||
"page": "background.html",
|
||||
"persistent": true
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": [
|
||||
"http://*.localhost/*"
|
||||
],
|
||||
"js": [
|
||||
"input-editor.js",
|
||||
"content.js"
|
||||
],
|
||||
"all_frames": true,
|
||||
"run_at": "document_start"
|
||||
},
|
||||
{
|
||||
"matches": [
|
||||
"https://vulpes-os.org/marketplace/*"
|
||||
],
|
||||
"js": [
|
||||
"marketplace-page.js",
|
||||
"marketplace-content.js"
|
||||
],
|
||||
"all_frames": true,
|
||||
"run_at": "document_end"
|
||||
}
|
||||
]
|
||||
}
|
||||
1
android/extension/marketplace-content.js
Normal file
1
android/extension/marketplace-content.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
installMarketplacePage(window,url=>browser.runtime.sendMessage({marketplaceDownload:url}).catch(console.error));
|
||||
88
android/extension/personal-data.mjs
Normal file
88
android/extension/personal-data.mjs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import {Contacts} from './Contacts.mjs';
|
||||
import {Messages} from './Messages.mjs';
|
||||
|
||||
export function authorizePersonal(app, kind, operation) {
|
||||
if (app.installed || !['settings', 'communications', 'sms', 'system'].includes(app.id))
|
||||
throw Error('PERMISSION_DENIED');
|
||||
if (operation === 'status' && app.id === 'settings') return;
|
||||
if (!['contacts', 'sms', 'calls'].includes(kind)) throw Error('INVALID_DATA_KIND');
|
||||
if (['permission', 'disconnect'].includes(operation)) {
|
||||
if (app.id !== 'settings') throw Error('PERMISSION_DENIED');
|
||||
return;
|
||||
}
|
||||
if (kind === 'contacts' && !app.manifest.permissions?.contacts?.access?.includes('read')) throw Error('PERMISSION_DENIED');
|
||||
if (kind === 'sms' && app.id !== 'sms') throw Error('PERMISSION_DENIED');
|
||||
if (kind === 'calls' && app.id !== 'communications') throw Error('PERMISSION_DENIED');
|
||||
}
|
||||
|
||||
export function contactFromAndroid(row) {
|
||||
return {id:'android:'+row._id, name:[row.display_name || ''], givenName:row.givenName || [],
|
||||
familyName:row.familyName || [], tel:row.tel || [], email:row.email || [],
|
||||
category:row.starred ? ['favorite'] : [], updated:new Date(row.contact_last_updated_timestamp || 0).toISOString()};
|
||||
}
|
||||
export function smsFromAndroid(row) {
|
||||
// Negative IDs keep Android provider rows separate from existing Vulpes messages.
|
||||
if (![row._id, row.thread_id].every(n => Number.isSafeInteger(n) && n > 0)) throw Error('INVALID_ANDROID_ID');
|
||||
const incoming = row.type === 1;
|
||||
return {id:-row._id, threadId:-row.thread_id, type:'sms',
|
||||
sender:incoming ? row.address : null, receiver:incoming ? null : row.address,
|
||||
body:row.body || '', timestamp:row.date, read:!!row.read,
|
||||
delivery:incoming ? 'received' : row.type === 2 ? 'sent' : row.type === 5 ? 'error' : 'sending',
|
||||
deliveryStatus:row.status === 0 ? 'success' : row.status >= 64 ? 'error' : 'not-applicable'};
|
||||
}
|
||||
|
||||
export class AndroidPersonalData {
|
||||
constructor(native, contacts, messages, storage) {
|
||||
Object.assign(this, {native, contacts, messages, storage});
|
||||
}
|
||||
async rows(kind) {
|
||||
const status = await this.native({operation:'status'});
|
||||
if (!status[kind]) return [];
|
||||
const rows = [];
|
||||
let after = 0;
|
||||
do {
|
||||
const page = await this.native({operation:'page', kind, after});
|
||||
rows.push(...page.rows);
|
||||
if (rows.length > 100000) throw Error('ANDROID_DATA_TOO_LARGE');
|
||||
if (page.next === null) break;
|
||||
if (!Number.isSafeInteger(page.next) || page.next <= after) throw Error('INVALID_CURSOR');
|
||||
after = page.next;
|
||||
} while (true);
|
||||
return rows;
|
||||
}
|
||||
async contactRequest(app, params) {
|
||||
const write = ['save', 'remove', 'clear'].includes(params.operation);
|
||||
const access = app.manifest.permissions?.contacts?.access || '';
|
||||
if (!access.includes(write ? 'write' : 'read')) throw Error('PERMISSION_DENIED');
|
||||
// Third-party apps keep the local address book and never inherit Android's grant.
|
||||
if (app.installed || !['communications','sms','system'].includes(app.id)) return this.contacts.request(app, params);
|
||||
if (write) {
|
||||
if (String(params.contact?.id || params.id || '').startsWith('android:') ||
|
||||
params.operation === 'clear' && (await this.native({operation:'status'})).contacts)
|
||||
throw Error('ANDROID_DATA_READ_ONLY');
|
||||
return this.contacts.request(app, params);
|
||||
}
|
||||
const remote = (await this.rows('contacts')).map(contactFromAndroid);
|
||||
const local = await this.contacts.request(app, {operation:'all'});
|
||||
const view = new Contacts(null, ()=>{});
|
||||
let revision = this.contacts.data.revision;
|
||||
for (const c of remote) for (const character of JSON.stringify(c)) revision = (revision * 31 + character.charCodeAt(0)) >>> 0;
|
||||
view.data = {revision, contacts:[...local, ...remote]};
|
||||
return view.request(app, params);
|
||||
}
|
||||
async messageRequest(app, params) {
|
||||
if (!app.manifest.permissions?.sms) throw Error('PERMISSION_DENIED');
|
||||
if (app.installed || app.id !== 'sms') return this.messages.request(app, params);
|
||||
const {operation, args=[]} = params;
|
||||
if (['delete','markMessageRead'].includes(operation) && [args[0]].flat().some(id => id < 0))
|
||||
throw Error('ANDROID_DATA_READ_ONLY');
|
||||
if (!['getMessages','getThreads','getMessage'].includes(operation)) return this.messages.request(app, params);
|
||||
const remote = (await this.rows('sms')).filter(row => row.type !== 3).map(smsFromAndroid);
|
||||
const local = await this.messages.request(app, {operation:'getMessages'});
|
||||
const view = new Messages({run:async(_store, op)=>{
|
||||
if (op !== 'get') throw Error('ANDROID_DATA_READ_ONLY');
|
||||
return {value:[...local, ...remote]};
|
||||
}});
|
||||
return view.request(app, params);
|
||||
}
|
||||
}
|
||||
3
android/gradle.properties
Normal file
3
android/gradle.properties
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8
|
||||
org.gradle.workers.max=4
|
||||
android.useAndroidX=true
|
||||
7
android/settings.gradle
Normal file
7
android/settings.gradle
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } }
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories { google(); mavenCentral(); maven { url = uri('https://maven.mozilla.org/maven2/') } }
|
||||
}
|
||||
rootProject.name = 'VulpesPreview'
|
||||
include ':app'
|
||||
67
android/tests/settings-codec.test.mjs
Normal file
67
android/tests/settings-codec.test.mjs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import vm from 'node:vm';
|
||||
import { SettingsService } from '../../services/settings.mjs';
|
||||
|
||||
const context = vm.createContext({ window: {}, Blob, Uint8Array, btoa, atob });
|
||||
vm.runInContext(
|
||||
await readFile(new URL('../../host/settings-codec.js', import.meta.url), 'utf8'),
|
||||
context,
|
||||
);
|
||||
const { encode, decode } = context.window.VulpesSettingsCodec;
|
||||
// A binary wallpaper larger than the original 1 MiB JSON ceiling, including
|
||||
// every byte value. Exercise the actual service, JSON storage and observer path.
|
||||
const pixels = Uint8Array.from({ length: 1200000 }, (_, i) => i % 256);
|
||||
const image = new Blob([pixels], { type: 'image/png' });
|
||||
let stored;
|
||||
const backend = {
|
||||
get: async (key) => stored[key],
|
||||
setMany: async (entries) => {
|
||||
stored = JSON.parse(JSON.stringify(Object.fromEntries(entries)));
|
||||
},
|
||||
};
|
||||
const payload = JSON.parse(
|
||||
JSON.stringify(await encode({ 'wallpaper.image': image, 'wallpaper.image.valid': true })),
|
||||
);
|
||||
const request = { version: 1, id: 1, method: 'settings.set', params: { values: payload } };
|
||||
const service = new SettingsService(backend, { maxBytes: 32 * 1024 * 1024 });
|
||||
const session = service.bindSession('system', ['settings.read', 'settings.write']);
|
||||
assert.equal((await session(request)).result.committed, true);
|
||||
const reply = await session({
|
||||
version: 1,
|
||||
id: 2,
|
||||
method: 'settings.get',
|
||||
params: { key: 'wallpaper.image' },
|
||||
});
|
||||
for (const value of [
|
||||
decode(reply.result).value,
|
||||
decode(stored)['wallpaper.image'],
|
||||
decode(stored['wallpaper.image']),
|
||||
]) {
|
||||
assert.ok(value instanceof Blob);
|
||||
assert.equal(value.type, image.type);
|
||||
assert.equal(value.size, image.size); // Gaia's savedBlobSize loop guard now works.
|
||||
assert.deepEqual(new Uint8Array(await value.arrayBuffer()), pixels);
|
||||
}
|
||||
assert.equal(
|
||||
(await service.bindSession('unprivileged', [])(request)).error.code,
|
||||
'PERMISSION_DENIED',
|
||||
);
|
||||
assert.equal(
|
||||
(await new SettingsService(backend).bindSession('desktop', ['settings.write'])(request)).error
|
||||
.code,
|
||||
'VALUE_TOO_LARGE',
|
||||
);
|
||||
assert.equal(
|
||||
JSON.stringify(
|
||||
decode(await encode({ language: 'fr', enabled: true, unset: null, list: [1, 'x'] })),
|
||||
),
|
||||
JSON.stringify({ language: 'fr', enabled: true, unset: null, list: [1, 'x'] }),
|
||||
);
|
||||
await assert.rejects(
|
||||
encode(new Blob([new Uint8Array(16 * 1024 * 1024 + 1)])),
|
||||
/SETTING_BLOB_TOO_LARGE/,
|
||||
);
|
||||
console.log(
|
||||
'PASS: binary settings survive JSON persistence, reads and observers; limits and permissions retained.',
|
||||
);
|
||||
29
android/toolchain-lock.json
Normal file
29
android/toolchain-lock.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"jdk": "Temurin 21.0.12.1+1",
|
||||
"gradle": "9.5.0",
|
||||
"androidGradlePlugin": "9.3.3",
|
||||
"geckoview": "156.0.20260921121718",
|
||||
"sdk": [
|
||||
"platforms;android-37.1",
|
||||
"build-tools;37.0.0"
|
||||
],
|
||||
"archives": {
|
||||
"jdk.tar.gz": {
|
||||
"sha256": "ce79869e1307ed8ee1e2baa86a412b1eb5b75d10a01006d788a6f968bcfaee94",
|
||||
"url": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jdk_x64_linux_hotspot_21.0.12.1_1.tar.gz"
|
||||
},
|
||||
"gradle-9.5.zip": {
|
||||
"sha256": "553c78f50dafcd54d65b9a444649057857469edf836431389695608536d6b746",
|
||||
"url": "https://services.gradle.org/distributions/gradle-9.5.0-bin.zip"
|
||||
},
|
||||
"geckoview.aar": {
|
||||
"sha256": "42f80fcccbbe72c17f67bc7be3044cae097e4cb61a9084b406ec935f66e8edca",
|
||||
"url": "https://maven.mozilla.org/maven2/org/mozilla/geckoview/geckoview/156.0.20260921121718/geckoview-156.0.20260921121718.aar"
|
||||
},
|
||||
"commandlinetools.zip": {
|
||||
"url": "https://dl.google.com/android/repository/commandlinetools-linux-13114758_latest.zip",
|
||||
"sha1": "5fdcc763663eefb86a5b8879697aa6088b041e70",
|
||||
"checksumSource": "https://dl.google.com/android/repository/repository2-1.xml"
|
||||
}
|
||||
}
|
||||
}
|
||||
110
android/tools/check-bridge.py
Normal file
110
android/tools/check-bridge.py
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
"""Exercise alarms and Android permission boundaries in the packaged bridge."""
|
||||
|
||||
import functools
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
|
||||
from pathlib import Path
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
# This harness mocks extension storage/native messaging, not the Gaia service code.
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
class Handler(SimpleHTTPRequestHandler):
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/test.html":
|
||||
body = b'<!doctype html><html><head><script type="module" src="/background.js"></script></head><body>Bridge test</body></html>'
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
else:
|
||||
super().do_GET()
|
||||
|
||||
|
||||
server = ThreadingHTTPServer(
|
||||
("127.0.0.1", 0),
|
||||
functools.partial(Handler, directory=str(root / "android/app/src/main/assets/bridge")),
|
||||
)
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
b = p.chromium.launch(executable_path=os.environ.get("PLAYWRIGHT_CHROMIUM_EXECUTABLE"))
|
||||
page = b.new_page()
|
||||
messages = []
|
||||
errors = []
|
||||
page.on("console", lambda msg: messages.append(msg.text))
|
||||
page.on("pageerror", lambda e: errors.append(str(e)))
|
||||
page.add_init_script(
|
||||
"""
|
||||
const values={};window.nativeCalls=[];window.events=[];
|
||||
window.browser={storage:{local:{get:async key=>({[key]:structuredClone(values[key])}),set:async data=>Object.assign(values,structuredClone(data))}},runtime:{
|
||||
connectNative:()=>({onMessage:{addListener(fn){window.nativeEvent=fn;}},onDisconnect:{addListener(){}},postMessage(){}}),
|
||||
sendNativeMessage:async(app,message)=>{nativeCalls.push(message);return message.type==='android.snapshot'?{}:null;},onConnect:{addListener(fn){window.connectPort=fn;}}
|
||||
}};
|
||||
window.makePort=url=>{
|
||||
let receive,seq=0;const waiting=new Map();
|
||||
connectPort({name:'gaia',sender:{url},onMessage:{addListener(fn){receive=fn;}},onDisconnect:{addListener(){}},postMessage(msg){if(msg.event)events.push({url,...msg.event});if(waiting.has(msg.id)){waiting.get(msg.id)(msg.reply);waiting.delete(msg.id);}}});
|
||||
return (method,params)=>new Promise(resolve=>{const id=++seq;waiting.set(id,resolve);receive({id,request:{version:1,id,method,params}});});
|
||||
};
|
||||
"""
|
||||
)
|
||||
page.goto(f"http://127.0.0.1:{server.server_port}/test.html")
|
||||
page.wait_for_function('typeof connectPort === "function"')
|
||||
results = page.evaluate(
|
||||
"""async()=>{
|
||||
const clock=makePort('http://clock.localhost:18765/onring.html');
|
||||
const before=await clock('alarms.request',{operation:'getAll'});
|
||||
if(before.error)throw Error(JSON.stringify(before));
|
||||
const add=await clock('alarms.request',{operation:'add',date:Date.now()+60000,respectTimezone:'honorTimezone',local:[2026,8,23,9,0,0,0],data:{label:'test'}});
|
||||
if(add.error)throw Error(JSON.stringify(add));
|
||||
const list=await clock('alarms.request',{operation:'getAll'});
|
||||
await clock('alarms.request',{operation:'remove',id:add.result});
|
||||
const empty=await clock('alarms.request',{operation:'getAll'});
|
||||
const gallery=makePort('http://gallery.localhost:18765/index.html');
|
||||
const denied=await gallery('android.settings',{panel:'wifi'});
|
||||
const cameraDenied=await gallery('android.capture',{});
|
||||
const settingsDenied=await gallery('settings.set',{values:{'screen.brightness':.4}});
|
||||
const settings=makePort('http://settings.localhost:18765/index.html');
|
||||
nativeCalls.length=0;
|
||||
const malformed=await settings('settings.set',{values:{'screen.brightness':.5,'':true}});
|
||||
const noSideEffect=malformed.error?.code==='INVALID_KEY' && nativeCalls.length===0;
|
||||
const notificationDenied=await gallery('android.notification',{operation:'show',owner:'settings',title:'Spoof'});
|
||||
const smsDenied=await clock('android.sms',{number:'123',body:'test'});
|
||||
const foreign=makePort('https://example.com/');
|
||||
const foreignDenied=await foreign('android.snapshot',{});
|
||||
const system=makePort('http://system.localhost:18765/index.html');
|
||||
events.length=0;
|
||||
await nativeEvent({type:'notification-click',data:{owner:'settings',id:1}});
|
||||
const queued=!events.some(e=>e.type==='launch');
|
||||
const forbiddenReady=await gallery('android.systemReady',{});
|
||||
await system('android.systemReady',{});
|
||||
const replayed=events.some(e=>e.type==='launch' && e.data.manifestURL==='http://settings.localhost:18765/manifest.webapp');
|
||||
return {coldNotificationWaitsForGaia:queued && replayed,systemReadinessPermission:forbiddenReady.error?.code==='PERMISSION_DENIED',invalidSettingNoNativeSideEffect:noSideEffect,notificationPermissions:notificationDenied.error?.code==='PERMISSION_DENIED',smsPermissions:smsDenied.error?.code==='PERMISSION_DENIED',started:true,stored:list.result.length===1,removed:empty.result.length===0,
|
||||
hardwarePermissions:denied.error?.code==='PERMISSION_DENIED' && cameraDenied.error?.code==='PERMISSION_DENIED',
|
||||
settingsPermissions:settingsDenied.error?.code==='PERMISSION_DENIED',
|
||||
externalOriginDenied:foreignDenied.error?.code==='PERMISSION_DENIED'};
|
||||
}"""
|
||||
)
|
||||
assert all(results.values()), results
|
||||
assert not errors, errors
|
||||
(root / "logs/repository-preparation").mkdir(parents=True, exist_ok=True)
|
||||
(root / "logs/repository-preparation/android-bridge-test.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"results": results,
|
||||
"scope": "Packaged extension in a browser; native messaging and extension storage mocked. Not an Android device test.",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
print(results)
|
||||
b.close()
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
56
android/tools/check-camera-permission.py
Normal file
56
android/tools/check-camera-permission.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Reproduce first camera consent on the test emulator, without restarting afterward."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
ADB = str(ROOT / "android/toolchain/sdk/platform-tools/adb")
|
||||
spec = importlib.util.spec_from_file_location("probe", Path(__file__).with_name("probe-android.py"))
|
||||
probe = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(probe)
|
||||
|
||||
|
||||
def adb(*args):
|
||||
return subprocess.check_output([ADB, "-s", "emulator-5554", *args], text=True).strip()
|
||||
|
||||
|
||||
assert adb("shell", "getprop", "ro.kernel.qemu") == "1"
|
||||
adb("shell", "pm", "revoke", "org.vulpes_os.preview", "android.permission.CAMERA")
|
||||
adb("shell", "am", "start", "-n", "org.vulpes_os.preview/.MainActivity")
|
||||
adb("forward", "tcp:6088", "localabstract:org.vulpes_os.preview/firefox-debugger-socket")
|
||||
for _ in range(60):
|
||||
if probe.run("system", 'document.body?.getAttribute("ready-state")==="fullyLoaded"').get(
|
||||
"value"
|
||||
):
|
||||
break
|
||||
time.sleep(0.3)
|
||||
else:
|
||||
raise TimeoutError("Gaia boot")
|
||||
probe.run(
|
||||
"system",
|
||||
'VulpesCompat.call("apps.launch",{manifestURL:"http://camera.localhost:18765/manifest.webapp"})',
|
||||
)
|
||||
subprocess.run(
|
||||
["python3", str(ROOT / "android/tools/ui-android.py"), "While using the app"],
|
||||
check=True,
|
||||
timeout=30,
|
||||
)
|
||||
for _ in range(60):
|
||||
state = probe.run(
|
||||
"camera",
|
||||
'({live:!!app.camera.mozCamera && app.camera.mozCamera.getVideoTracks().every(t=>t.readyState==="live"),loaded:!!app.loaded,hidden:document.hidden,busy:app.camera.isBusy,width:document.querySelector(".viewfinder video")?.videoWidth,text:document.body.innerText})',
|
||||
).get("value", {})
|
||||
if state.get("live") and state.get("width") and state.get("loaded") and not state.get("busy"):
|
||||
break
|
||||
time.sleep(0.3)
|
||||
else:
|
||||
raise AssertionError(state)
|
||||
result = {"first_consent_without_restart": state, "scope": "Android 16 x86_64 emulator"}
|
||||
(ROOT / "android/logs/camera-first-permission.json").write_text(
|
||||
json.dumps(result, indent=2, ensure_ascii=False) + "\n"
|
||||
)
|
||||
print(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
141
android/tools/check-camera.py
Normal file
141
android/tools/check-camera.py
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Exercise Gaia Photo/Gallery on an Android emulator with its virtual sensor."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
spec = importlib.util.spec_from_file_location("probe", Path(__file__).with_name("probe-android.py"))
|
||||
probe = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(probe)
|
||||
ADB = str(ROOT / "android/toolchain/sdk/platform-tools/adb")
|
||||
|
||||
|
||||
def adb(*args):
|
||||
return subprocess.check_output([ADB, "-s", "emulator-5554", *args], text=True).strip()
|
||||
|
||||
|
||||
assert (
|
||||
adb("shell", "getprop", "ro.kernel.qemu") == "1"
|
||||
), "Use a virtual sensor, not a personal camera."
|
||||
|
||||
|
||||
def ev(app, code):
|
||||
reply = probe.run(app, code)
|
||||
if "error" in reply:
|
||||
raise AssertionError(reply)
|
||||
return reply.get("value")
|
||||
|
||||
|
||||
def wait(app, code):
|
||||
for _ in range(60):
|
||||
value = ev(app, code)
|
||||
if value:
|
||||
return value
|
||||
time.sleep(0.3)
|
||||
raise TimeoutError(app + ": " + code)
|
||||
|
||||
|
||||
def launch(app):
|
||||
ev(
|
||||
"system",
|
||||
'VulpesCompat.call("apps.launch",{manifestURL:"http://'
|
||||
+ app
|
||||
+ '.localhost:18765/manifest.webapp"})',
|
||||
)
|
||||
wait(app, 'document.readyState==="complete" && !document.hidden')
|
||||
|
||||
|
||||
result = {"scope": "Android 16 x86_64 emulator, virtual camera; physical phones untested."}
|
||||
wait("system", 'document.body?.getAttribute("ready-state")==="fullyLoaded"')
|
||||
ev("system", "(ScreenManager.turnScreenOn(true),true)")
|
||||
time.sleep(.5)
|
||||
ev("system", "(lockScreen.unlock(true),true)")
|
||||
wait("system", "!lockScreen.locked")
|
||||
time.sleep(.5)
|
||||
launch("camera")
|
||||
wait(
|
||||
"camera",
|
||||
'!!app.loaded && !!app.camera.mozCamera && !app.camera.isBusy && !!document.querySelector(".viewfinder video")?.videoWidth',
|
||||
)
|
||||
result["preview"] = ev(
|
||||
"camera",
|
||||
'(()=>{const v=document.querySelector(".viewfinder video");return {width:v.videoWidth,height:v.videoHeight,cameras:navigator.mozCameras.getListOfCameras()}})()',
|
||||
)
|
||||
result["original_gaia_ui"] = ev(
|
||||
"camera",
|
||||
'!!document.querySelector(".js-capture") && !document.querySelector(".vulpes-capability")',
|
||||
)
|
||||
assert result["original_gaia_ui"]
|
||||
ev(
|
||||
"camera",
|
||||
'(async()=>{window.__beforeCapture=(await VulpesCompat.call("media.request",{type:"pictures",operation:"enumerate"})).map(f=>f.name);window.__streamBefore=app.camera.mozCamera;return true})()',
|
||||
)
|
||||
point = ev(
|
||||
"camera",
|
||||
'(()=>{const r=document.querySelector(".js-capture").getBoundingClientRect();return {x:(mozInnerScreenX+r.x+r.width/2)*devicePixelRatio,y:(mozInnerScreenY+r.y+r.height/2)*devicePixelRatio}})()',
|
||||
)
|
||||
adb("shell", "input", "tap", str(round(point["x"])), str(round(point["y"])))
|
||||
photo = wait(
|
||||
"camera",
|
||||
'(async()=>(await VulpesCompat.call("media.request",{type:"pictures",operation:"enumerate"})).find(f=>!__beforeCapture.includes(f.name)))()',
|
||||
)
|
||||
assert photo["size"] > 0 and photo["type"] == "image/jpeg"
|
||||
result["photo"] = photo
|
||||
launch("gallery")
|
||||
result["camera_stops_when_hidden"] = wait(
|
||||
"camera", '__streamBefore.getTracks().every(t=>t.readyState==="ended")'
|
||||
)
|
||||
name = json.dumps(photo["name"])
|
||||
result["gallery_indexes_photo"] = wait(
|
||||
"gallery",
|
||||
'typeof files!=="undefined" && files.some(f=>f.name==='
|
||||
+ name
|
||||
+ " && f.metadata.width>0 && f.metadata.thumbnail)",
|
||||
)
|
||||
ev(
|
||||
"gallery",
|
||||
'(()=>{const e=[...document.querySelectorAll(".thumbnailImage")].find(e=>e.dataset.filename==='
|
||||
+ name
|
||||
+ ");e.click();return true})()",
|
||||
)
|
||||
result["gallery_opens_photo"] = wait(
|
||||
"gallery", 'document.body.classList.contains("fullscreenView")'
|
||||
)
|
||||
result["gallery_import_button"] = ev(
|
||||
"gallery", '!!document.querySelector("gaia-header [data-vulpes-import]") && !document.querySelector(".vulpes-import")'
|
||||
)
|
||||
assert result["gallery_import_button"]
|
||||
ev("gallery", "(launchCameraApp(),true)")
|
||||
wait("camera", "!document.hidden")
|
||||
result["camera_resumes"] = wait(
|
||||
"camera",
|
||||
'!!app.camera.mozCamera && app.camera.mozCamera!==__streamBefore && app.camera.mozCamera.getVideoTracks().every(t=>t.readyState==="live") && !app.camera.isBusy',
|
||||
)
|
||||
ev("camera", "(window.__streamBefore=app.camera.mozCamera,true)")
|
||||
adb("shell", "input", "keyevent", "KEYCODE_HOME")
|
||||
result["camera_stops_in_android_background"] = wait(
|
||||
"camera", '__streamBefore.getTracks().every(t=>t.readyState==="ended")'
|
||||
)
|
||||
adb("shell", "am", "start", "-n", "org.vulpes_os.preview/.MainActivity")
|
||||
result["camera_resumes_from_android_background"] = wait(
|
||||
"camera",
|
||||
'!document.hidden && !!app.camera.mozCamera && app.camera.mozCamera!==__streamBefore && app.camera.mozCamera.getVideoTracks().every(t=>t.readyState==="live") && !app.camera.isBusy',
|
||||
)
|
||||
result["capability_controls"] = ev(
|
||||
"camera",
|
||||
'document.querySelector(".js-flash").getAttribute("aria-disabled")==="true" && (document.querySelector(".mode-switch").getAttribute("aria-disabled")!=="true") === !!Object.keys(app.camera.mozCamera.capabilities.recorderProfiles).length',
|
||||
)
|
||||
assert result["capability_controls"]
|
||||
result["errors"] = ev(
|
||||
"camera", '__vulpesDiagnostics.filter(d=>d.kind==="error" || d.kind==="rejection")'
|
||||
)
|
||||
assert not result["errors"], result
|
||||
launch("gallery")
|
||||
(ROOT / "android/logs/camera-results.json").write_text(
|
||||
json.dumps(result, indent=2, ensure_ascii=False) + "\n"
|
||||
)
|
||||
print(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
50
android/tools/check-lifecycle.py
Normal file
50
android/tools/check-lifecycle.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Exercise Android background/resume and renderer termination on a test device."""
|
||||
import importlib.util,json,subprocess,sys,time
|
||||
from pathlib import Path
|
||||
ROOT=Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0,str(ROOT/'android/tools'))
|
||||
spec=importlib.util.spec_from_file_location('probe',ROOT/'android/tools/probe-android.py');probe=importlib.util.module_from_spec(spec);spec.loader.exec_module(probe)
|
||||
from rdp import RDP
|
||||
ADB=str(ROOT/'android/toolchain/sdk/platform-tools/adb')
|
||||
def adb(*args):return subprocess.check_output([ADB,*args],text=True).strip()
|
||||
def wait_app():
|
||||
end=time.monotonic()+30
|
||||
while time.monotonic()<end:
|
||||
try:
|
||||
result=probe.run('system','({ready:document.readyState,marker:window.__lifecycleMarker,frames:[...document.querySelectorAll("iframe")].map(f=>f.src)})')
|
||||
if result.get('value',{}).get('ready')=='complete':return result['value']
|
||||
except (Exception,):pass
|
||||
time.sleep(.5)
|
||||
raise TimeoutError('Gaia did not recover')
|
||||
report={}
|
||||
adb('shell','am','start','-n','org.vulpes_os.preview/.MainActivity')
|
||||
wait_app();probe.run('system','window.__lifecycleMarker="lifecycle-20260924"')
|
||||
cycles=[]
|
||||
for i in range(10):
|
||||
adb('shell','input','keyevent','KEYCODE_HOME')
|
||||
adb('shell','am','start','-a','android.settings.SETTINGS')
|
||||
time.sleep(.5)
|
||||
adb('shell','am','start','-n','org.vulpes_os.preview/.MainActivity')
|
||||
state=wait_app();cycles.append(state['marker']=='lifecycle-20260924')
|
||||
print('Resume cycle',i+1,cycles[-1],flush=True)
|
||||
assert all(cycles),cycles
|
||||
report['ten_switches_preserve_document']=cycles
|
||||
r=RDP();tab=next(t for t in r.call('root','listTabs')['tabs'] if 'system.localhost' in t.get('url',''));target=r.call(tab['actor'],'getTarget')['frame'];r.s.close()
|
||||
pid=str(target['processID']);name=adb('shell','ps','-p',pid,'-o','NAME')
|
||||
assert 'org.vulpes_os.preview:tab' in name,name
|
||||
adb('shell','input','keyevent','KEYCODE_HOME');adb('shell','run-as','org.vulpes_os.preview','kill','-9',pid)
|
||||
time.sleep(.5);adb('shell','am','start','-n','org.vulpes_os.preview/.MainActivity')
|
||||
state=wait_app();assert state.get('marker') is None,state
|
||||
for _ in range(30):
|
||||
try:
|
||||
home=probe.run('homescreen','({icons:document.querySelectorAll("gaia-app-icon").length,ready:document.readyState})')['value']
|
||||
if home['icons']>10:break
|
||||
except Exception:pass
|
||||
time.sleep(.5)
|
||||
assert home['icons']>10,home
|
||||
report['renderer_killed_in_background_recovers']=home
|
||||
report['snapshot']=probe.run('system','VulpesCompat.call("android.snapshot",{})')['value']
|
||||
report['scope']='Android 16 x86_64 emulator; not a physical-device validation.'
|
||||
(ROOT/'android/logs/integration/lifecycle.json').write_text(json.dumps(report,indent=2))
|
||||
print(json.dumps(report,indent=2),flush=True)
|
||||
45
android/tools/check-wallpaper.py
Normal file
45
android/tools/check-wallpaper.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Observe real Gaia wallpaper events over an ADB-forwarded debug connection."""
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from rdp import RDP
|
||||
|
||||
|
||||
def evaluate(source):
|
||||
r = RDP()
|
||||
try:
|
||||
result = r.eval(source)
|
||||
if result.get("hasException"):
|
||||
raise RuntimeError(result)
|
||||
return result.get("result")
|
||||
finally:
|
||||
r.s.close()
|
||||
|
||||
|
||||
duration = int(sys.argv[1]) if len(sys.argv) > 1 else 30
|
||||
evaluate(
|
||||
"""window.__wallpaperProbe={changes:0,settings:0,start:Date.now()};
|
||||
addEventListener('wallpaperchange',()=>__wallpaperProbe.changes++);
|
||||
navigator.mozSettings.addObserver('wallpaper.image',()=>__wallpaperProbe.settings++);
|
||||
navigator.mozSettings.createLock().get('wallpaper.image').onsuccess=e=>{
|
||||
const v=e.target.result['wallpaper.image'];
|
||||
__wallpaperProbe.image={blob:v instanceof Blob,size:v.size,type:v.type};
|
||||
}; 'probe-started';"""
|
||||
)
|
||||
time.sleep(duration)
|
||||
result = json.loads(
|
||||
evaluate(
|
||||
"""JSON.stringify({...__wallpaperProbe,
|
||||
elapsed:Date.now()-__wallpaperProbe.start,ready:applications.ready,
|
||||
background:document.getElementById('screen').style.backgroundImage})"""
|
||||
)
|
||||
)
|
||||
print(json.dumps(result, indent=2))
|
||||
assert result["ready"], "Gaia not ready"
|
||||
assert result.get("image", {}).get("blob"), "Wallpaper was not restored as a Blob"
|
||||
assert result["image"]["size"] > 0, "Empty wallpaper"
|
||||
assert (
|
||||
result["changes"] == 0 and result["settings"] == 0
|
||||
), "Wallpaper is still changing without user action"
|
||||
assert "blob:" in result["background"], "Wallpaper missing from the screen"
|
||||
19
android/tools/inspect-process.py
Normal file
19
android/tools/inspect-process.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import sys
|
||||
import json
|
||||
from rdp import RDP
|
||||
|
||||
r = RDP()
|
||||
targets = r.call("root", "listProcesses")["processes"]
|
||||
for p in targets:
|
||||
if p["isParent"]:
|
||||
continue
|
||||
t = r.call(p["actor"], "getTarget")["process"]
|
||||
if sys.argv[1] not in t.get("remoteType", ""):
|
||||
continue
|
||||
a = r.call(t["consoleActor"], "evaluateJSAsync", text=sys.argv[2])
|
||||
while True:
|
||||
q = r.recv()
|
||||
if q.get("type") == "evaluationResult":
|
||||
print(json.dumps(q, indent=2))
|
||||
break
|
||||
r.s.close()
|
||||
184
android/tools/pack-assets.py
Normal file
184
android/tools/pack-assets.py
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Snapshot the same Gaia assets/overlays as the desktop, without changing them."""
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import zipfile
|
||||
import sys
|
||||
import base64
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
from project import SOURCE_OVERRIDES, build_info, validate_assets, release_info, application_manifest
|
||||
|
||||
validate_assets()
|
||||
OUT = ROOT / "android/app/src/main/assets"
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
PORT = 18765
|
||||
pattern = re.compile(
|
||||
r"app://([a-zA-Z0-9_-]+)\.gaiamobile\.org|http://([a-zA-Z0-9_-]+)\.localhost:8765"
|
||||
)
|
||||
|
||||
|
||||
def translate(text):
|
||||
return pattern.sub(lambda m: f"http://{m[1] or m[2]}.localhost:{PORT}", text)
|
||||
|
||||
|
||||
APPS = ROOT / "assets/webapps"
|
||||
registry = []
|
||||
for manifest in sorted(APPS.glob("*/manifest.webapp")):
|
||||
name = manifest.parent.name.removesuffix(".gaiamobile.org")
|
||||
if not re.fullmatch("[a-z0-9_-]+", name):
|
||||
continue
|
||||
origin = f"http://{name}.localhost:{PORT}"
|
||||
registry.append(
|
||||
dict(
|
||||
id=name,
|
||||
origin=origin,
|
||||
manifestURL=origin + "/manifest.webapp",
|
||||
manifest=application_manifest(name, json.loads(translate(manifest.read_text())), "--include-dev-apps" in sys.argv),
|
||||
)
|
||||
)
|
||||
info = build_info()
|
||||
release = release_info()
|
||||
revision = info["revision"]
|
||||
commit_time = str(info["timestamp"])
|
||||
textsuffix = {".html", ".js", ".css", ".json", ".webapp", ".properties", ".svg"}
|
||||
head = b'<head><script src="/_vulpes/platform-config.js"></script><meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"><link rel="stylesheet" href="/_vulpes/desktop.css"><link rel="stylesheet" href="/_vulpes/android.css"><script src="/_vulpes/settings-codec.js"></script><script src="/_vulpes/gaia-compat.js"></script><script src="/_vulpes/gaia-wifi.js"></script><script src="/_vulpes/gaia-mobile-settings.js"></script><script src="/_vulpes/gaia-bluetooth.js"></script><script src="/_vulpes/gaia-input.js"></script><script src="/_vulpes/gaia-services.js"></script><script src="/_vulpes/gaia-styles.js"></script><link rel="stylesheet" href="/_vulpes/gaia-theme.css"><script src="/_vulpes/gaia-theme.js"></script><script src="/_vulpes/gaia-select.js"></script><script src="/_vulpes/android-ui.js"></script><script src="/_vulpes/gaia-restoration.js"></script><script src="/_vulpes/platform-ui.js"></script><script src="/_vulpes/gaia-camera.js"></script>'
|
||||
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 = {}
|
||||
|
||||
def put(name, data):
|
||||
z.writestr(name, data)
|
||||
hashes[name] = hashlib.sha256(data).hexdigest()
|
||||
|
||||
for app in registry:
|
||||
folder = APPS / (app["id"] + ".gaiamobile.org")
|
||||
files = {p.relative_to(folder).as_posix(): p for p in folder.rglob("*") if p.is_file()}
|
||||
common = ROOT / "overrides/shared"
|
||||
if common.is_dir():
|
||||
for source in common.rglob("*"):
|
||||
if source.is_file():
|
||||
files["shared/" + source.relative_to(common).as_posix()] = source
|
||||
overlay = ROOT / "overrides" / folder.name
|
||||
if overlay.exists():
|
||||
files.update(
|
||||
{
|
||||
p.relative_to(overlay).as_posix(): p
|
||||
for p in overlay.rglob("*")
|
||||
if p.is_file() and p.name != "manifest.webapp"
|
||||
}
|
||||
)
|
||||
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()
|
||||
if p.suffix in textsuffix:
|
||||
text = translate(data.decode("utf8"))
|
||||
if p.suffix in {".html", ".css", ".js"}:
|
||||
for a, b in [
|
||||
("-moz-calc(", "calc("),
|
||||
("-moz-box-sizing:", "box-sizing:"),
|
||||
("-moz-plaintext", "plaintext"),
|
||||
(":-moz-dir(", ":dir("),
|
||||
("offset-inline-start:", "inset-inline-start:"),
|
||||
("offset-inline-end:", "inset-inline-end:"),
|
||||
]:
|
||||
text = text.replace(a, b)
|
||||
# Clearing a media URL with null requests the literal /null path.
|
||||
if app["id"] == "music" and p.suffix == ".js":
|
||||
text = text.replace("audio.src=null;", "audio.removeAttribute('src');")
|
||||
if name == "resources/gaia_commit.txt":
|
||||
text = revision + "\n" + commit_time + "\n"
|
||||
data = text.encode()
|
||||
if p.suffix == ".html" and not (app["id"] == "search" and name.startswith("home/")):
|
||||
data = data.replace(b"<head>", head, 1)
|
||||
if app["id"] == "communications" and name.startswith("dialer/"):
|
||||
data = data.replace(
|
||||
b"<head>",
|
||||
b'<head><script src="/shared/js/accessibility_helper.js"></script>',
|
||||
1,
|
||||
)
|
||||
put(app["id"] + "/" + name, data)
|
||||
for p in (ROOT / "gaia/shared").rglob("*"):
|
||||
if p.is_file():
|
||||
data = p.read_bytes()
|
||||
if p.suffix in textsuffix:
|
||||
data = translate(data.decode("utf8")).encode()
|
||||
put("_shared/" + p.relative_to(ROOT / "gaia/shared").as_posix(), data)
|
||||
put("_vulpes/platform-config.js", b'window.VulpesPlatform="android";')
|
||||
for name in ["gaia-compat.js", "gaia-input.js", "gaia-services.js", "gaia-styles.js", "gaia-theme.js", "gaia-select.js", "gaia-mobile-settings.js", "gaia-theme.css", "gaia-restoration.js", "gaia-wifi.js", "gaia-bluetooth.js", "desktop.css", "platform-ui.js", "gaia-camera.js"]:
|
||||
text = (
|
||||
(ROOT / "host" / name)
|
||||
.read_text()
|
||||
)
|
||||
put("_vulpes/" + name, translate(text).encode())
|
||||
put("_vulpes/settings-codec.js", (ROOT / "host/settings-codec.js").read_bytes())
|
||||
put("_vulpes/android-ui.js", (ROOT / "android/ui/android-ui.js").read_bytes())
|
||||
put(
|
||||
"_vulpes/android.css",
|
||||
b'html,body{overscroll-behavior:none} html[data-vulpes-app="system"] #keyboard-frame{display:none!important} html[data-vulpes-app="system"] #screen{height:100%!important} input,textarea{touch-action:manipulation}',
|
||||
)
|
||||
(OUT / "payload-checksums.json").write_text(json.dumps(hashes, sort_keys=True))
|
||||
ext = OUT / "bridge"
|
||||
shutil.copytree(ROOT / "android/extension", ext, dirs_exist_ok=True)
|
||||
for folder in ["services", "adapters"]:
|
||||
shutil.copytree(ROOT / folder, ext / folder, dirs_exist_ok=True)
|
||||
for name in ["Connections", "Contacts", "Messages"]:
|
||||
text = (
|
||||
(ROOT / "host" / f"{name}.sys.mjs")
|
||||
.read_text()
|
||||
.replace("Services.uuid.generateUUID().toString()", "crypto.randomUUID()")
|
||||
)
|
||||
(ext / f"{name}.mjs").write_text(text)
|
||||
shutil.copy2(ROOT / "host/Alarms.sys.mjs", ext / "Alarms.mjs")
|
||||
(ext / "input-editor.js").write_text((ROOT / "services/input-editor.mjs").read_text().replace('export class InputEditor', 'class InputEditor'))
|
||||
shutil.copy2(ROOT / "host/marketplace-page.js", ext / "marketplace-page.js")
|
||||
(ext / "release.json").write_text(json.dumps(release))
|
||||
(ext / "registry.json").write_text(json.dumps(registry, ensure_ascii=False))
|
||||
settings = json.loads(translate((ROOT / "assets/settings-defaults.json").read_text()))
|
||||
wallpaper = ROOT / "gaia/apps/default_theme/wallpapers/Illus_Flow2_Blue@2x.jpg"
|
||||
if not wallpaper.is_file():
|
||||
raise RuntimeError("Default wallpaper is missing: " + str(wallpaper))
|
||||
settings["wallpaper.image"] = (
|
||||
"data:image/jpeg;base64," + base64.b64encode(wallpaper.read_bytes()).decode()
|
||||
)
|
||||
settings.update(
|
||||
{
|
||||
"ftu.manifestURL": "",
|
||||
"lockscreen.enabled": True,
|
||||
"screen.timeout": 0,
|
||||
"language.current": "fr",
|
||||
"software-button.enabled": False,
|
||||
"metrics.selectedMetrics.level": "None",
|
||||
"ftu.pingEnabled": False,
|
||||
"search.suggestions.enabled": False,
|
||||
"search.marketplace.url": "",
|
||||
"gaia.system.checkForUpdates": False,
|
||||
"deviceinfo.os": release["gaia"],
|
||||
"deviceinfo.software": "Vulpes OS " + release["version"],
|
||||
"deviceinfo.product_model": "Vulpes Android Preview",
|
||||
"deviceinfo.product_device": "android",
|
||||
"deviceinfo.platform_version": release["androidEngine"],
|
||||
"deviceinfo.platform_build_id": release["androidEngineBuildID"],
|
||||
}
|
||||
)
|
||||
(ext / "defaults.json").write_text(json.dumps(settings, ensure_ascii=False))
|
||||
(OUT / "build-info.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"revision": revision,
|
||||
"geckoview": release["geckoview"],
|
||||
"version": release["version"],
|
||||
"minSdk": 26,
|
||||
"targetSdk": 37,
|
||||
}
|
||||
)
|
||||
)
|
||||
print(
|
||||
f'Packaged {len(registry)} Gaia apps, {round((OUT/"gaia.zip").stat().st_size/1024/1024,1)} MiB'
|
||||
)
|
||||
108
android/tools/prepare-toolchain.py
Normal file
108
android/tools/prepare-toolchain.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Install the locked Linux x86_64 Android build tools in the ignored toolchain/."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import urllib.request
|
||||
import zipfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
TOOLS = ROOT / "toolchain"
|
||||
|
||||
|
||||
def fetch(name, entry):
|
||||
path = TOOLS / "downloads" / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
algorithm = "sha256" if "sha256" in entry else "sha1"
|
||||
if not path.exists():
|
||||
partial = path.with_suffix(path.suffix + ".partial")
|
||||
with urllib.request.urlopen(entry["url"], timeout=60) as source, partial.open("wb") as out:
|
||||
shutil.copyfileobj(source, out)
|
||||
partial.replace(path)
|
||||
with path.open("rb") as stream:
|
||||
digest = hashlib.file_digest(stream, algorithm).hexdigest()
|
||||
if digest != entry[algorithm]:
|
||||
raise RuntimeError(f"Checksum mismatch; archive left unextracted: {path}")
|
||||
return path
|
||||
|
||||
|
||||
def extract(archive, member_root, target):
|
||||
if target.exists():
|
||||
raise RuntimeError(f"Incomplete installation left untouched: {target}")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix=".install-", dir=TOOLS) as temporary:
|
||||
stage = Path(temporary)
|
||||
if archive.name.endswith(".tar.gz"):
|
||||
with tarfile.open(archive) as package:
|
||||
package.extractall(stage, filter="data")
|
||||
else:
|
||||
with zipfile.ZipFile(archive) as package:
|
||||
for info in package.infolist():
|
||||
path = stage / info.filename
|
||||
if not path.resolve().is_relative_to(stage) or "\\" in info.filename:
|
||||
raise RuntimeError("Unsafe toolchain archive entry")
|
||||
if (info.external_attr >> 16) & 0o170000 == 0o120000:
|
||||
raise RuntimeError("Symlink in toolchain ZIP archive")
|
||||
package.extract(info, stage)
|
||||
mode = (info.external_attr >> 16) & 0o777
|
||||
if mode and path.is_file():
|
||||
path.chmod(mode)
|
||||
(stage / member_root).rename(target)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--check", action="store_true", help="Check installed tools without downloading"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
if platform.system() != "Linux" or platform.machine() not in ("x86_64", "amd64"):
|
||||
raise RuntimeError("This bootstrap currently supports Linux x86_64 only")
|
||||
lock = json.loads((ROOT / "toolchain-lock.json").read_text())
|
||||
jdk = TOOLS / "jdk-21.0.12.1+1"
|
||||
gradle = TOOLS / "gradle-9.5.0"
|
||||
sdk = TOOLS / "sdk"
|
||||
installs = [
|
||||
("jdk.tar.gz", "jdk-21.0.12.1+1", jdk, jdk / "bin/java"),
|
||||
("gradle-9.5.zip", "gradle-9.5.0", gradle, gradle / "bin/gradle"),
|
||||
(
|
||||
"commandlinetools.zip",
|
||||
"cmdline-tools",
|
||||
sdk / "cmdline-tools/latest",
|
||||
sdk / "cmdline-tools/latest/bin/sdkmanager",
|
||||
),
|
||||
]
|
||||
missing = [
|
||||
str(executable.relative_to(ROOT))
|
||||
for _, _, _, executable in installs
|
||||
if not executable.is_file()
|
||||
]
|
||||
packages = [sdk / "platforms/android-37.1/android.jar", sdk / "build-tools/37.0.0/apksigner"]
|
||||
if args.check:
|
||||
missing += [str(path.relative_to(ROOT)) for path in packages if not path.is_file()]
|
||||
if missing:
|
||||
raise RuntimeError("Missing tools: " + ", ".join(missing))
|
||||
else:
|
||||
TOOLS.mkdir(exist_ok=True)
|
||||
for name, member, target, executable in installs:
|
||||
if not executable.is_file():
|
||||
extract(fetch(name, lock["archives"][name]), member, target)
|
||||
if not all(path.is_file() for path in packages):
|
||||
env = {**os.environ, "JAVA_HOME": str(jdk)}
|
||||
manager = sdk / "cmdline-tools/latest/bin/sdkmanager"
|
||||
# sdkmanager asks the user to review any licenses not already accepted.
|
||||
subprocess.run(
|
||||
[str(manager), "--sdk_root=" + str(sdk), *lock["sdk"]], env=env, check=True
|
||||
)
|
||||
print("Android build tools available in", TOOLS)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
52
android/tools/probe-android.py
Normal file
52
android/tools/probe-android.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Evaluate a diagnostic in one Gaia app through the ADB-only debugger."""
|
||||
import json,sys,time
|
||||
from rdp import RDP
|
||||
|
||||
def evaluate(host, script, connection=None):
|
||||
hostname, _, path = host.partition('/')
|
||||
r=connection or RDP()
|
||||
try:
|
||||
for process in r.call('root','listProcesses')['processes']:
|
||||
if process['isParent']: continue
|
||||
target=r.call(process['actor'],'getTarget')['process']
|
||||
if 'webIsolated' not in target.get('remoteType',''): continue
|
||||
text='''(() => { const w=[...Services.ww.getWindowEnumerator()].find(w=>w.location.hostname===%s && (!%s || w.location.pathname.startsWith(%s))); if(!w)return null; return w.wrappedJSObject.eval(%s); })()''' % (json.dumps(hostname+'.localhost'),json.dumps(path),json.dumps('/'+path),json.dumps(script))
|
||||
response=r.call(target['consoleActor'],'evaluateJSAsync',text=text)
|
||||
if 'resultID' not in response: continue
|
||||
while True:
|
||||
value=r.recv()
|
||||
if value.get('type')=='evaluationResult' and value.get('resultID')==response['resultID']:
|
||||
if value.get('hasException'): raise RuntimeError(value.get('exceptionMessage',str(value)))
|
||||
if value.get('result') is not None and value.get('result') != {'type':'null'}: return value.get('result')
|
||||
break
|
||||
raise RuntimeError('App not open: '+host)
|
||||
finally:
|
||||
if connection is None:r.s.close()
|
||||
|
||||
def _run(host,expression,timeout,connection):
|
||||
start=time.monotonic()+timeout
|
||||
while True:
|
||||
try:
|
||||
evaluate(host,'window.__androidProbe=null; Promise.resolve().then(async()=>('+expression+')).then(value=>window.__androidProbe=JSON.stringify({value}),error=>window.__androidProbe=JSON.stringify({error:String(error),stack:error.stack})); "started"',connection)
|
||||
break
|
||||
except RuntimeError as e:
|
||||
if time.monotonic()>=start:raise
|
||||
if not str(e).startswith('App not open:'):raise
|
||||
time.sleep(.3)
|
||||
until=time.monotonic()+timeout
|
||||
while time.monotonic()<until:
|
||||
result=evaluate(host,'window.__androidProbe || "pending"',connection)
|
||||
if isinstance(result,str) and result != 'pending':return json.loads(result)
|
||||
time.sleep(.3)
|
||||
raise TimeoutError(host)
|
||||
|
||||
def run(host,expression,timeout=30):
|
||||
connection=RDP()
|
||||
try:
|
||||
return _run(host,expression,timeout,connection)
|
||||
finally:
|
||||
connection.s.close()
|
||||
|
||||
if __name__=='__main__':
|
||||
print(json.dumps(run(sys.argv[1],sys.argv[2]),ensure_ascii=False,indent=2))
|
||||
62
android/tools/rdp.py
Normal file
62
android/tools/rdp.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
#!/usr/bin/env python3
|
||||
"""ADB-forwarded Firefox debugger helper for local development APKs."""
|
||||
import socket
|
||||
import json
|
||||
import sys
|
||||
|
||||
|
||||
class RDP:
|
||||
def __init__(self, port=6088):
|
||||
self.events = []
|
||||
self.s = socket.create_connection(("127.0.0.1", port), timeout=20)
|
||||
self.recv()
|
||||
|
||||
def recv(self):
|
||||
n = b""
|
||||
while not n.endswith(b":"):
|
||||
c = self.s.recv(1)
|
||||
if not c:
|
||||
raise EOFError()
|
||||
n += c
|
||||
data = b""
|
||||
while len(data) < int(n[:-1]):
|
||||
data += self.s.recv(int(n[:-1]) - len(data))
|
||||
return json.loads(data)
|
||||
|
||||
def call(self, to, kind, **args):
|
||||
data = json.dumps(dict(to=to, type=kind, **args)).encode()
|
||||
self.s.sendall(str(len(data)).encode() + b":" + data)
|
||||
while True:
|
||||
r = self.recv()
|
||||
self.events.append(r)
|
||||
if str(r.get("type", "")).endswith("ListChanged"):
|
||||
continue
|
||||
if r.get("from") == to and (r.get("type") not in ["consoleAPICall", "pageError"]):
|
||||
return r
|
||||
|
||||
def eval(self, text):
|
||||
tabs = self.call("root", "listTabs")["tabs"]
|
||||
tab = next(t for t in tabs if "system.localhost" in t.get("url", ""))
|
||||
frame = self.call(tab["actor"], "getTarget")["frame"]
|
||||
reply = self.call(frame["consoleActor"], "evaluateJSAsync", text=text)
|
||||
if "resultID" not in reply:
|
||||
return reply
|
||||
while True:
|
||||
result = self.recv()
|
||||
if (
|
||||
result.get("type") == "evaluationResult"
|
||||
and result.get("resultID") == reply["resultID"]
|
||||
):
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
c = RDP()
|
||||
print(
|
||||
json.dumps(
|
||||
c.eval(sys.argv[1]) if len(sys.argv) > 1 else c.call("root", "listTabs"),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
c.s.close()
|
||||
17
android/tools/ui-android.py
Normal file
17
android/tools/ui-android.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Read or tap a labeled Android accessibility node on the connected test device."""
|
||||
import re,sys,subprocess,xml.etree.ElementTree as E
|
||||
from pathlib import Path
|
||||
adb=str(Path(__file__).resolve().parents[1]/'toolchain/sdk/platform-tools/adb')
|
||||
subprocess.run([adb,'shell','uiautomator','dump','/sdcard/vulpes-ui.xml'],stdout=subprocess.DEVNULL,check=True)
|
||||
nodes=list(E.fromstring(subprocess.check_output([adb,'shell','cat','/sdcard/vulpes-ui.xml'],text=True)).iter('node'))
|
||||
if len(sys.argv)>1:
|
||||
needle=sys.argv[1]
|
||||
for n in nodes:
|
||||
if needle in (n.get('text'),n.get('content-desc'),n.get('resource-id')):
|
||||
x1,y1,x2,y2=map(int,re.findall(r'\d+',n.get('bounds')))
|
||||
subprocess.run([adb,'shell','input','tap',str((x1+x2)//2),str((y1+y2)//2)],check=True);print('Tapped:',needle);break
|
||||
else:raise SystemExit('Node not found: '+needle)
|
||||
else:
|
||||
for n in nodes:
|
||||
if n.get('text') or n.get('clickable')=='true':print(n.get('text'),n.get('content-desc'),n.get('resource-id'),n.get('bounds'))
|
||||
202
android/ui/android-ui.js
Normal file
202
android/ui/android-ui.js
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
// Android-owned data stays in its provider; Gaia keeps its local data separately.
|
||||
(() => {
|
||||
const app = location.hostname.split('.')[0];
|
||||
const fr = () => (document.documentElement.lang || navigator.language).startsWith('fr');
|
||||
const text = (f,e) => fr() ? f : e;
|
||||
const call = params => VulpesCompat.call('android.personal', params);
|
||||
const notice = message => window.VulpesGaiaUI?.notice(message);
|
||||
const readOnly = () => notice(text('Ces données appartiennent à Android. Modifiez-les dans l’application Android.',
|
||||
'Android owns these records. Edit them in the Android app.'));
|
||||
const failure = () => notice(text('Accès aux données Android indisponible. Vérifiez les autorisations dans Paramètres.',
|
||||
'Android data is unavailable. Check permissions in Settings.'));
|
||||
let dirty = false;
|
||||
let selectedContact;
|
||||
const pendingCallChanges = new Set();
|
||||
function refreshCallLog() {
|
||||
if (document.hidden || !window.CallLog?.sticky) return;
|
||||
if (pendingCallChanges.has('calls')) {
|
||||
CallLog.callLogContainer.textContent = '';
|
||||
CallLog.render();
|
||||
} else if (pendingCallChanges.has('contacts')) {
|
||||
CallLog._contactCache = false;
|
||||
CallLog.updateListWithContactInfo('update');
|
||||
}
|
||||
pendingCallChanges.clear();
|
||||
}
|
||||
if(app==='communications' && location.pathname.startsWith('/contacts')) {
|
||||
// Gaia's first-paint HTML cache must not retain Android names after revocation.
|
||||
localStorage.removeItem('firstChunk');
|
||||
document.addEventListener('click',event=>{
|
||||
const row=event.target.closest?.('[data-uuid]');
|
||||
if(row) selectedContact=row.dataset.uuid;
|
||||
},true);
|
||||
let cache;
|
||||
Object.defineProperty(window,'Cache',{configurable:true,get:()=>cache,set:value=>{
|
||||
cache=value;
|
||||
Object.defineProperties(cache,{
|
||||
enabled:{get:()=>false},active:{get:()=>false},firstChunk:{set:()=>{}}
|
||||
});
|
||||
cache.apply=()=>Promise.reject();
|
||||
}});
|
||||
}
|
||||
function canRefresh() {
|
||||
if (document.hidden) return false;
|
||||
if (app === 'sms') return window.Navigation?.isCurrentPanel('thread-list');
|
||||
if (location.pathname.startsWith('/contacts')) return window.MainNavigation?.currentView() === 'view-contacts-list';
|
||||
return false;
|
||||
}
|
||||
let refreshingMessages = false;
|
||||
async function refreshMessages() {
|
||||
const view=window.ConversationView, id=view?.activeThread?.id;
|
||||
if(refreshingMessages || !(id < 0) || document.hidden || !window.Navigation?.isCurrentPanel('thread'))return;
|
||||
refreshingMessages=true;
|
||||
try {
|
||||
const rows=await VulpesCompat.call('messages.request',{operation:'getMessages',args:[{threadId:id}]});
|
||||
if(view.activeThread?.id!==id || document.hidden)return;
|
||||
const ids=new Set(rows.map(row=>String(row.id)));
|
||||
for(const node of document.querySelectorAll('[data-message-id]')) {
|
||||
if(Number(node.dataset.messageId)<0 && !ids.has(node.dataset.messageId))view.removeMessageDOM(node);
|
||||
}
|
||||
for(const row of rows) {
|
||||
const key=JSON.stringify(row),old=document.getElementById('message-'+row.id);
|
||||
if(old?.dataset.androidRecord===key)continue;
|
||||
const message={...row,timestamp:new Date(row.timestamp)};
|
||||
window.Threads?.registerMessage(message);
|
||||
await view.appendMessage(message,false);
|
||||
const node=document.getElementById('message-'+row.id);if(node)node.dataset.androidRecord=key;
|
||||
}
|
||||
dirty=false;
|
||||
} catch(error){failure();}finally{refreshingMessages=false;}
|
||||
}
|
||||
function refresh() {
|
||||
if (app === 'communications' && location.pathname.startsWith('/dialer')) {
|
||||
refreshCallLog();
|
||||
return;
|
||||
}
|
||||
if (dirty && selectedContact && !document.hidden && window.MainNavigation?.currentView()==='view-contact-details') {
|
||||
dirty = false;
|
||||
const id = selectedContact;
|
||||
VulpesCompat.call('contacts.request',{operation:'find',options:{filterBy:['id'],filterOp:'equals',filterValue:id}})
|
||||
.then(rows=>{if(rows.length)Contacts.updateContactDetail(id);else Contacts.goBack();}).catch(failure);
|
||||
return;
|
||||
}
|
||||
if(dirty && app==='sms')refreshMessages();
|
||||
if (!dirty || !canRefresh()) return;
|
||||
dirty = false;
|
||||
if(app==='sms' && window.InboxView?.container && !InboxView.inEditMode) InboxView.renderThreads();
|
||||
else if(window.contacts?.List?.load) contacts.List.load(undefined,true);
|
||||
}
|
||||
addEventListener('visibilitychange', refresh);
|
||||
addEventListener('hashchange', refresh);
|
||||
addEventListener('transitionend', refresh);
|
||||
addEventListener('vulpes-service-event', ({detail}) => {
|
||||
if (detail.type !== 'android-data-changed') return;
|
||||
if (app === 'communications' && location.pathname.startsWith('/dialer')) {
|
||||
pendingCallChanges.add(detail.data.kind);
|
||||
refreshCallLog();
|
||||
return;
|
||||
}
|
||||
dirty = true;
|
||||
refresh();
|
||||
});
|
||||
|
||||
// Keep Gaia's call grouping/rendering while reading the current Android log.
|
||||
if (app === 'communications' && location.pathname.startsWith('/dialer')) {
|
||||
let value;
|
||||
Object.defineProperty(window, 'CallLogDBManager', {configurable:true,
|
||||
get:()=>value, set:db=>{
|
||||
value=db;
|
||||
const original = Object.fromEntries(['getGroupList','getGroup','getGroupAtPosition'].map(k=>[k,db[k].bind(db)]));
|
||||
const groups = async () => {
|
||||
const rows = await call({operation:'page',kind:'calls'});
|
||||
const list = new Map();
|
||||
for (const row of rows) {
|
||||
const recent = {date:row.date,number:row.number || '',type:row.type===2 ? 'dialing':'incoming',
|
||||
status:row.type===1 ? 'connected':null};
|
||||
const id=db._getGroupId(recent), key=JSON.stringify(id);
|
||||
if(!list.has(key)) list.set(key,{id,number:recent.number,serviceId:0,lastEntryDate:row.date,retryCount:0,calls:[]});
|
||||
const g=list.get(key);g.lastEntryDate=Math.max(g.lastEntryDate,row.date);g.retryCount++;
|
||||
g.calls.push({date:row.date,duration:row.duration*1000});
|
||||
}
|
||||
return [...list.values()].map(g=>{g.calls.sort((a,b)=>b.date-a.date);return db._getGroupObject(g);});
|
||||
};
|
||||
let nativeCalls=false;
|
||||
const active=async()=> (nativeCalls=!!(await call({operation:'status',kind:'calls'})).calls);
|
||||
db.getGroupList=async(callback,sortedBy,prev,cursor,limit)=>{
|
||||
try {
|
||||
if(!await active()) return original.getGroupList(callback,sortedBy,prev,cursor,limit);
|
||||
// Native groups do not live in Gaia's IndexedDB contact cache.
|
||||
if(window.CallLog)CallLog._contactCache=false;
|
||||
let list=(await groups()).sort((a,b)=>(a.lastEntryDate-b.lastEntryDate)*(prev?-1:1));
|
||||
if(limit>0)list=list.slice(0,limit);
|
||||
if(!cursor)return callback(list);
|
||||
let index=0;
|
||||
const next=()=>queueMicrotask(()=>callback({value:list[index++] || null,continue:next}));next();
|
||||
} catch(error) { failure(); callback(cursor?{value:null}:[]); }
|
||||
};
|
||||
db.getGroup=async(number,date,type,status)=>{
|
||||
if(!await active())return original.getGroup(number,date,type,status);
|
||||
const key=db._getGroupId({number,date,type,status}).join('-');
|
||||
const found=(await groups()).find(g=>g.id===key);
|
||||
if(!found)throw Error('NotFoundError');return found;
|
||||
};
|
||||
db.getGroupAtPosition=async(position,sortedBy,prev,type,callback)=>{
|
||||
if(!await active())return original.getGroupAtPosition(position,sortedBy,prev,type,callback);
|
||||
try {const list=(await groups()).filter(g=>!type||g.type===type).sort((a,b)=>(a.lastEntryDate-b.lastEntryDate)*(prev?-1:1));callback(list[position-1]||null);}
|
||||
catch(error){failure();callback(null);}
|
||||
};
|
||||
document.addEventListener('click', event=>{
|
||||
if(!nativeCalls || !event.target.closest?.('#call-log-icon-edit'))return;
|
||||
// Editing Android logs is intentionally delegated in this first integration.
|
||||
event.preventDefault();event.stopImmediatePropagation();readOnly();
|
||||
call({operation:'open',kind:'calls'}).catch(failure);
|
||||
},true);
|
||||
}});
|
||||
}
|
||||
if(app==='communications' && location.pathname.startsWith('/contacts')) {
|
||||
const timer=setInterval(()=>{
|
||||
if(!window.Contacts?.showForm)return;
|
||||
clearInterval(timer);
|
||||
const original=Contacts.showForm;
|
||||
Contacts.showForm=function(edit,contact) {
|
||||
if(edit && String(contact?.id).startsWith('android:')) {
|
||||
selectedContact=contact.id;
|
||||
call({operation:'open',kind:'contacts',id:contact.id.slice(8)}).catch(failure);
|
||||
return;
|
||||
}
|
||||
return original.apply(this,arguments);
|
||||
};
|
||||
},100);
|
||||
addEventListener('pagehide',()=>clearInterval(timer),{once:true});
|
||||
}
|
||||
function initialize() {
|
||||
if (app !== 'settings') return;
|
||||
const root=document.querySelector('#root > div');
|
||||
if(!root || document.querySelector(".vulpes-android-data"))return;
|
||||
const header=document.createElement('header');header.className='vulpes-android-data';
|
||||
const title=document.createElement('h2');title.textContent=text('Données Android','Android data');header.append(title);
|
||||
const list=document.createElement('ul');list.className='vulpes-android-data';
|
||||
const hint=document.createElement('p');hint.style.cssText='margin:1rem 1.5rem;font-size:1.4rem;line-height:1.5';
|
||||
hint.textContent=text('Lecture des contacts, SMS (hors MMS) et appels Android. Envoi, modification et suppression dans Android. Les contacts créés dans Gaia restent locaux.',
|
||||
'Read Android contacts, SMS (not MMS) and calls. Send, edit and delete in Android. Contacts created in Gaia stay local.');
|
||||
root.append(header,hint,list);
|
||||
for(const [kind,french,english] of [['contacts','Contacts','Contacts'],['sms','SMS','SMS'],['calls','Journal d’appels','Call history']]) {
|
||||
const li=document.createElement('li'),button=document.createElement('button');
|
||||
li.append(button);list.append(li);
|
||||
let enabled=false;
|
||||
const update=state=>{enabled=state[kind];button.textContent=text(french,english)+' — '+(enabled?text('Déconnecter','Disconnect'):text('Autoriser la lecture','Allow reading'));};
|
||||
call({operation:'status'}).then(update).catch(failure);
|
||||
button.onclick=async()=>{
|
||||
button.disabled=true;
|
||||
try {
|
||||
const disconnect=enabled;
|
||||
const state=await call({operation:disconnect?'disconnect':'permission',kind});update(state);
|
||||
if(!disconnect && !state[kind])notice(text('Autorisation refusée ou restreinte par Android. L’application Android reste disponible.',
|
||||
'Permission denied or restricted by Android. Your Android app is still available.'));
|
||||
} catch(error){failure();} finally{button.disabled=false;}
|
||||
};
|
||||
}
|
||||
}
|
||||
if(app==='settings') new MutationObserver(initialize).observe(document.documentElement,{childList:true,subtree:true});
|
||||
if(document.readyState==='loading')addEventListener('DOMContentLoaded',initialize,{once:true});else initialize();
|
||||
})();
|
||||
40
android/ui/settings-codec.js
Normal file
40
android/ui/settings-codec.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
// Android's extension storage/RPC is JSON. Preserve binary Gaia settings across
|
||||
// both boundaries and reconstruct Blobs in the application's own JS realm.
|
||||
(() => {
|
||||
const marker = '__vulpesBlobV1';
|
||||
async function encode(value) {
|
||||
if (value instanceof Blob) {
|
||||
if (value.size > 16 * 1024 * 1024) throw new Error('SETTING_BLOB_TOO_LARGE');
|
||||
const bytes = new Uint8Array(await value.arrayBuffer());
|
||||
const chunks = [];
|
||||
for (let i = 0; i < bytes.length; i += 16384) {
|
||||
chunks.push(String.fromCharCode(...bytes.subarray(i, i + 16384)));
|
||||
}
|
||||
return { [marker]: true, type: value.type, base64: btoa(chunks.join('')) };
|
||||
}
|
||||
if (Array.isArray(value)) return Promise.all(value.map(encode));
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
await Promise.all(
|
||||
Object.entries(value).map(async ([key, item]) => [key, await encode(item)]),
|
||||
),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
function decode(value) {
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
if (
|
||||
value[marker] === true &&
|
||||
typeof value.type === 'string' &&
|
||||
typeof value.base64 === 'string' &&
|
||||
Object.keys(value).length === 3
|
||||
) {
|
||||
const bytes = Uint8Array.from(atob(value.base64), (c) => c.charCodeAt(0));
|
||||
return new Blob([bytes], { type: value.type });
|
||||
}
|
||||
if (Array.isArray(value)) return value.map(decode);
|
||||
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, decode(item)]));
|
||||
}
|
||||
window.VulpesAndroidSettingsCodec = Object.freeze({ encode, decode });
|
||||
})();
|
||||
340
assets/settings-defaults.json
Executable file
340
assets/settings-defaults.json
Executable file
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,7 @@
|
|||
;var Pairview={_debug:false,_remoteDeviceName:null,_pairMethod:null,_options:null,_passkey:'',pairview:document.getElementById('pair-view'),nameLabel:document.getElementById('label-name'),pairDescription:document.getElementById('pair-description'),pairButton:document.getElementById('button-pair'),closeButton:document.getElementById('button-close'),comfirmationItem:document.getElementById('confirmation-method'),pinInputItem:document.getElementById('pin-input-method'),passkey:document.getElementById('passkey'),pinInput:document.getElementById('pin-input'),get isFullAttentionMode(){return(window.innerHeight>200);},show:function pv_show(){if(!this.isFullAttentionMode){this.close();return;}
|
||||
var _=navigator.mozL10n.get;this.pairButton.addEventListener('click',this);this.closeButton.addEventListener('click',this);window.addEventListener('resize',this);this._remoteDeviceName=this._remoteDeviceName||_('unnamed-device');var truncatedDeviceName=getTruncated(this._remoteDeviceName,{node:this.nameLabel,maxLine:2,ellipsisIndex:3});this.nameLabel.textContent=truncatedDeviceName;this.pairview.hidden=false;var stringName='passive-pair-'+this._pairMethod;this.pairDescription.textContent=_(stringName,{device:truncatedDeviceName});switch(this._pairMethod){case'displaypasskey':case'confirmation':this.passkey.textContent=this._passkey;this.comfirmationItem.hidden=false;this.pinInputItem.hidden=true;break;case'enterpincode':this.pinInputItem.hidden=false;this.comfirmationItem.hidden=true;this.pinInput.focus();break;case'consent':this.comfirmationItem.hidden=false;this.pinInputItem.hidden=true;break;}},init:function pv_init(method,options){this._pairMethod=method;this._options=options;this._remoteDeviceName=options.deviceName;if(options.handle&&options.handle.passkey){var passkey=options.handle.passkey;var len=passkey.toString().length;var zeros=(len<6)?(new Array((6-len)+1)).join('0'):'';this._passkey=zeros+passkey;}
|
||||
navigator.mozL10n.once(Pairview.show.bind(Pairview));},close:function pv_close(){this._options.handle.reject().then(()=>{this.debug('Resolved in reject '+this._pairMethod+' request.');window.close();},(aReason)=>{this.debug('Rejected in reject '+this._pairMethod+' request with reason: '+aReason);window.close();});},closeInput:function pv_closeInput(){if(!this.pinInputItem.hidden){this.pinInput.blur();}},handleEvent:function pv_handleEvent(evt){var _=navigator.mozL10n.get;if(!evt.target){return;}
|
||||
switch(evt.type){case'click':evt.preventDefault();switch(evt.target.id){case'button-pair':this.pairDescription.textContent=_('device-status-waiting');this.pairButton.disabled=true;this.closeButton.disabled=true;switch(this._pairMethod){case'displaypasskey':window.close();break;case'enterpincode':var pinCode=this.pinInput.value;this._options.handle.setPinCode(pinCode).then(()=>{this.debug('Resolved setPinCode operation');window.close();},(aReason)=>{this.debug('Rejected setPinCode with reason: '+aReason);window.close();});break;case'confirmation':case'consent':this._options.handle.accept().then(()=>{this.debug('Resolved in '+this._pairMethod+' request');window.close();},(aReason)=>{this.debug('Rejected in '+this._pairMethod+' request '+' with reason: '+aReason);window.close();});break;}
|
||||
break;case'button-close':this.close();break;}
|
||||
break;case'resize':if(!this.isFullAttentionMode){this.close();}
|
||||
break;default:break;}},debug:function(msg){if(this._debug){console.log('Pairview(): '+msg);}}};;function getTruncated(t,e){function i(t,e){return t!==e}var n=e.maxLine||2,l=e.node,o=e.ellipsisIndex||3,r=e.ellipsisCharacter||"...";if(null===l)return t;var s,a,c,u,d,y,h="",v=!0,p=[],f=t.slice(-o),g=l.style.visibility,x=l.style.wordBreak;l.style.visibility="hidden",l.style.wordBreak="break-all",l.textContent=".",c=l.clientHeight,l.textContent="";var C=t.split("").some(function(t,e){if(p.push(t),y=p.join(""),s=l.clientHeight,l.textContent=y+r+f,a=l.clientHeight,0===e&&(u=Math.floor(a/c)),i(s,a)&&0!==e){var o;l.textContent=y,o=l.clientHeight,i(s,o)?(v=!0,u+=1):v&&(d=e,v=!1)}return u>n?(0===e&&console.log("Your string is in a overflowed situation, please check your options"),p.pop(),l.textContent="",!0):void 0});return l.style.visibility=g,l.style.wordBreak=x,C?(h+=p.join("").slice(0,d),h+=r,h+=f):h=t,h}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,373 @@
|
|||
;window.COMPONENTS_BASE_URL="/shared/elements/";;(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.GaiaHeader=f()}})(function(){var define,module,exports;return(function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){;(function(define){define(function(require,exports,module){'use strict';var debug=0?console.log.bind(console):function(){};var cache={};var MIN=16;var MAX=24;var BUFFER=3;module.exports=function(config){debug('font fit',config);var space=config.space-BUFFER;var min=config.min||MIN;var max=config.max||MAX;var text=trim(config.text);var fontSize=max;var textWidth;var font;do{font=config.font.replace(/\d+px/,fontSize+'px');textWidth=getTextWidth(text,font);}while(textWidth>space&&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<l;i++){var el=children[i];if(el.tagName==='H1'){break;}
|
||||
if(!contributesToLayout(el)){continue;}
|
||||
els.push(el);}
|
||||
if(this.action){els.push(this.els.actionButton);}
|
||||
return els;},getButtonsAfterTitle:function(){var children=this.children;var els=[];for(var i=children.length-1;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:`<div class="inner">
|
||||
<button class="action-button">
|
||||
<content select="[l10n-action]"></content>
|
||||
</button>
|
||||
<content></content>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
-moz-user-select: none;
|
||||
|
||||
--gaia-header-button-color:
|
||||
var(--header-button-color,
|
||||
var(--header-color,
|
||||
var(--link-color,
|
||||
inherit)));
|
||||
}
|
||||
|
||||
/**
|
||||
* [hidden]
|
||||
*/
|
||||
|
||||
:host[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/** Reset
|
||||
---------------------------------------------------------*/
|
||||
|
||||
::-moz-focus-inner { border: 0; }
|
||||
|
||||
/** Inner
|
||||
---------------------------------------------------------*/
|
||||
|
||||
.inner {
|
||||
display: flex;
|
||||
min-height: 50px;
|
||||
-moz-user-select: none;
|
||||
|
||||
background:
|
||||
var(--header-background,
|
||||
var(--background,
|
||||
#fff));
|
||||
}
|
||||
|
||||
/** Action Button
|
||||
---------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* 1. Hidden by default
|
||||
*/
|
||||
|
||||
.action-button {
|
||||
position: relative;
|
||||
|
||||
display: none; /* 1 */
|
||||
width: 50px;
|
||||
font-size: 30px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
|
||||
align-items: center;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
transition: opacity 200ms 280ms;
|
||||
color:
|
||||
var(--header-action-button-color,
|
||||
var(--header-icon-color,
|
||||
var(--gaia-header-button-color)));
|
||||
}
|
||||
|
||||
/**
|
||||
* [action=back]
|
||||
* [action=menu]
|
||||
* [action=close]
|
||||
*
|
||||
* 1. For icon vertical-alignment
|
||||
*/
|
||||
|
||||
[action=back] .action-button,
|
||||
[action=menu] .action-button,
|
||||
[action=close] .action-button {
|
||||
display: flex; /* 1 */
|
||||
}
|
||||
|
||||
/**
|
||||
* :active
|
||||
*/
|
||||
|
||||
.action-button:active {
|
||||
transition: none;
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
/** Action Button Icon
|
||||
---------------------------------------------------------*/
|
||||
|
||||
.action-button:before {
|
||||
font-family: 'gaia-icons';
|
||||
font-style: normal;
|
||||
text-rendering: optimizeLegibility;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
[action=close] .action-button:before { content: 'close' }
|
||||
[action=menu] .action-button:before { content: 'menu' }
|
||||
|
||||
[action=back]:-moz-dir(ltr) .action-button:before { content: 'left' }
|
||||
[action=back]:-moz-dir(rtl) .action-button:before { content: 'right' }
|
||||
|
||||
/** Action Button Icon
|
||||
---------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* 1. To enable vertical alignment.
|
||||
*/
|
||||
|
||||
.action-button:before {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/** Action Button Text
|
||||
---------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* To provide custom localized content for
|
||||
* the action-button, we allow the user
|
||||
* to provide an element with the class
|
||||
* .l10n-action. This node is then
|
||||
* pulled inside the real action-button.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* <gaia-header action="back">
|
||||
* <span l10n-action aria-label="Back">Localized text</span>
|
||||
* <h1>title</h1>
|
||||
* </gaia-header>
|
||||
*/
|
||||
|
||||
::content [l10n-action] {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
/** Title
|
||||
---------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* 1. Vertically center text. We can't use flexbox
|
||||
* here as it breaks text-overflow ellipsis
|
||||
* without an inner div.
|
||||
*/
|
||||
|
||||
::content h1 {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
text-align: center;
|
||||
line-height: 50px; /* 1 */
|
||||
font-weight: 300;
|
||||
font-style: italic;
|
||||
font-size: 24px;
|
||||
|
||||
color:
|
||||
var(--header-title-color,
|
||||
var(--header-color,
|
||||
var(--title-color,
|
||||
var(--text-color,
|
||||
inherit))));
|
||||
}
|
||||
|
||||
/**
|
||||
* [ignore-dir]
|
||||
*
|
||||
* When the <gaia-header> component has an [ignore-dir] attribute, header
|
||||
* direction is forced to LTR but we still want the <h1> text to be reversed
|
||||
* so that strings like '1 selected' become 'selected 1'.
|
||||
*
|
||||
* When we're happy for <gaia-header> to be fully RTL responsive we won't need
|
||||
* these rules anymore, but this depends on all Gaia apps being ready.
|
||||
*
|
||||
* This should be safe to remove when bug 1179459 lands.
|
||||
*/
|
||||
|
||||
:host[ignore-dir] {
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
:host[ignore-dir]:-moz-dir(rtl) h1 {
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
/** Buttons
|
||||
---------------------------------------------------------*/
|
||||
|
||||
::content a,
|
||||
::content button {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-width: 50px;
|
||||
margin: 0;
|
||||
padding: 0 10px;
|
||||
outline: 0;
|
||||
border: 0;
|
||||
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
background: none;
|
||||
border-radius: 0;
|
||||
font-style: italic;
|
||||
cursor: pointer;
|
||||
transition: opacity 200ms 280ms;
|
||||
color: var(--gaia-header-button-color);
|
||||
}
|
||||
|
||||
/**
|
||||
* :active
|
||||
*/
|
||||
|
||||
::content a:active,
|
||||
::content button:active {
|
||||
transition: none;
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
/**
|
||||
* [hidden]
|
||||
*/
|
||||
|
||||
::content a[hidden],
|
||||
::content button[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* [disabled]
|
||||
*/
|
||||
|
||||
::content a[disabled],
|
||||
::content button[disabled] {
|
||||
pointer-events: none;
|
||||
color: var(--header-disabled-button-color);
|
||||
}
|
||||
|
||||
/** Icon Buttons
|
||||
---------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* Icons are a different color to text
|
||||
*/
|
||||
|
||||
::content .icon,
|
||||
::content [data-icon] {
|
||||
color:
|
||||
var(--header-icon-color,
|
||||
var(--gaia-header-button-color));
|
||||
}
|
||||
|
||||
/**
|
||||
* If users want their action button
|
||||
* to be in the component's light-dom
|
||||
* they can add an .action class
|
||||
* to make it look like the
|
||||
* shadow action button.
|
||||
*/
|
||||
|
||||
::content .action {
|
||||
color:
|
||||
var(--header-action-button-color,
|
||||
var(--header-icon-color,
|
||||
var(--gaia-header-button-color)));
|
||||
}
|
||||
|
||||
/**
|
||||
* [data-icon]:empty
|
||||
*
|
||||
* Icon buttons with no textContent,
|
||||
* should always be 50px.
|
||||
*
|
||||
* This is to prevent buttons being
|
||||
* larger than they should be before
|
||||
* icon-font has loaded.
|
||||
*/
|
||||
|
||||
::content [data-icon]:empty {
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
</style>`,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)});
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1 @@
|
|||
require.config({baseUrl:"/js",paths:{modules:"modules",views:"views",shared:"../shared/js"},shim:{"shared/async_storage":{exports:"asyncStorage"}}});
|
||||
1
assets/webapps/bluetooth.gaiamobile.org/js/deviceList.js
Normal file
1
assets/webapps/bluetooth.gaiamobile.org/js/deviceList.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
var gDeviceList=null;navigator.mozL10n.once(function(){function e(e){o&&console.log("[Bluetooth APP Device List]: "+e)}var t=navigator.mozL10n.get,n=window.navigator.mozSettings,i=window.navigator.mozBluetooth,d=null,o=!1;n&&i&&(gDeviceList=function(){function n(e,n){var i=document.createElement("span"),d=""===e.name?t("unnamed-device"):e.name,o=""===e.name?"unnamed-device":"";i.textContent=d,i.dataset.l10nId=o;var a=document.createElement("small");a.textContent=""===n?"":t(n),a.dataset.l10nId=n;var s=document.createElement("progress");s.classList.add("overlapping-icon"),s.classList.add("hidden");var l=document.createElement("a");l.appendChild(i),l.appendChild(a);var r=document.createElement("li");return r.classList.add("bluetooth-device"),r.classList.add("bluetooth-type-"+e.icon),r.appendChild(l),r.appendChild(s),r}function o(e){p.hidden=!e,e?(q.show(!0),x.hidden=!1):(q.show(!1),S.show(!1),x.hidden=!0,C=null,E=null,b=null,clearTimeout(I),I=null)}function a(e,t,n){f.hidden=!1,y.addEventListener("action",m),d=e,d.onpairedstatuschanged=function(e){c(e.status,"Authentication Failed",e.address)},d.ondiscoverystatechanged=function(e){e.discovering?(g.disabled=!0,x.hidden=!1):(g.disabled=!1,x.hidden=!0,clearTimeout(I),I=null)},L=t,B=n,l(),u()}function s(){f.hidden=!0,d=null,y.removeEventListener("action",m)}function l(){if(i.enabled&&d){var e=d.getPairedDevices();e.onsuccess=function(){function i(){return function(e){var i=e.address===b?"device-status-connected":"",d=n(e,i);d.onclick=function(){if(!D&&!C){v();var n=d.querySelector("small");n.textContent=t("device-status-connecting"),n.dataset.l10nId="device-status-connecting",h(e)}},S.list.appendChild(d),S.index[e.address]=[e,d]}}var d=e.result.slice(),o=d.length;if(0===o)return void S.show(!1);S.clear(),d.sort(function(e,t){return e.name>t.name});for(var a=0;o>a;a++)i()(d[a]);S.show(!0)}}}function r(i){var o=i.device,a=q.index[o.address]||S.index[o.address];if(a){var s=a[1];if(o.name&&s){var l=s.querySelector("a > span");l&&(l.dataset.l10nId="",l.textContent=o.name)}}else{var r=n(o,"device-status-tap-connect");r.onclick=function(){if(v(),!D&&!C){var n=r.querySelector("small");n.textContent=t("device-status-pairing"),n.dataset.l10nId="device-status-pairing",r.classList.add("icon-hidden");var i=r.querySelector("progress");i.classList.remove("hidden");var a=d.pair(o.address);C=o.address;var s="pairing with address = "+C;e(s),a.onerror=function(e){c(!1,a.error.name)}}},q.list.appendChild(r),q.index[o.address]=[o,r]}}function c(n,i,d){if(C){var o=C;if(C=null,n){if(q.index[o]){var a=q.index[o][0],s=q.index[o][1];q.list.removeChild(s),delete q.index[o],E=o,h(a)}}else{var r=t("error-pair-title");if("Repeated Attempts"===i?r=r+"\n"+t("error-pair-toofast"):"Authentication Failed"===i&&(r=r+"\n"+t("error-pair-pincode")),e(r),window.alert(r),q.index[o]){var c=q.index[o][1].querySelector("small");c.textContent=t("device-status-tap-connect"),c.dataset.l10nId="device-status-tap-connect";var u=q.index[o][1];u.classList.remove("icon-hidden");var v=q.index[o][1].querySelector("progress");v.classList.add("hidden")}}l()}else if(l(),d&&n&&q.index[d]){var m=q.index[d][1];q.list.removeChild(m),delete q.index[d]}}function u(){if(i.enabled&&d&&!I){var e=d.startDiscovery();e.onsuccess=function(){I||(I=setTimeout(v,w))},e.onerror=function(){console.error("Can not discover nearby device")}}}function v(){if(i.enabled&&d&&I){var e=d.stopDiscovery();e.onerror=function(){console.error("Failed to stop discovery of nearby devices")},clearTimeout(I),I=null}}function h(e){D=!0,L&&L(e)}function m(){B&&B()}var f=document.getElementById("devices-list-view"),p=document.getElementById("bluetooth-search"),g=document.getElementById("search-device"),x=document.getElementById("bluetooth-searching"),y=document.getElementById("devices-list-header"),C=null,E=null,b=null,w=6e4,I=null,L=null,B=null,D=!1,S={title:document.getElementById("bluetooth-paired-title"),list:document.getElementById("bluetooth-paired-devices"),index:[],clear:function(){for(;this.list.hasChildNodes();)this.list.removeChild(this.list.lastChild);this.index=[]},show:function(e){e||this.clear(),this.title.hidden=!e,this.list.hidden=!e}},q={title:document.getElementById("bluetooth-found-title"),list:document.getElementById("bluetooth-devices"),index:[],clear:function(){for(;this.list.hasChildNodes();)this.list.removeChild(this.list.lastChild);this.index=[]},show:function(e){e||this.clear(),this.title.hidden=!e,this.list.hidden=!e}};return g.onclick=function(){D||C||(o(!0),q.clear(),u())},{update:o,initWithAdapter:a,uninit:s,startDiscovery:u,onDeviceFound:r}}())});
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
/**
|
||||
* BluetoothAdapterManager:
|
||||
* - BluetoothAdapterManager is an Observable that wraps the platform
|
||||
* Bluetooth object for construct default adapter.
|
||||
* - It has only one observable properties: defaultAdapter
|
||||
* BluetoothAdapterManager only update state and does not involve in any UI
|
||||
* logic.
|
||||
*
|
||||
* @module BluetoothAdapterManager
|
||||
*/
|
||||
define(['require','modules/navigator/mozBluetooth','modules/mvvm/observable'],function(require) {
|
||||
'use strict';
|
||||
|
||||
var NavigatorBluetooth = require('modules/navigator/mozBluetooth');
|
||||
var Observable = require('modules/mvvm/observable');
|
||||
|
||||
var _debug = false;
|
||||
var debug = function() {};
|
||||
if (_debug) {
|
||||
debug = function btam_debug(msg) {
|
||||
console.log('--> [BluetoothAdapterManager]: ' + msg);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @alias module:modules/bluetooth/BluetoothAdapterManager
|
||||
* @requires module:modules/navigator/mozBluetooth
|
||||
* @requires module:modules/mvvm/observable
|
||||
* @return {BluetoothAdapterManager}
|
||||
*/
|
||||
var BluetoothAdapterManager = {
|
||||
/**
|
||||
* The default adapter used to connect to the remote bluetooth devices.
|
||||
*
|
||||
* @readonly
|
||||
* @memberOf BluetoothAdapterManager
|
||||
* @type {Object}
|
||||
*/
|
||||
defaultAdapter: null,
|
||||
|
||||
/**
|
||||
* Init Bluetooth module.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf BluetoothAdapterManager
|
||||
*/
|
||||
_init: function btam__init() {
|
||||
// Early return while there is no navigator.mozBluetooth module.
|
||||
if (!NavigatorBluetooth) {
|
||||
return;
|
||||
}
|
||||
|
||||
// watch BluetoothManager event
|
||||
this._watchMozBluetoothAttributechanged();
|
||||
|
||||
// init default adapter
|
||||
this._initDefaultAdapter();
|
||||
},
|
||||
|
||||
/**
|
||||
* Watch 'attributechanged' event from mozBluetooth for updating default
|
||||
* adapter information.
|
||||
*
|
||||
* 'attributechanged' event description:
|
||||
* A handler to trigger when bluetooth manager's only property
|
||||
* defaultAdapter has changed.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf BluetoothAdapterManager
|
||||
*/
|
||||
_watchMozBluetoothAttributechanged:
|
||||
function btam__watchMozBluetoothAttributechanged() {
|
||||
NavigatorBluetooth.addEventListener('attributechanged', (evt) => {
|
||||
for (var i in evt.attrs) {
|
||||
switch (evt.attrs[i]) {
|
||||
case 'defaultAdapter':
|
||||
// Default adapter attribute change.
|
||||
// Usually, it means that we reach new default adapter.
|
||||
this.defaultAdapter = NavigatorBluetooth.defaultAdapter;
|
||||
debug('_watchMozBluetoothAttributechanged(): ' +
|
||||
'this.defaultAdapter = ' + this.defaultAdapter);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/* Reach default adapter from platform Bluetooth.
|
||||
*
|
||||
* If the property is accessed on platform in the first time, it will
|
||||
* trigger Gecko::Bluetooth to get default adapter from the chipset.
|
||||
* So the value would be null.
|
||||
* Then we will receive 'attributechanged' event later.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf BluetoothAdapterManager
|
||||
*/
|
||||
_initDefaultAdapter: function btam__initDefaultAdapter() {
|
||||
this.defaultAdapter = NavigatorBluetooth.defaultAdapter;
|
||||
debug('_initDefaultAdapter(): this.defaultAdapter = ' +
|
||||
this.defaultAdapter);
|
||||
}
|
||||
};
|
||||
|
||||
// Create the observable object using the prototype.
|
||||
var bluetoothAdapterManager = Observable(BluetoothAdapterManager);
|
||||
bluetoothAdapterManager._init();
|
||||
return bluetoothAdapterManager;
|
||||
});
|
||||
|
|
@ -0,0 +1 @@
|
|||
define([],function(){"use strict";var e=!1,o=function(){};e&&(o=function(e){console.log("--> [BluetoothCODMapper]: "+e)});var i={Computer:{0:"computer",1:"computer",2:"computer",3:"computer",4:"pda",5:"pda",6:"computer",7:"computer",reserved:"computer"},Phone:{0:"phone",1:"phone",2:"phone",3:"phone",4:"modem",5:"phone",reserved:"phone"},AudioVideo:{0:"audio-card",1:"audio-card",2:"audio-card",4:"audio-input-microphone",5:"audio-card",6:"audio-card",7:"audio-card",8:"audio-card",9:"audio-card",10:"audio-card",11:"camera-video",12:"camera-video",13:"camera-video",14:"video-display",15:"video-display",16:"video-display",18:"audio-card",reserved:"audio-card"},Peripheral:{1:"input-gaming",2:"input-gaming",16:"input-keyboard",17:"input-keyboard",18:"input-keyboard",19:"input-keyboard",20:"input-keyboard",21:"input-keyboard",22:"input-keyboard",23:"input-keyboard",24:"input-keyboard",25:"input-keyboard",26:"input-keyboard",27:"input-keyboard",28:"input-keyboard",29:"input-keyboard",30:"input-keyboard",31:"input-keyboard",32:"input-mouse",33:"input-mouse",34:"input-mouse",35:"input-mouse",36:"input-mouse",37:"input-tablet",38:"input-mouse",39:"input-mouse",40:"input-mouse",41:"input-mouse",42:"input-mouse",43:"input-mouse",44:"input-mouse",45:"input-mouse",46:"input-mouse",47:"input-mouse"},Imaging:{4:"video-display",8:"camera-photo",16:"scanner",32:"printer"}},r={1:i.Computer,2:i.Phone,3:"network-wireless",4:i.AudioVideo,5:i.Peripheral,6:i.Imaging},a={getDeviceType:function(e){o("cod.majorDeviceClass = "+e.majorDeviceClass),o("cod.majorServiceClass = "+e.majorServiceClass),o("cod.minorDeviceClass = "+e.minorDeviceClass);var i="",a=r[e.majorDeviceClass];return o("majorDeviceClass = "+a),"object"==typeof a?(i=a[e.minorDeviceClass]||a.reserved||"",o("return in minor class, type = "+i),i):"string"==typeof a?(o("return in major class, type = network-wireless"),a):256&e.majorServiceClass?(i="audio-card",o("return in major service class, type = audio-card"),i):(o("not mapping in any class, return type = "+i),i)}};return a});
|
||||
|
|
@ -0,0 +1,953 @@
|
|||
/**
|
||||
* BluetoothConnectionManager:
|
||||
* BluetoothConnectionManager only update state and does not involve in any UI
|
||||
* logic.
|
||||
*
|
||||
* @module BluetoothConnectionManager
|
||||
*/
|
||||
define(['require','modules/bluetooth/bluetooth_adapter_manager','shared/async_storage'],function(require) {
|
||||
'use strict';
|
||||
|
||||
var AdapterManager = require('modules/bluetooth/bluetooth_adapter_manager');
|
||||
var AsyncStorage = require('shared/async_storage');
|
||||
|
||||
var _debug = false;
|
||||
var debug = function() {};
|
||||
if (_debug) {
|
||||
debug = function btam_debug(msg) {
|
||||
console.log('--> [BluetoothConnectionManager]: ' + msg);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @alias module:modules/bluetooth/BluetoothConnectionManager
|
||||
* @requires module:modules/bluetooth/bluetooth_adapter_manager
|
||||
* @requires module:shared/async_storage
|
||||
* @return {BluetoothConnectionManager}
|
||||
*/
|
||||
var BluetoothConnectionManager = {
|
||||
/**
|
||||
* The profiles of connected device that we are defined here.
|
||||
*
|
||||
* @public
|
||||
* @memberOf BluetoothConnectionManager
|
||||
* @type {Object}
|
||||
*/
|
||||
Profiles: {
|
||||
'hfp': 0x111E, // Handsfree
|
||||
'a2dp': 0x110D // Advanced Audio Distribution Devices
|
||||
},
|
||||
|
||||
/**
|
||||
* The address of device that we are trying to connect.
|
||||
*
|
||||
* @public
|
||||
* @memberOf BluetoothConnectionManager
|
||||
* @type {String}
|
||||
*/
|
||||
connectingAddress: null,
|
||||
|
||||
/**
|
||||
* A object that we cache the connected devices information(address, device,
|
||||
* connectedProfiles). It will be inited while default adapter is ready.
|
||||
* And these information is coming from profile events.
|
||||
* Each connected device is hashed by device address.
|
||||
*
|
||||
* EX:
|
||||
* _connectedDevicesInfo = {
|
||||
* 'AA:BB:CC:00:11:22': {
|
||||
* 'device': DeviceObject,
|
||||
* 'connectedProfiles': {
|
||||
* 'hfp': true,
|
||||
* 'a2dp': false
|
||||
* }
|
||||
* }
|
||||
* };
|
||||
*
|
||||
* @private
|
||||
* @memberOf BluetoothConnectionManager
|
||||
* @type {Object}
|
||||
*/
|
||||
_connectedDevicesInfo: {},
|
||||
|
||||
/**
|
||||
* An instance to maintain that we have created a promise to get connected
|
||||
* devices.
|
||||
*
|
||||
* @access public
|
||||
* @memberOf BluetoothConnectionManager
|
||||
* @return {Promise}
|
||||
*/
|
||||
_getConnectedDevicesPromise: null,
|
||||
|
||||
/**
|
||||
* The object maintains listeners' callback per property name.
|
||||
* Each listener would be called as following definition.
|
||||
* 'connecting' - be called when device is connecting.
|
||||
* 'connected': - be called when device is connected.
|
||||
* 'disconnected': - be called when device is disconnected.
|
||||
* 'profileChanged': - be called when profile is changed.
|
||||
*
|
||||
* @memberOf BluetoothConnectionManager
|
||||
* @type {Object}
|
||||
*/
|
||||
_listeners: {
|
||||
'connecting': [],
|
||||
'connected': [],
|
||||
'disconnected': [],
|
||||
'profileChanged': []
|
||||
},
|
||||
|
||||
/**
|
||||
* Default adapter of Bluetooth.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf BluetoothConnectionManager
|
||||
* @type {Object} BluetoothAdapter
|
||||
*/
|
||||
_defaultAdapter: null,
|
||||
|
||||
/**
|
||||
* Init BluetoothConnectionManager module.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf BluetoothConnectionManager
|
||||
*/
|
||||
_init: function btcm__init() {
|
||||
// Observe 'defaultAdapter' property for reaching default adapter.
|
||||
AdapterManager.observe('defaultAdapter',
|
||||
this._onDefaultAdapterChanged.bind(this));
|
||||
this._onDefaultAdapterChanged(AdapterManager.defaultAdapter);
|
||||
},
|
||||
|
||||
/**
|
||||
* 'defaultAdapter' change event handler from adapter manager for
|
||||
* updating adapter immediately.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf BluetoothConnectionManager
|
||||
* @param {Object} BluetoothAdapter newAdapter
|
||||
* @param {Object} BluetoothAdapter oldAdapter
|
||||
*/
|
||||
_onDefaultAdapterChanged:
|
||||
function btcm__onDefaultAdapterChanged(newAdapter, oldAdapter) {
|
||||
debug('_onDefaultAdapterChanged(): newAdapter = ' + newAdapter);
|
||||
debug('_onDefaultAdapterChanged(): oldAdapter = ' + oldAdapter);
|
||||
|
||||
// save default adapter
|
||||
this._defaultAdapter = newAdapter;
|
||||
|
||||
if (oldAdapter) {
|
||||
// unwatch event since the old adapter is no longer usefull
|
||||
this._unwatchProfilesStatuschanged(oldAdapter);
|
||||
this._unwatchDefaultAdapterOnattributechanged(oldAdapter);
|
||||
}
|
||||
|
||||
if (newAdapter) {
|
||||
// watch event since the new adapter is ready to access
|
||||
this._watchProfilesStatuschanged(newAdapter);
|
||||
this._watchDefaultAdapterOnattributechanged(newAdapter);
|
||||
// restore connection
|
||||
if (newAdapter.state === 'enabled') {
|
||||
this._restoreConnection();
|
||||
}
|
||||
} else {
|
||||
// reset properties only
|
||||
this._resetConnectionInfo();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Return the cache of connected devices in ConnectionManager.
|
||||
*
|
||||
* @access public
|
||||
* @memberOf BluetoothConnectionManager
|
||||
* @return {Promise}
|
||||
*/
|
||||
getConnectedDevices: function btcm_getConnectedDevices() {
|
||||
if (!this._getConnectedDevicesPromise) {
|
||||
this._getConnectedDevicesPromise =
|
||||
this._initConnectedDevicesInfo().then(() => {
|
||||
debug('getConnectedDevices(): resolved with latest cache = ' +
|
||||
JSON.stringify(this._connectedDevicesInfo));
|
||||
return this._connectedDevicesInfo;
|
||||
}, (reason) => {
|
||||
debug('getConnectedDevices(): rejected with reason = ' + reason);
|
||||
this._getConnectedDevicesPromise = null;
|
||||
});
|
||||
}
|
||||
return this._getConnectedDevicesPromise;
|
||||
},
|
||||
|
||||
/**
|
||||
* Init cache of connected device and save it in cache.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf BluetoothConnectionManager
|
||||
* @return {Promise}
|
||||
*/
|
||||
_initConnectedDevicesInfo: function btcm__initConnectedDevicesInfo() {
|
||||
if (!this._defaultAdapter) {
|
||||
return Promise.reject('default adapter is not existed!!');
|
||||
}
|
||||
// Init connection status and profile from platform.
|
||||
// Then, save these connected device information in cache.
|
||||
return this._getConnectedDevicesFromPlatform().then(
|
||||
(connectedDevicesByProfile) => {
|
||||
this._constructDeviceItemsMap(connectedDevicesByProfile);
|
||||
}, (reason) => {
|
||||
debug('_initConnectedDevicesInfo(): rejected in ' +
|
||||
'_getConnectedDevicesFromPlatform');
|
||||
return Promise.reject(reason);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* The method will update each device item in maintaining map.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf BluetoothConnectionManager
|
||||
* @param {Object} connectedDevices
|
||||
*/
|
||||
_constructDeviceItemsMap:
|
||||
function btcm__constructDeviceItemsMap(connectedDevices) {
|
||||
debug('_constructDeviceItemsMap(): connectedDevices = ' +
|
||||
JSON.stringify(connectedDevices));
|
||||
if (!connectedDevices) {
|
||||
// Return empty object while there is no any connected devices.
|
||||
debug('_constructDeviceItemsMap(): early return with empty object');
|
||||
return;
|
||||
}
|
||||
|
||||
Object.keys(this.Profiles).map((profileID) => {
|
||||
connectedDevices[profileID].forEach((connectedDevice) => {
|
||||
var connectionDeviceInfo = {
|
||||
address: connectedDevice.address,
|
||||
connected: true,
|
||||
profileID: profileID,
|
||||
device: connectedDevice
|
||||
};
|
||||
debug('_constructDeviceItemsMap(): connectionDeviceInfo = ' +
|
||||
JSON.stringify(connectionDeviceInfo));
|
||||
this._initConnectedDevicesCache(connectionDeviceInfo);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Init the cache which is maintained for connection devices.
|
||||
* And the input is gotten from platform API adapter.getConnectedDevices().
|
||||
*
|
||||
* @access private
|
||||
* @memberOf BluetoothConnectionManager
|
||||
* @param {Object} options
|
||||
* @param {String} options.address - address of the device
|
||||
* @param {Boolean} options.connected - is connected or not
|
||||
* @param {Object} options.profileID - profile ID of the connection type
|
||||
* @param {Object} options.device - connect device, Bluetooth Object
|
||||
*/
|
||||
_initConnectedDevicesCache:
|
||||
function btcm__initConnectedDevicesCache(options) {
|
||||
debug('_initConnectedDevicesCache(): options = ' +
|
||||
JSON.stringify(options));
|
||||
// hash by device address
|
||||
var info = this._connectedDevicesInfo[options.address];
|
||||
if (info) {
|
||||
// Already have profiles, update it for other profile.
|
||||
info.connectedProfiles[options.profileID] = options.connected;
|
||||
} else {
|
||||
// Not have profiles yet, create for it.
|
||||
// If options.device is existed, save the connected device.
|
||||
// Otherwise, given null in this property.
|
||||
var connectedDevice = (options.device) ? options.device : null;
|
||||
info = {
|
||||
'device': connectedDevice,
|
||||
'connectedProfiles': {}
|
||||
};
|
||||
info.connectedProfiles[options.profileID] = options.connected;
|
||||
}
|
||||
// Save the device/profile in map.
|
||||
this._connectedDevicesInfo[options.address] = info;
|
||||
|
||||
// If there is no profile connected,
|
||||
// remove the device item from cache since it is already disconnected.
|
||||
var dataToCheckConnectedProfile = {
|
||||
address: options.address,
|
||||
connectedDevices: this._connectedDevicesInfo
|
||||
};
|
||||
if (!this._hasConnectedProfileByAddress(dataToCheckConnectedProfile)) {
|
||||
delete this._connectedDevicesInfo[options.address];
|
||||
}
|
||||
// Return the latest cache which is just updated here.
|
||||
debug('_initConnectedDevicesCache(): this._connectedDevicesInfo = ' +
|
||||
JSON.stringify(this._connectedDevicesInfo));
|
||||
return this._connectedDevicesInfo;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the cache which is maintained for connection devices.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf BluetoothConnectionManager
|
||||
* @param {Object} options
|
||||
* @param {String} options.address - address of the device
|
||||
* @param {Boolean} options.connected - is connected or not
|
||||
* @param {Object} options.profileID - profile ID of the connection type
|
||||
* @param {Object} options.device - connect device, Bluetooth Object
|
||||
*/
|
||||
_updateConnectedDevices:
|
||||
function btcm__updateConnectedDevices(options) {
|
||||
return this.getConnectedDevices().then((connectedDevicesInfo) => {
|
||||
debug('_updateConnectedDevices(): connectedDevicesInfo = ' +
|
||||
JSON.stringify(connectedDevicesInfo));
|
||||
|
||||
// hash by device address
|
||||
var info =
|
||||
(connectedDevicesInfo) ? connectedDevicesInfo[options.address] : null;
|
||||
if (info) {
|
||||
// Already have profiles, update it for other profile.
|
||||
info.connectedProfiles[options.profileID] = options.connected;
|
||||
} else {
|
||||
// Not have profiles yet, create for it.
|
||||
// If options.device is existed, save the connected device.
|
||||
// Otherwise, given null in this property.
|
||||
var connectedDevice = (options.device) ? options.device : null;
|
||||
info = {
|
||||
'device': connectedDevice,
|
||||
'connectedProfiles': {}
|
||||
};
|
||||
info.connectedProfiles[options.profileID] = options.connected;
|
||||
}
|
||||
// Save the device/profile in map.
|
||||
this._connectedDevicesInfo[options.address] = info;
|
||||
|
||||
// If there is no profile connected,
|
||||
// remove the device item from cache since it is already disconnected.
|
||||
var dataToCheckConnectedProfile = {
|
||||
address: options.address,
|
||||
connectedDevices: this._connectedDevicesInfo
|
||||
};
|
||||
if (!this._hasConnectedProfileByAddress(dataToCheckConnectedProfile)) {
|
||||
delete this._connectedDevicesInfo[options.address];
|
||||
}
|
||||
// Return the latest cache which is just updated here.
|
||||
debug('_updateConnectedDevices(): this._connectedDevicesInfo = ' +
|
||||
JSON.stringify(this._connectedDevicesInfo));
|
||||
return Promise.resolve(this._connectedDevicesInfo);
|
||||
}, () => {
|
||||
debug('_updateConnectedDevices(): rejected with some exception');
|
||||
return Promise.reject('rejected with some exception');
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Only reset properties since there is no available default adapter.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf BluetoothConnectionManager
|
||||
*/
|
||||
_resetConnectionInfo: function btcm__resetConnectionInfo() {
|
||||
// Reset connection status.
|
||||
this.connectingAddress = null;
|
||||
// Clean up the instance to get connected devices
|
||||
// while new adapter is ready.
|
||||
this._getConnectedDevicesPromise = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Watch 'onattributechanged' event from default adapter for watching
|
||||
* adapter enabled/disabled status.
|
||||
*
|
||||
* Description of 'onattributechanged' event:
|
||||
* A handler to trigger when one of the local bluetooth adapter's properties
|
||||
* has changed. Note access to the changed property in this event handler
|
||||
* would get the updated value.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf BluetoothConnectionManager
|
||||
* @param {Object} BluetoothAdapter adapter
|
||||
*/
|
||||
_watchDefaultAdapterOnattributechanged:
|
||||
function btcm__watchDefaultAdapterOnattributechanged(adapter) {
|
||||
adapter.addEventListener('attributechanged',
|
||||
this._onAdapterAttributeChanged.bind(this, adapter));
|
||||
},
|
||||
|
||||
/**
|
||||
* Unwatch 'onattributechanged' event from default adapter since adapter is
|
||||
* removed.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf BluetoothConnectionManager
|
||||
* @param {Object} BluetoothAdapter adapter
|
||||
*/
|
||||
_unwatchDefaultAdapterOnattributechanged:
|
||||
function btcm__unwatchDefaultAdapterOnattributechanged(adapter) {
|
||||
adapter.removeEventListener('attributechanged',
|
||||
this._onAdapterAttributeChanged);
|
||||
},
|
||||
|
||||
/**
|
||||
* 'onattributechanged' event handler from default adapter for reaching
|
||||
* adapter enabled/disabled status.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf BluetoothConnectionManager
|
||||
* @param {Object} BluetoothAdapter adapter
|
||||
* @param {event} evt
|
||||
*/
|
||||
_onAdapterAttributeChanged:
|
||||
function btcm__onAdapterAttributeChanged(adapter, evt) {
|
||||
for (var i in evt.attrs) {
|
||||
debug('_onAdapterAttributeChanged(): ' + evt.attrs[i]);
|
||||
switch (evt.attrs[i]) {
|
||||
case 'state':
|
||||
if (adapter.state === 'enabled') {
|
||||
// Restore connection while default adapter state is enabled.
|
||||
this._restoreConnection();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Watch every of profile events('onhfpstatuschanged','ona2dpstatuschanged')
|
||||
* from default adapter for updating device connected status immediately.
|
||||
*
|
||||
* Description of 'onhfpstatuschanged' event:
|
||||
* Specifies an event listener to receive hfpstatuschanged events.
|
||||
* Those events occur when an HFP connection status changes.
|
||||
*
|
||||
* Description of 'ona2dpstatuschanged' event:
|
||||
* Specifies an event listener to receive a2dpstatuschanged events.
|
||||
* Those events occur when an A2DP connection status changes.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf BluetoothConnectionManager
|
||||
* @param {Object} BluetoothAdapter adapter
|
||||
*/
|
||||
_watchProfilesStatuschanged:
|
||||
function btcm__watchProfilesStatuschanged(adapter) {
|
||||
var eventName;
|
||||
for (var profileID in this.Profiles) {
|
||||
eventName = 'on' + profileID + 'statuschanged';
|
||||
adapter[eventName] =
|
||||
this._onProfileStatuschangeHandler.bind(this, profileID);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Unwatch every of profile events('onhfpstatuschanged',
|
||||
* 'ona2dpstatuschanged') from default adapter since adapter is removed.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf BluetoothConnectionManager
|
||||
* @param {Object} BluetoothAdapter adapter
|
||||
*/
|
||||
_unwatchProfilesStatuschanged:
|
||||
function btc__unwatchProfilesStatuschanged(adapter) {
|
||||
var eventName;
|
||||
for (var profileID in this.Profiles) {
|
||||
eventName = 'on' + profileID + 'statuschanged';
|
||||
adapter[eventName] = null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 'onhfpstatuschanged', 'ona2dpstatuschanged' events handler from
|
||||
* default adapter for updating device connected status.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf BluetoothConnectionManager
|
||||
* @param {String} profileID
|
||||
* @param {event} evt
|
||||
*/
|
||||
_onProfileStatuschangeHandler:
|
||||
function btcm__onProfileStatuschangeHandler(profileID, evt) {
|
||||
debug('_onProfileStatuschangeHandler(): profileID = ' + profileID +
|
||||
', evt.address = ' + evt.address + ', evt.status = ' + evt.status);
|
||||
var options = {
|
||||
address: evt.address,
|
||||
connected: evt.status,
|
||||
profileID: profileID
|
||||
};
|
||||
// Update connection status.
|
||||
this._updateConnectionStatus(options);
|
||||
},
|
||||
|
||||
/**
|
||||
* Query async storage to restore connection.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf BluetoothConnectionManager
|
||||
*/
|
||||
_restoreConnection: function btcm__restoreConnection() {
|
||||
if (!this._defaultAdapter) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reconnect the one kept in the async storage.
|
||||
AsyncStorage.getItem('device.connected', (address) => {
|
||||
if (!address) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Make sure the restore device is already connected or not.
|
||||
this.getConnectedDevices().then((connectedDevices) => {
|
||||
if (connectedDevices && connectedDevices[address]) {
|
||||
// Do an early return since the restore device is connected already.
|
||||
debug('_restoreConnection(): early return cause connected already');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the device which is wanted to connect from paired devices.
|
||||
var restoreDevice = this._getPairedDeviceByAddress(address);
|
||||
if (restoreDevice) {
|
||||
this.connectingAddress = restoreDevice.address;
|
||||
// Fire 'connecting' event.
|
||||
var event = {
|
||||
type: 'connecting',
|
||||
detail: {
|
||||
address: restoreDevice.address
|
||||
}
|
||||
};
|
||||
this._emitEvent(event);
|
||||
this._connect(restoreDevice).then(() => {
|
||||
debug('_restoreConnection(): restore connection successfully');
|
||||
}, (reason) => {
|
||||
debug('_restoreConnection(): restore connection failed, ' +
|
||||
'reason = ' + reason);
|
||||
// No available profiles are connected. Reset connecting address.
|
||||
this.connectingAddress = null;
|
||||
// Then, fire 'disconnected' event.
|
||||
event = {
|
||||
type: 'disconnected',
|
||||
detail: {
|
||||
address: restoreDevice.address
|
||||
}
|
||||
};
|
||||
this._emitEvent(event);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Record connected device so if Bluetooth is turned off and then on
|
||||
* we can restore the connection.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf BluetoothConnectionManager
|
||||
* @param {String} action - to set or remove item for recording connection
|
||||
* @param {String} address - the address of connected device
|
||||
*/
|
||||
_recordConnection: function btcm__recordConnection(action, address) {
|
||||
if (action === 'set') {
|
||||
// record connected device so if Bluetooth is turned off and then on
|
||||
// we can restore the connection
|
||||
AsyncStorage.setItem('device.connected', address);
|
||||
debug('_recordConnection(): set item');
|
||||
} else if ((action === 'remove') &&
|
||||
(this._defaultAdapter.state === 'enabled')) {
|
||||
// Clean up the connected device from async storage
|
||||
// which is recorded before.
|
||||
// Only remove the record while Bluetooth state is enabled.
|
||||
// Because the request also comes while Bluetooth is turned off.
|
||||
AsyncStorage.removeItem('device.connected');
|
||||
debug('_recordConnection(): remove item');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update connection status.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf BluetoothConnectionManager
|
||||
* @param {Object} options
|
||||
* @param {String} options.address - address of the device
|
||||
* @param {Boolean} options.connected - is connected or not
|
||||
* @param {Object} options.profileID - profile ID of the connection type
|
||||
*/
|
||||
_updateConnectionStatus: function btcm___updateConnectionStatus(options) {
|
||||
debug('_updateConnectionStatus(): address = ' + options.address +
|
||||
', connected = ' + options.connected +
|
||||
', profileID = ' + options.profileID);
|
||||
this.connectingAddress = null;
|
||||
|
||||
// Update the profile in the cache of connected device info.
|
||||
this._updateConnectedDevices(options).then((connectedDevicesInfo) => {
|
||||
debug('_updateConnectionStatus(): _updateConnectedDevices() ' +
|
||||
'resolved with connectedDevicesInfo = ' +
|
||||
JSON.stringify(connectedDevicesInfo));
|
||||
// Prepare latest data to check connected profile.
|
||||
var dataToCheckConnectedProfile = {
|
||||
address: options.address,
|
||||
connectedDevices: connectedDevicesInfo
|
||||
};
|
||||
// Fire 'connected'/'disconnected' event according to
|
||||
// the connection profile. Then, record connection.
|
||||
var event;
|
||||
if (options.connected) {
|
||||
// Fire 'connected' event.
|
||||
event = {
|
||||
type: 'connected',
|
||||
detail: {
|
||||
address: options.address
|
||||
}
|
||||
};
|
||||
this._emitEvent(event);
|
||||
// Record connection.
|
||||
this._recordConnection('set', options.address);
|
||||
} else {
|
||||
// If there is no profile connected,
|
||||
// we have to remove the record connection.
|
||||
// And fire 'disconnected' event for outer modules.
|
||||
if (!this._hasConnectedProfileByAddress(
|
||||
dataToCheckConnectedProfile)) {
|
||||
// Record connection. Only remove the record while Bluetooth state
|
||||
// is enabled. Because the event also comes while Bluetooth is
|
||||
// turned off.
|
||||
if (this._defaultAdapter.state === 'enabled') {
|
||||
this._recordConnection('remove');
|
||||
}
|
||||
// Fire 'disconnected' event.
|
||||
event = {
|
||||
type: 'disconnected',
|
||||
detail: {
|
||||
address: options.address
|
||||
}
|
||||
};
|
||||
this._emitEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
// Fire 'profileChanged' event.
|
||||
var newProfiles;
|
||||
if (!this._hasConnectedProfileByAddress(dataToCheckConnectedProfile)) {
|
||||
newProfiles = null;
|
||||
} else {
|
||||
newProfiles = connectedDevicesInfo[options.address].connectedProfiles;
|
||||
}
|
||||
event = {
|
||||
type: 'profileChanged',
|
||||
detail: {
|
||||
address: options.address,
|
||||
profiles: newProfiles
|
||||
}
|
||||
};
|
||||
this._emitEvent(event);
|
||||
}, (reason) => {
|
||||
debug('_updateConnectionStatus(): miss to update in rejected case, ' +
|
||||
'reason = ' + reason);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* It provides a convenient function for panel to connect with a device.
|
||||
* And the panel no need to care about connected device currently.
|
||||
* The method will disconnect current connected device first.
|
||||
* Then, it will start connecting a paired device with the device's adapter.
|
||||
*
|
||||
* @access public
|
||||
* @memberOf BluetoothConnectionManager
|
||||
* @param {BluetoothDevice} device
|
||||
* @return {Promise}
|
||||
*/
|
||||
connect: function btcm_connect(device) {
|
||||
if (!this._defaultAdapter) {
|
||||
return Promise.reject('default adapter is not existed!!');
|
||||
}
|
||||
|
||||
// Disconnect current connected device first.
|
||||
debug('connect(): Want to connect device address = ' + device.address +
|
||||
', name = ' + device.name);
|
||||
|
||||
var connectedDevices = [];
|
||||
return this.getConnectedDevices().then((connectedDevicesInfo) => {
|
||||
for (var address in connectedDevicesInfo) {
|
||||
if (connectedDevicesInfo[address].device !== null) {
|
||||
connectedDevices.push(connectedDevicesInfo[address].device);
|
||||
debug('connect(): push device cache in queue = ' +
|
||||
JSON.stringify(connectedDevices));
|
||||
} else {
|
||||
var regetPairedDevice = this._getPairedDeviceByAddress(address);
|
||||
connectedDevices.push(regetPairedDevice);
|
||||
debug('connect(): push _getPairedDeviceByAddress in queue = ' +
|
||||
JSON.stringify(regetPairedDevice));
|
||||
}
|
||||
}
|
||||
|
||||
debug('connect(): Will disconnect these connected devices = ' +
|
||||
JSON.stringify(connectedDevices));
|
||||
|
||||
// Disconnect these connected device before
|
||||
// service to connect with new device.
|
||||
return Promise.all(connectedDevices.map((connectedDevice) => {
|
||||
return this.disconnect(connectedDevice);
|
||||
})).then(() => {
|
||||
// All connected devices is disconnected.
|
||||
// We can start to connect the new request.
|
||||
debug('connect(): Start to connect with wanted device address = ' +
|
||||
device.address);
|
||||
return this._connect(device).then(() => {
|
||||
debug('connect(): Resolved');
|
||||
}, (reason) => {
|
||||
debug('connect(): reason = ' + reason);
|
||||
return Promise.reject(reason);
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* The method will connect the input device with the device's adapter.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf BluetoothConnectionManager
|
||||
* @param {BluetoothDevice} device
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_connect: function btcm__connect(device) {
|
||||
if (!this._defaultAdapter) {
|
||||
return Promise.reject('default adapter is not existed!!');
|
||||
}
|
||||
|
||||
// Save the connecting address
|
||||
this.connectingAddress = device.address;
|
||||
// Fire 'connecting' event.
|
||||
var event = {
|
||||
type: 'connecting',
|
||||
detail: {
|
||||
address: device.address
|
||||
}
|
||||
};
|
||||
this._emitEvent(event);
|
||||
debug('_connect(): before start to connect, stop discovery first');
|
||||
|
||||
// Note on Bluedroid stack, discovery has to be stopped before connect
|
||||
// (i.e., call stopDiscovery() before connect())
|
||||
// otherwise stack callbacks with connect failure.
|
||||
return this._defaultAdapter.stopDiscovery().then(() => {
|
||||
debug('_connect(): start connecting device, ' +
|
||||
'address = ' + device.address);
|
||||
return this._defaultAdapter.connect(device).then(() => {
|
||||
debug('_connect(): resolve, already connected with address = ' +
|
||||
device.address);
|
||||
}, () => {
|
||||
// No available profiles are connected. Reset connecting address.
|
||||
this.connectingAddress = null;
|
||||
// Fire 'disconnected' event.
|
||||
event = {
|
||||
type: 'disconnected',
|
||||
detail: {
|
||||
address: device.address
|
||||
}
|
||||
};
|
||||
this._emitEvent(event);
|
||||
debug('_connect(): reject with connection failed');
|
||||
return Promise.reject('connection failed');
|
||||
});
|
||||
}, () => {
|
||||
// Cannot connect with the device since stopDiscovery failed.
|
||||
this.connectingAddress = null;
|
||||
// Fire 'disconnected' event.
|
||||
event = {
|
||||
type: 'disconnected',
|
||||
detail: {
|
||||
address: device.address
|
||||
}
|
||||
};
|
||||
this._emitEvent(event);
|
||||
debug('_connect(): reject with stop discovery failed');
|
||||
return Promise.reject('stop discovery failed');
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* The method will disconnect the input device with the device's adapter.
|
||||
*
|
||||
* @access public
|
||||
* @memberOf BluetoothConnectionManager
|
||||
* @param {BluetoothDevice} device
|
||||
* @returns {Promise}
|
||||
*/
|
||||
disconnect: function btcm_disconnect(device) {
|
||||
if (!this._defaultAdapter) {
|
||||
return Promise.reject('default adapter is not existed!!');
|
||||
}
|
||||
|
||||
return this._defaultAdapter.disconnect(device).then(() => {
|
||||
debug('disconnect(): onsuccess(): resolve');
|
||||
}, () => {
|
||||
debug('disconnect(): onerror(): reject with disconnect failed');
|
||||
return Promise.reject('disconnect failed');
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* The method will get all connected devices profiles
|
||||
* which we are interested in. Profile: HFP, A2DP.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf BluetoothConnectionManager
|
||||
* @returns {Promise} resolve: connectedDevices
|
||||
* @returns {Promise} reject: reason
|
||||
*/
|
||||
_getConnectedDevicesFromPlatform:
|
||||
function btcm__getConnectedDevicesFromPlatform() {
|
||||
// Get connected device via profiles HFP, A2DP
|
||||
return Promise.all(Object.keys(this.Profiles).map((profile) => {
|
||||
return this._getConnectedDevicesByProfile(this.Profiles[profile]);
|
||||
})).then((connectedDevices) => {
|
||||
// Update each connected devices in map.
|
||||
var collectedConnectedDevicesByProfile = {};
|
||||
Object.keys(this.Profiles).forEach((profile, index) => {
|
||||
collectedConnectedDevicesByProfile[profile] = connectedDevices[index];
|
||||
});
|
||||
debug('_getConnectedDevicesFromPlatform(): ' +
|
||||
'collectedConnectedDevicesByProfile = ' +
|
||||
JSON.stringify(collectedConnectedDevicesByProfile));
|
||||
return Promise.resolve(collectedConnectedDevicesByProfile);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* The method will get connected device by inputed profile.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf BluetoothConnectionManager
|
||||
* @param {String} profileID
|
||||
* @returns {Promise} resolve: the connected devices in array
|
||||
* @returns {Promise} reject: reason
|
||||
*/
|
||||
_getConnectedDevicesByProfile:
|
||||
function btcm__getConnectedDevicesByProfile(profileID) {
|
||||
if (!this._defaultAdapter) {
|
||||
debug('_getConnectedDevicesByProfile(): reject with no adapter');
|
||||
return Promise.reject('default adapter is not existed!!');
|
||||
}
|
||||
|
||||
if (this._defaultAdapter.state === 'disabled') {
|
||||
debug('_getConnectedDevicesByProfile(): resolve with empty array ' +
|
||||
'since it is impossible to connect with any device');
|
||||
return Promise.reject('getConnectedDevices in disabled state');
|
||||
}
|
||||
|
||||
return this._defaultAdapter.getConnectedDevices(profileID).then(
|
||||
(connectedDevice) => {
|
||||
debug('_getConnectedDevicesByProfile(): resolved with ' +
|
||||
'connectedDevice = ' + JSON.stringify(connectedDevice));
|
||||
return Promise.resolve(connectedDevice || []);
|
||||
}, (reason) => {
|
||||
debug('_getConnectedDevicesByProfile(): rejected with ' +
|
||||
'reason = ' + reason);
|
||||
return Promise.reject(reason);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Get device from paired devices by address.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf BluetoothConnectionManager
|
||||
* @param {String} address
|
||||
*/
|
||||
_getPairedDeviceByAddress:
|
||||
function btcm__getPairedDeviceByAddress(address) {
|
||||
if (!this._defaultAdapter) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var pairedDevices = this._defaultAdapter.getPairedDevices();
|
||||
if (pairedDevices.length === 0) {
|
||||
return null;
|
||||
} else {
|
||||
for (var i in pairedDevices) {
|
||||
if (pairedDevices[i].address === address) {
|
||||
return pairedDevices[i];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Find out there is any profile still connected.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf BluetoothConnectionManager
|
||||
* @param {Object} options
|
||||
* @param {String} options.connectedDevices - connected devices info
|
||||
* @param {String} options.address - the address of device
|
||||
*/
|
||||
_hasConnectedProfileByAddress:
|
||||
function btcm__hasConnectedProfileByAddress(options) {
|
||||
if (!options.connectedDevices[options.address]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var hasConnectedProfile = false;
|
||||
for (var profileID in this.Profiles) {
|
||||
var connectedProfiles =
|
||||
options.connectedDevices[options.address].connectedProfiles;
|
||||
if (connectedProfiles && (connectedProfiles[profileID] === true)) {
|
||||
hasConnectedProfile = true;
|
||||
}
|
||||
}
|
||||
return hasConnectedProfile;
|
||||
},
|
||||
|
||||
/**
|
||||
* A function to emit event to each registered listener by the input type.
|
||||
*
|
||||
* @memberOf BluetoothConnectionManager
|
||||
* @param {Object} options
|
||||
* @param {String} options.type - type of event name
|
||||
* @param {Object} options.detail - the object pass to the listener
|
||||
*/
|
||||
_emitEvent: function btcm__emitEvent(options) {
|
||||
this._listeners[options.type].forEach(function(listener) {
|
||||
listener(options);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* The method will provide event listener for outer modules to regist.
|
||||
*
|
||||
* @access public
|
||||
* @memberOf BluetoothConnectionManager
|
||||
* @param {String} eventName
|
||||
* @param {Function} callback
|
||||
*/
|
||||
addEventListener: function btcm_addEventListener(eventName, callback) {
|
||||
if (callback && (this._listeners.hasOwnProperty(eventName))) {
|
||||
this._listeners[eventName].push(callback);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* The method will provide event listener for outer modules to un-regist.
|
||||
*
|
||||
* @access public
|
||||
* @memberOf BluetoothConnectionManager
|
||||
* @param {String} eventName
|
||||
* @param {Function} callback
|
||||
*/
|
||||
removeEventListener:
|
||||
function btcm_removeEventListener(eventName, callback) {
|
||||
if (callback && (this._listeners.hasOwnProperty(eventName))) {
|
||||
var index = this._listeners[eventName].indexOf(callback);
|
||||
if (index >= 0) {
|
||||
this._listeners[eventName].splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
BluetoothConnectionManager._init();
|
||||
return BluetoothConnectionManager;
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1 @@
|
|||
define(["require","modules/bluetooth/bluetooth_cod_mapper","modules/mvvm/observable"],function(e){"use strict";var t=e("modules/bluetooth/bluetooth_cod_mapper"),i=e("modules/mvvm/observable"),n=!1,o=function(){};n&&(o=function(e){console.log("--> [BluetoothDevice]: "+e)});var s=function(e){o("updateConnectionInfo(): this.address = "+this.address+", this.name = "+this.name+", options = "+JSON.stringify(e)),e.connectionStatus&&(this.connectionStatus=e.connectionStatus),e.profiles&&(this.profiles=Object.create(e.profiles)),o("updateConnectionInfo(): this.connectionStatus = "+this.connectionStatus+", this.profiles = "+JSON.stringify(this.profiles))},c=function(){if(o("updateDescriptionText():"),this.paired===!1)this.descriptionText="tapToConnect";else if("pairing"===this.paired)this.descriptionText="pairing";else if(this.paired===!0)if(this.descriptionText="paired","connecting"===this.connectionStatus)this.descriptionText="connecting";else if("connected"===this.connectionStatus)if(this.profiles){var e=this.profiles.hfp,t=this.profiles.a2dp;e&&t?this.descriptionText="connectedWithDeviceMedia":e?this.descriptionText="connectedWithDevice":t?this.descriptionText="connectedWithMedia":this.descriptionText="connectedWithNoProfileInfo"}else this.descriptionText="connectedWithNoProfileInfo";else"disconnected"===this.connectionStatus&&(this.descriptionText="disconnected");o("updateDescriptionText(): this.descriptionText = "+this.descriptionText)};return function(e){var n=t.getDeviceType(e.cod),r=e.connectionStatus||"disconnected",d=i({name:e.name,paired:e.paired,address:e.address,type:n,connectionStatus:r,profiles:null,descriptionText:"",get data(){return e},updateConnectionInfo:s,updateDescriptionText:c});return d._init=function(){this.observe("paired",this.updateDescriptionText.bind(this)),this.observe("connectionStatus",this.updateDescriptionText.bind(this)),this.observe("profiles",this.updateDescriptionText.bind(this))},e.onattributechanged=function(i){for(var n in i.attrs)switch(o("onDeviceAttributeChanged(): "+i.attrs[n]),i.attrs[n]){case"name":d.name=e.name;break;case"paired":o("onDeviceAttributeChanged(): device.paired = "+e.paired),d.paired=e.paired;break;case"cod":o("onDeviceAttributeChanged(): device.cod = "+e.cod),d.type=t.getDeviceType(e.cod)}},d._init(),d}});
|
||||
|
|
@ -0,0 +1 @@
|
|||
define(["require","modules/mvvm/observable_array"],function(e){var n=new WeakMap,r=e("modules/mvvm/observable_array"),t=function(e,t,i){function o(e){var n=u.get(e);if(n&&"object"==typeof n){var r=u.get(n);r&&(r.forEach(function(e){n.unobserve(e)}),u["delete"](n),u["delete"](e))}}function l(e,n){var r=u.get(e)||[];u.has(e)&&(r=u.get(e)),r=Object.keys(n).map(function(r){return e.observe(r,n[r]),n[r](),n[r]}),u.set(e,r)}var a=null,s=e,c=i,f=!0,u=new WeakMap,v={observeAndCall:l},d=function(e){if(f){var n=e.data;switch(e.type){case"insert":b(n.index,n.items);break;case"remove":h(n.index,n.count);break;case"replace":m(n.index,n.newValue);break;case"reset":g(n.items||[])}}},b=function(e,n){if(!(n.length<=0))for(var r=s.children[e],t=n.length-1;t>=0;t--){var i=c(n[t],void 0,v);u.set(i,n[t]),s.insertBefore(i,r),r=i}},h=function(e,n){if(0!==n)if(n>=s.childElementCount)for(;s.firstElementChild;)o(s.firstElementChild),s.removeChild(s.firstElementChild);else for(var r=s.children[e],t=0;n>t&&r;t++){var i=r.nextElementSibling;o(r),s.removeChild(r),r=i}},m=function(e,n){var r=s.children[e];if(r){o(r);var t=c(n,r,v);u.set(r,n),t!==r&&(s.insertBefore(t,r),s.removeChild(r))}},g=function(e){var n=e.length,r=s.childElementCount;if(0===n)h(0,r);else if(r>=n)e.forEach(function(e,n){m(n,e)}),h(n,r-n);else{var t=e.slice(0,r),i=e.slice(r);t.forEach(function(e,n){m(n,e)}),b(r,i)}},y=function(){f&&g(a.array)},C={set:function(e){return a&&a.unobserve(d),e?(Array.isArray(e)&&(e=r(e)),a=e,a.observe("insert",d),a.observe("remove",d),a.observe("replace",d),a.observe("reset",d),void(this.enabled&&g(a.array))):void(a&&(h(0,a.length),a=null))},destroy:function(){a&&a.unobserve(d),h(0,a.length),n["delete"](s),s=null,a=null,c=null,f=!1},get element(){return s},set enabled(e){f!==e&&(f=e,y())},get enabled(){return f}};return n.has(s)&&n.get(s).destroy(),n.set(s,C),s.innerHTML="",C.set(t),C};return t});
|
||||
|
|
@ -0,0 +1 @@
|
|||
define([],function(){function n(n,e,r){function t(e,r){var t=n[r];if(t){var i=t.indexOf(e);i>=0&&t.splice(i,1)}}"function"==typeof e?Object.keys(n).forEach(t.bind(null,e)):r?t(r,e):e in n&&(n[e]=[])}function e(e){var r={},t={observe:function(n,e){var t=r[n];t&&t.push(e)},unobserve:n.bind(null,r)},i=function(n){return function(){return t["_"+n]}},f=function(n){return function(e){var i=t["_"+n];if(i!==e){t["_"+n]=e;var f=r[n];f.forEach(function(n){n(e,i)})}}};for(var u in e)"function"!=typeof e[u]?(r[u]=[],Object.defineProperty(t,"_"+u,{value:e[u],writable:!0}),Object.defineProperty(t,u,{enumerable:!0,get:i(u),set:f(u)})):t[u]=e[u];return t}return e});
|
||||
|
|
@ -0,0 +1 @@
|
|||
define([],function(){function n(n,e,t){function r(e,t){var r=n[t];if(r){var i=r.indexOf(e);i>=0&&r.splice(i,1)}}"function"==typeof e?Object.keys(n).forEach(r.bind(null,e)):t?r(t,e):e in n&&(n[e]=[])}function e(e){var t=e||[],r={insert:[],remove:[],replace:[],reset:[]},i=function(n,e){var t=r[n];t.forEach(function(t){t({type:n,data:e})})};return{get length(){return t.length},get array(){return t},forEach:function(n){t.forEach(n)},observe:function(n,e){var t=r[n];t&&t.push(e)},unobserve:n.bind(null,r),push:function(n){t.push(n),i("insert",{index:t.length-1,count:1,items:[n]})},pop:function(){if(t.length){var n=t.pop();return i("remove",{index:t.length,count:1}),n}},splice:function(n,e){if(!(arguments.length<2)){var r=Array.prototype.slice.call(arguments,2);t.splice.apply(t,arguments),e&&i("remove",{index:n,count:e}),r.length&&i("insert",{index:n,count:r.length,items:r})}},set:function(n,e){if(!(0>n||n>=t.length)){var r=t[n];t[n]=e,i("replace",{index:n,oldValue:r,newValue:e})}},get:function(n){return t[n]},reset:function(n){t=n,i("reset",{items:t})}}}return e});
|
||||
|
|
@ -0,0 +1 @@
|
|||
define([],function(){"use strict";return navigator.mozBluetooth?navigator.mozBluetooth:null});
|
||||
|
|
@ -0,0 +1,430 @@
|
|||
/* -*- Mode: js; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- /
|
||||
/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */
|
||||
|
||||
/* global NotificationHelper */
|
||||
|
||||
define(['require','modules/bluetooth/bluetooth_adapter_manager','modules/bluetooth/bluetooth_context','views/pair_expired_dialog'],function(require) {
|
||||
'use strict';
|
||||
|
||||
var AdapterManager = require('modules/bluetooth/bluetooth_adapter_manager');
|
||||
var BtContext = require('modules/bluetooth/bluetooth_context');
|
||||
var PairExpiredDialog = require('views/pair_expired_dialog');
|
||||
|
||||
var _debug = false;
|
||||
var debug = function() {};
|
||||
if (_debug) {
|
||||
debug = function btam_debug(msg) {
|
||||
console.log('--> [PairManager]: ' + msg);
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* PairManager is responsible for:
|
||||
* 1. Handling system message 'bluetooth-pairing-request' while there is an
|
||||
* incoming/outgoing pairing request.
|
||||
* 2. Handling dom event from BluetoothPairingListener while there is an
|
||||
* incoming/outgoing pairing request.
|
||||
* 3. Handling dom event 'ondeviceunpaired' while some remote devices
|
||||
* request for canceling an overdue pairing request. The reason could be
|
||||
* authentication fails, remote device down, and internal error happens.
|
||||
*/
|
||||
var PairManager = {
|
||||
/**
|
||||
* Default adapter of Bluetooth.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf PairManger
|
||||
* @type {Object} BluetoothAdapter
|
||||
*/
|
||||
_defaultAdapter: null,
|
||||
/**
|
||||
* Indicate if the pair request is expired
|
||||
* @type {Boolean}
|
||||
*/
|
||||
_isExpired: false,
|
||||
|
||||
init: function() {
|
||||
// Observe 'defaultAdapter' property for reaching default adapter.
|
||||
AdapterManager.observe('defaultAdapter',
|
||||
this._onDefaultAdapterChanged.bind(this));
|
||||
this._onDefaultAdapterChanged(AdapterManager.defaultAdapter);
|
||||
|
||||
// Watch pairing events.
|
||||
this._watchOndisplaypasskeyreq();
|
||||
this._watchOnenterpincodereq();
|
||||
this._watchOnpairingconfirmationreq();
|
||||
this._watchOnpairingconsentreq();
|
||||
this._watchOnpairingaborted();
|
||||
|
||||
navigator.mozSetMessageHandler('bluetooth-pairing-request',
|
||||
this._onRequestPairingFromSystemMessage.bind(this)
|
||||
);
|
||||
|
||||
// Observe screen lockscreen locked/unlocked state for show pending
|
||||
// pairing request immediately.
|
||||
navigator.mozSettings.addObserver('lockscreen.locked',
|
||||
function gotScreenLockedChanged(event) {
|
||||
this.showPendingPairing(event.settingValue);
|
||||
}.bind(this));
|
||||
|
||||
// Observe Bluetooth 'enabled' property from hardware side.
|
||||
// Then, close pairing dialog immediately.
|
||||
BtContext.observe('enabled', (enabled) => {
|
||||
if (!enabled) {
|
||||
this.onBluetoothDisabled();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 'defaultAdapter' change event handler from adapter manager for
|
||||
* updating it immediately.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf PairManager
|
||||
* @param {Object} BluetoothAdapter newAdapter
|
||||
*/
|
||||
_onDefaultAdapterChanged: function(newAdapter) {
|
||||
// save default adapter
|
||||
this._defaultAdapter = newAdapter;
|
||||
},
|
||||
|
||||
/**
|
||||
* Watch 'ondisplaypasskeyreq' dom event for pairing.
|
||||
* A handler to trigger when a remote bluetooth device requests to display
|
||||
* passkey on the screen during pairing process.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf PairManager
|
||||
*/
|
||||
_watchOndisplaypasskeyreq: function() {
|
||||
if (!this._defaultAdapter || !this._defaultAdapter.pairingReqs) {
|
||||
debug('_watchOndisplaypasskeyreq() no adapter or pairingReqs');
|
||||
return;
|
||||
}
|
||||
|
||||
this._defaultAdapter.pairingReqs.addEventListener('displaypasskeyreq',
|
||||
(evt) => {
|
||||
debug('_watchOndisplaypasskeyreq()');
|
||||
this._handlePairingRequest({
|
||||
method: 'displaypasskey',
|
||||
evt: evt
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Watch 'onenterpincodereq' dom event for pairing.
|
||||
* A handler to trigger when a remote bluetooth device requests user enter
|
||||
* PIN code during pairing process.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf PairManager
|
||||
*/
|
||||
_watchOnenterpincodereq: function() {
|
||||
if (!this._defaultAdapter || !this._defaultAdapter.pairingReqs) {
|
||||
debug('_watchOnenterpincodereq() no adapter or pairingReqs');
|
||||
return;
|
||||
}
|
||||
|
||||
this._defaultAdapter.pairingReqs.addEventListener('enterpincodereq',
|
||||
(evt) => {
|
||||
debug('_watchOnenterpincodereq()');
|
||||
this._handlePairingRequest({
|
||||
method: 'enterpincode',
|
||||
evt: evt
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Watch 'onpairingconfirmationreq' dom event for pairing.
|
||||
* A handler to trigger when a remote bluetooth device requests user
|
||||
* confirm passkey during pairing process. Applications may prompt passkey
|
||||
* to user for confirmation, or confirm the passkey for user proactively.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf PairManager
|
||||
*/
|
||||
_watchOnpairingconfirmationreq: function() {
|
||||
if (!this._defaultAdapter || !this._defaultAdapter.pairingReqs) {
|
||||
debug('_watchOnpairingconfirmationreq() no adapter or pairingReqs');
|
||||
return;
|
||||
}
|
||||
|
||||
this._defaultAdapter.pairingReqs.addEventListener(
|
||||
'pairingconfirmationreq', (evt) => {
|
||||
debug('_watchOnpairingconfirmationreq: evt = ' + JSON.stringify(evt));
|
||||
this._handlePairingRequest({
|
||||
method: 'confirmation',
|
||||
evt: evt
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Watch 'onpairingconsentreq' dom event for pairing.
|
||||
* A handler to trigger when a remote bluetooth device requests user
|
||||
* confirm pairing during pairing process. Applications may prompt user
|
||||
* for confirmation or confirm for user proactively.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf PairManager
|
||||
*/
|
||||
_watchOnpairingconsentreq: function() {
|
||||
if (!this._defaultAdapter || !this._defaultAdapter.pairingReqs) {
|
||||
debug('_watchOnpairingconsentreq() no adapter or pairingReqs');
|
||||
return;
|
||||
}
|
||||
|
||||
this._defaultAdapter.pairingReqs.addEventListener('pairingconsentreq',
|
||||
(evt) => {
|
||||
debug('_watchOnpairingconsentreq: evt = ' + JSON.stringify(evt));
|
||||
this._handlePairingRequest({
|
||||
method: 'consent',
|
||||
evt: evt
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Watch 'onpairingaborted' dom event for pairing aborted.
|
||||
* A handler to trigger when pairing fails due to one of
|
||||
* following conditions:
|
||||
* - authentication fails
|
||||
* - remote device down (bluetooth ACL becomes disconnected)
|
||||
* - internal error happens
|
||||
*
|
||||
* @access private
|
||||
* @memberOf PairManager
|
||||
*/
|
||||
_watchOnpairingaborted: function() {
|
||||
if (!this._defaultAdapter) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._defaultAdapter.addEventListener('pairingaborted',
|
||||
this._onPairingAborted.bind(this));
|
||||
},
|
||||
|
||||
/**
|
||||
* A handler to handle 'onpairingaborted' event while it's coming.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf PairManager
|
||||
*/
|
||||
_onPairingAborted: function(evt) {
|
||||
debug('_onPairingAborted(): evt = ' + JSON.stringify(evt));
|
||||
// if the attention screen still open, close it
|
||||
if (this.childWindow) {
|
||||
this.childWindow.Pairview.closeInput();
|
||||
this.childWindow.close();
|
||||
}
|
||||
|
||||
// If "onpairingaborted" event is coming, and there is a pending pairing
|
||||
// request, we have reset the object. Because the callback is useless
|
||||
// since the older pairing request is expired.
|
||||
// The moment is fit to notify user that the later pairing request
|
||||
// is timeout or canceled. According to user story, do nothing here.
|
||||
// Clear up the pending pairing request only.
|
||||
if (this.pendingPairing) {
|
||||
this.pendingPairing = null;
|
||||
}
|
||||
|
||||
// Close pairing expired dialog if it is showing.
|
||||
if (PairExpiredDialog.isVisible) {
|
||||
PairExpiredDialog.close();
|
||||
// Have to close Bluetooth app after the dialog is closed.
|
||||
window.close();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Receive the system message event for launch Bluetooth app here.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf PairManager
|
||||
*/
|
||||
_onRequestPairingFromSystemMessage: function() {
|
||||
debug('onRequestPairingFromSystemMessage():');
|
||||
},
|
||||
|
||||
/**
|
||||
* It is used to handle each pairing request from different pairing methods.
|
||||
*
|
||||
* @memberOf PairManager
|
||||
* @access private
|
||||
* @param {Object} pairingInfo
|
||||
* @param {Object} pairingInfo.method - method of this pairing request
|
||||
* @param {Object} pairingInfo.evt - DOM evt of this pairing request
|
||||
*/
|
||||
_handlePairingRequest: function(pairingInfo) {
|
||||
debug('_onRequestPairing():' +
|
||||
' pairingInfo.method = ' + pairingInfo.method +
|
||||
' pairingInfo.evt = ' + pairingInfo.evt);
|
||||
|
||||
var req = navigator.mozSettings.createLock().get('lockscreen.locked');
|
||||
req.onsuccess = () => {
|
||||
if (req.result['lockscreen.locked']) {
|
||||
// notify user that we are receiving pairing request
|
||||
this.fireNotification(pairingInfo);
|
||||
} else {
|
||||
// We have clear up pending one before show the new pairing requst.
|
||||
// Becasue the pending pairing request is no longer usefull.
|
||||
this.cleanPendingPairing();
|
||||
|
||||
// show pair view directly while lock screen is unlocked
|
||||
this.showPairview(pairingInfo);
|
||||
}
|
||||
};
|
||||
req.onerror = () => {
|
||||
// We have clear up pending one before show the new pairing requst.
|
||||
// Becasue the pending pairing request is no longer usefull.
|
||||
this.cleanPendingPairing();
|
||||
|
||||
// fallback to default value 'unlocked'
|
||||
this.showPairview(pairingInfo);
|
||||
};
|
||||
},
|
||||
|
||||
fireNotification: function(pairingInfo) {
|
||||
// Once we received a pairing request in screen locked mode,
|
||||
// overwrite the object with the latest pairing request. Because the
|
||||
// older pairing request might be timeout and useless now.
|
||||
this.pendingPairing = {
|
||||
showPairviewCallback: this.showPairview.bind(this, pairingInfo)
|
||||
};
|
||||
// Prepare notification toast.
|
||||
var titleL10n = 'bluetooth-pairing-request-now-title';
|
||||
var bodyL10n = pairingInfo.evt.deviceName ?
|
||||
{ raw: pairingInfo.evt.deviceName } :
|
||||
'unnamed-device';
|
||||
var iconUrl =
|
||||
'app://bluetooth.gaiamobile.org/style/images/icon_bluetooth.png';
|
||||
// We always use tag "pairing-request" to manage these notifications.
|
||||
var notificationId = 'pairing-request';
|
||||
return NotificationHelper.send(titleL10n, {
|
||||
bodyL10n: bodyL10n,
|
||||
icon: iconUrl,
|
||||
tag: notificationId,
|
||||
closeOnClick: false
|
||||
}).then(notification => {
|
||||
// set onclick handler for the notification
|
||||
notification.onclick =
|
||||
this.pairingRequestExpiredNotificationHandler.bind(this,
|
||||
notification);
|
||||
});
|
||||
},
|
||||
|
||||
// According to user story, it won't notify user again
|
||||
// while the pending pairing request is just timeout or canceled.
|
||||
// So we have to set onclick handler in this moment.
|
||||
// The handler will pop out a pairing request expired prompt only.
|
||||
pairingRequestExpiredNotificationHandler: function(notification) {
|
||||
var req = navigator.mozSettings.createLock().get('lockscreen.locked');
|
||||
req.onsuccess = () => {
|
||||
// Avoid to do nothting while the notification toast is showing
|
||||
// and a user is able to trigger onclick event. Make sure screen
|
||||
// is unlocked, then show the prompt in bluetooth app.
|
||||
if (!req.result['lockscreen.locked']) {
|
||||
// Clean the pairing request notficiation which is expired.
|
||||
notification.close();
|
||||
|
||||
navigator.mozApps.getSelf().onsuccess = (evt) => {
|
||||
var app = evt.target.result;
|
||||
|
||||
// launch bluetooth app to foreground for showing the prompt
|
||||
if (PairExpiredDialog.isVisible) {
|
||||
app.launch();
|
||||
}
|
||||
|
||||
// show an alert with the overdue message
|
||||
if (!PairExpiredDialog.isVisible && this._isExpired) {
|
||||
debug('show expired dialog');
|
||||
PairExpiredDialog.showConfirm(function() {
|
||||
// Have to close Bluetooth app after the dialog is closed.
|
||||
window.close();
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
// If there is a pending pairing request while a user just unlocks screen,
|
||||
// we will show pair view immediately. Then, we clear up the notification.
|
||||
// If pendingPairing object is not exist, it means pair request is expired.
|
||||
showPendingPairing: function(screenLocked) {
|
||||
debug('showPendingPairing when screenLocked:' + screenLocked);
|
||||
if (!screenLocked) {
|
||||
if (this.pendingPairing) {
|
||||
this._isExpired = false;
|
||||
// show pair view from the callback function
|
||||
if (this.pendingPairing.showPairviewCallback) {
|
||||
this.pendingPairing.showPairviewCallback();
|
||||
}
|
||||
|
||||
this.cleanPendingPairing();
|
||||
} else {
|
||||
this._isExpired = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
cleanPendingPairing: function() {
|
||||
debug('cleanPendingPairing(): has pendingPairing = ' +
|
||||
(this.pendingPairing));
|
||||
|
||||
// Clear up the pending pairing request
|
||||
if (this.pendingPairing) {
|
||||
this.pendingPairing = null;
|
||||
}
|
||||
|
||||
// Clear up the pairing request from notification.
|
||||
this.cleanNotifications();
|
||||
},
|
||||
|
||||
// Clean all notifications which are fired with tag 'pairing-request'.
|
||||
cleanNotifications: function() {
|
||||
Notification.get().then(function(notifications) {
|
||||
if (notifications) {
|
||||
notifications.forEach(function(notification) {
|
||||
// Compare tags, as the tag is based on the "pairing-request" and
|
||||
// we only have one notification for the request. Plus, there
|
||||
// is no "id" field on the notification.
|
||||
if (notification.tag === 'pairing-request' &&
|
||||
notification.close) {
|
||||
notification.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
showPairview: function(pairingInfo) {
|
||||
debug('showPairview(): pairingInfo = ' + pairingInfo);
|
||||
var protocol = window.location.protocol;
|
||||
var host = window.location.host;
|
||||
this.childWindow = window.open(protocol + '//' + host + '/onpair.html',
|
||||
'pair_screen', 'attention');
|
||||
this.childWindow.onload = () => {
|
||||
this.childWindow.Pairview.init(pairingInfo.method, pairingInfo.evt);
|
||||
};
|
||||
},
|
||||
|
||||
onBluetoothDisabled: function() {
|
||||
debug('onBluetoothDisabled():');
|
||||
|
||||
// if the attention screen still open, close it
|
||||
if (this.childWindow) {
|
||||
this.childWindow.Pairview.closeInput();
|
||||
this.childWindow.close();
|
||||
}
|
||||
|
||||
// Since Bluetooth is off, close itself.
|
||||
window.close();
|
||||
}
|
||||
};
|
||||
|
||||
return PairManager;
|
||||
});
|
||||
|
|
@ -0,0 +1,349 @@
|
|||
/*
|
||||
* TransferManager is responsible for:
|
||||
* - Handling system message 'activity' while there is an activity request
|
||||
* incoming. Then, it will post result/error to requesting app.
|
||||
* - Watching Bluetooth enabled/disabled state, then show/hide turn Bluetooth
|
||||
* on dialog.
|
||||
* - Watching default adapter existed or not. And will post error if we cannot
|
||||
* get default adapter.
|
||||
* - Watching 'devicePicked', 'cancelSelection' events from DevicePickerPanel
|
||||
* module. Then, do corrected activity service flow regarding user decision.
|
||||
*
|
||||
* @module BluetoothContext
|
||||
*/
|
||||
define(['require','modules/bluetooth/bluetooth_adapter_manager','modules/bluetooth/bluetooth_context','views/cannot_transfer_dialog','views/device_picker_panel','views/turn_bluetooth_on_dialog'],function(require) {
|
||||
'use strict';
|
||||
|
||||
var AdapterManager = require('modules/bluetooth/bluetooth_adapter_manager');
|
||||
var BtContext = require('modules/bluetooth/bluetooth_context');
|
||||
var CannotTransferDialog = require('views/cannot_transfer_dialog');
|
||||
var DevicePickerPanel = require('views/device_picker_panel');
|
||||
var TurnBluetoothOnDialog = require('views/turn_bluetooth_on_dialog');
|
||||
|
||||
var _debug = false;
|
||||
var debug = function() {};
|
||||
if (_debug) {
|
||||
debug = function bttm_debug(msg) {
|
||||
console.log('--> [TransferManager]: ' + msg);
|
||||
};
|
||||
}
|
||||
|
||||
var TransferManager = {
|
||||
/**
|
||||
* An instance to access activity source.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf TransferManager
|
||||
* @type {Object}
|
||||
*/
|
||||
_activity: null,
|
||||
|
||||
/**
|
||||
* The init function only watches the 'activity' event from system message.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf TransferManager
|
||||
*/
|
||||
init: function bttm_init() {
|
||||
// Watch 'activity' event from system message.
|
||||
this._watchActivityRequest();
|
||||
},
|
||||
|
||||
/**
|
||||
* Watch 'activity' event with callback function from system message.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf TransferManager
|
||||
*/
|
||||
_watchActivityRequest: function bttm__watchActivityRequest() {
|
||||
navigator.mozSetMessageHandler('activity',
|
||||
this._activityHandler.bind(this));
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle 'activity' request. Then make decision to service the request or
|
||||
* reject it.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf TransferManager
|
||||
* @param {Object} activityRequest
|
||||
*/
|
||||
_activityHandler: function bttm__activityHandler(activityRequest) {
|
||||
debug('_activityHandler(): activityRequest = ' +
|
||||
JSON.stringify(activityRequest));
|
||||
if (activityRequest.source.data.blobs &&
|
||||
activityRequest.source.data.blobs.length > 0) {
|
||||
this._activity = activityRequest;
|
||||
// Source info is enough to servie file transfer.
|
||||
// Start to init and observe Bluetooth relative modules.
|
||||
this._observeDefaultAdapter();
|
||||
this._observeBluetoothEnabled();
|
||||
this._watchEventFromDevicePicker();
|
||||
DevicePickerPanel.visible = true;
|
||||
} else {
|
||||
debug('_activityHandler(): Cannot transfer without blobs data!');
|
||||
this._cannotTransfer();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Show alert dialog. End the activity service while a user confirm to close
|
||||
* the dialog.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf TransferManager
|
||||
*/
|
||||
_cannotTransfer: function bttm__cannotTransfer() {
|
||||
debug('_cannotTransfer(): show alert dialog!!');
|
||||
// show an alert with the overdue message
|
||||
if (!CannotTransferDialog.isVisible) {
|
||||
CannotTransferDialog.showConfirm().then(() => {
|
||||
// Post error with reason then end the activity service.
|
||||
this._endTransferWithReason('cancelled');
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Post result/error to end the activity service.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf TransferManager
|
||||
*/
|
||||
_endTransferWithReason: function bttm__endTransferWithReason(reason) {
|
||||
debug('_endTransferWithReason(): reason = ' + reason);
|
||||
if (reason === 'transferred') {
|
||||
this._activity.postResult(reason);
|
||||
} else if (reason === 'cancelled') {
|
||||
this._activity.postError(reason);
|
||||
}
|
||||
|
||||
this._activity = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Observe and get default adapter from bluetooth adapter manager.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf TransferManager
|
||||
*/
|
||||
_observeDefaultAdapter: function bttm__observeDefaultAdapter() {
|
||||
// Observe 'defaultAdapter' property for reaching default adapter.
|
||||
AdapterManager.observe('defaultAdapter',
|
||||
this._onDefaultAdapterChanged.bind(this));
|
||||
this._onDefaultAdapterChanged(AdapterManager.defaultAdapter);
|
||||
},
|
||||
|
||||
/**
|
||||
* Observe and get 'enabled' property from bluetooth context.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf TransferManager
|
||||
*/
|
||||
_observeBluetoothEnabled: function bttm__observeBluetoothEnabled() {
|
||||
// Observe Bluetooth 'enabled' property from hardware side.
|
||||
BtContext.observe('enabled', this._onBluetoothEnabledChanged.bind(this));
|
||||
this._onBluetoothEnabledChanged(BtContext.enabled);
|
||||
},
|
||||
|
||||
/**
|
||||
* 'defaultAdapter' change event handler from adapter manager for
|
||||
* updating it immediately.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf TransferManager
|
||||
* @param {Object} BluetoothAdapter newAdapter
|
||||
* @param {Object} BluetoothAdapter oldAdapter
|
||||
*/
|
||||
_onDefaultAdapterChanged:
|
||||
function bttm__onDefaultAdapterChanged(newAdapter, oldAdapter) {
|
||||
if (oldAdapter && (newAdapter === null)) {
|
||||
debug('_onDefaultAdapterChanged(): Can not get bluetooth adapter!');
|
||||
// Post error with reason then end the activity service.
|
||||
this._endTransferWithReason('cancelled');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 'enabled' change event handler from bluetooth context for
|
||||
* updating UI flow immediately.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf TransferManager
|
||||
* @param {Boolean} enabled
|
||||
*/
|
||||
_onBluetoothEnabledChanged:
|
||||
function bttm__onBluetoothEnabledChanged(enabled) {
|
||||
debug('_onBluetoothEnabledChanged(): enabled = ' + enabled);
|
||||
switch(enabled) {
|
||||
case undefined:
|
||||
break;
|
||||
case true:
|
||||
// Close the confirmation dialog
|
||||
TurnBluetoothOnDialog.close();
|
||||
break;
|
||||
default:
|
||||
TurnBluetoothOnDialog.showConfirm().then((result) => {
|
||||
if (result === 'confirm') {
|
||||
// Turn Bluetooth on.
|
||||
BtContext.setEnabled(true);
|
||||
} else if (result === 'cancel') {
|
||||
// Post error with reason then end the activity service.
|
||||
this._endTransferWithReason('cancelled');
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Watch 'devicePicked', 'cancelSelection' event from device picker panel.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf TransferManager
|
||||
*/
|
||||
_watchEventFromDevicePicker: function bttm__watchEventFromDevicePicker() {
|
||||
debug('_watchEventFromDevicePicker():');
|
||||
// Watch 'devicePicked' event for reaching device picked from user.
|
||||
DevicePickerPanel.addEventListener('devicePicked',
|
||||
this._onDevicePicked.bind(this));
|
||||
|
||||
// Watch 'cancelSelection' event for reaching cancel selection from user.
|
||||
DevicePickerPanel.addEventListener('cancelSelection',
|
||||
this._onCancelSelection.bind(this));
|
||||
},
|
||||
|
||||
/**
|
||||
* While the device is picked from user, let's send file(s) to the device.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf TransferManager
|
||||
* @param {Object} event
|
||||
* @param {String} event.type - type of event name
|
||||
* @param {Object} event.detail - device info in this object
|
||||
* @param {Object} event.detail.address - address of device
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_onDevicePicked: function bttm__onDevicePicked(event) {
|
||||
// Send file to the device via address
|
||||
debug('_onDevicePicked(): address of target device = ' +
|
||||
event.detail.address);
|
||||
var targetDeviceAddress = event.detail.address;
|
||||
var blobs = this._activity.source.data.blobs;
|
||||
|
||||
// Produce sending files schedule.
|
||||
// Then, post message to system app for sending files in queue.
|
||||
var schedule = this._produceSendingFilesSchedule(blobs.length);
|
||||
this._postMessageToSystemApp(schedule);
|
||||
|
||||
// Send each file via Bluetooth sendFile API
|
||||
return Promise.all(blobs.map((blob, index) => {
|
||||
/**
|
||||
* Check blob.name because sendFile() API needs a "file"
|
||||
* object and a filaname before sending it.
|
||||
* If there is no filename in the blob, Bluetooth API will give a
|
||||
* default name "Unknown.jpeg".
|
||||
* So Bluetooth app has to find out the name via device storage.
|
||||
*/
|
||||
if (blob.name) {
|
||||
// The blob has name, send the blob directly.
|
||||
debug('blob is sending with name...');
|
||||
return BtContext.sendFile(targetDeviceAddress, blob);
|
||||
} else if (this._activity.source.data.filepaths) {
|
||||
// The blob does not have name,
|
||||
// browse the file via filepath from storage again.
|
||||
var filepath = this._activity.source.data.filepaths[index];
|
||||
var storage = navigator.getDeviceStorage('sdcard');
|
||||
return storage.get(filepath).then((file) => {
|
||||
debug('getFile succeed & file is sending... file = ' + file);
|
||||
return BtContext.sendFile(targetDeviceAddress, file);
|
||||
}, (error) => {
|
||||
debug('getFile failed so that blob is sending without filename ' +
|
||||
error && error.name);
|
||||
return BtContext.sendFile(targetDeviceAddress, blob);
|
||||
});
|
||||
} else {
|
||||
// The blob does not have name and filepath,
|
||||
// pass the blob without name to send file directly.
|
||||
debug('no filepath to get from device storage ' +
|
||||
'so that blob is sending without filename');
|
||||
return BtContext.sendFile(targetDeviceAddress, blob);
|
||||
}
|
||||
})).then(() => {
|
||||
debug('all files are already sent out..');
|
||||
// Post result with reason then end the activity service.
|
||||
this._endTransferWithReason('transferred');
|
||||
return Promise.resolve();
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* While a user cancel selection, let's post result then end the activity
|
||||
* service.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf TransferManager
|
||||
* @param {Object} event
|
||||
* @param {String} event.type - type of event name
|
||||
*/
|
||||
_onCancelSelection: function bttm__onCancelSelection(event) {
|
||||
debug('_onCancelSelection(): cancel from user decision');
|
||||
// Post error with reason then end the activity service.
|
||||
this._endTransferWithReason('cancelled');
|
||||
},
|
||||
|
||||
/**
|
||||
* A function is a role producer.
|
||||
* Bluetooth app produce one message for each sending file request.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf TransferManager
|
||||
* @param {Number} numberOfTasks - number of tasks
|
||||
* @returns {Object}
|
||||
*/
|
||||
_produceSendingFilesSchedule:
|
||||
function bttm__produceSendingFilesSchedule(numberOfTasks) {
|
||||
// Construct a object to contain info of sending files schedule.
|
||||
// And the info will be posted message to system app.
|
||||
// The result of send files will be displayed on notification.
|
||||
var sendingFilesSchedule = {
|
||||
numberOfFiles: numberOfTasks,
|
||||
numSuccessful: 0,
|
||||
numUnsuccessful: 0
|
||||
};
|
||||
return sendingFilesSchedule;
|
||||
},
|
||||
|
||||
/**
|
||||
* A function to do inner app communcation.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf TransferManager
|
||||
* @param {Object} message - the info of sending files schedule
|
||||
*/
|
||||
_postMessageToSystemApp:
|
||||
function bttm__postMessageToSystemApp(message) {
|
||||
// Set up Inter-App Communications
|
||||
var getRequest = navigator.mozApps.getSelf();
|
||||
getRequest.onsuccess = (evt) => {
|
||||
var app = evt.target.result;
|
||||
// If IAC doesn't exist, just bail out.
|
||||
if (!app.connect) {
|
||||
debug('_postMessageToSystemApp(): Cannot post message since no ' +
|
||||
'connect function, message = ' + JSON.stringify(message));
|
||||
return;
|
||||
}
|
||||
// Post message object to system app.
|
||||
app.connect('bluetoothTransfercomms').then((ports) => {
|
||||
ports.forEach((port) => {
|
||||
port.postMessage(message);
|
||||
debug('_postMessageToSystemApp(): sending files schedule = ' +
|
||||
JSON.stringify(message));
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return TransferManager;
|
||||
});
|
||||
1
assets/webapps/bluetooth.gaiamobile.org/js/settings.js
Normal file
1
assets/webapps/bluetooth.gaiamobile.org/js/settings.js
Normal file
File diff suppressed because one or more lines are too long
16
assets/webapps/bluetooth.gaiamobile.org/js/startup_pair.js
Normal file
16
assets/webapps/bluetooth.gaiamobile.org/js/startup_pair.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
|
||||
|
||||
require(['config/require'], function() {
|
||||
var _debug = false;
|
||||
var debug = function() {};
|
||||
if (_debug) {
|
||||
debug = function btsp_debug(msg) {
|
||||
console.log('--> [startup_pair]: ' + msg);
|
||||
};
|
||||
}
|
||||
|
||||
require(['modules/pair_manager'], function(PairManager) {
|
||||
debug('Load pair manager with API version 2. Loaded!!');
|
||||
navigator.mozL10n.once(() => PairManager.init());
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
|
||||
|
||||
require(['config/require'], function() {
|
||||
var _debug = false;
|
||||
var debug = function() {};
|
||||
if (_debug) {
|
||||
debug = function btst_debug(msg) {
|
||||
console.log('--> [startup_transfer]: ' + msg);
|
||||
};
|
||||
}
|
||||
|
||||
require(['modules/transfer_manager'], function(TransferManager) {
|
||||
debug('Load transfer manager with API version 2. Loaded!!');
|
||||
navigator.mozL10n.once(() => TransferManager.init());
|
||||
});
|
||||
});
|
||||
1
assets/webapps/bluetooth.gaiamobile.org/js/utils.js
Normal file
1
assets/webapps/bluetooth.gaiamobile.org/js/utils.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
;function getTruncated(t,e){function i(t,e){return t!==e}var n=e.maxLine||2,l=e.node,o=e.ellipsisIndex||3,r=e.ellipsisCharacter||"...";if(null===l)return t;var s,a,c,u,d,y,h="",v=!0,p=[],f=t.slice(-o),g=l.style.visibility,x=l.style.wordBreak;l.style.visibility="hidden",l.style.wordBreak="break-all",l.textContent=".",c=l.clientHeight,l.textContent="";var C=t.split("").some(function(t,e){if(p.push(t),y=p.join(""),s=l.clientHeight,l.textContent=y+r+f,a=l.clientHeight,0===e&&(u=Math.floor(a/c)),i(s,a)&&0!==e){var o;l.textContent=y,o=l.clientHeight,i(s,o)?(v=!0,u+=1):v&&(d=e,v=!1)}return u>n?(0===e&&console.log("Your string is in a overflowed situation, please check your options"),p.pop(),l.textContent="",!0):void 0});return l.style.visibility=g,l.style.wordBreak=x,C?(h+=p.join("").slice(0,d),h+=r,h+=f):h=t,h}
|
||||
1
assets/webapps/bluetooth.gaiamobile.org/js/vendor/alameda.js
vendored
Normal file
1
assets/webapps/bluetooth.gaiamobile.org/js/vendor/alameda.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1 @@
|
|||
define([],function(){"use strict";function e(e,a,n){var c=n,r=document.createElement("span");t(r,c.name);var s=document.createElement("small");"remote"===e&&s.setAttribute("data-l10n-id","device-status-tap-connect");var o=document.createElement("li"),u=document.createElement("a");if(o.classList.add("bluetooth-device"),d("device.type = "+c.type),o.classList.add("bluetooth-type-"+c.type),i(o,s,c.descriptionText,e),u.appendChild(r),u.appendChild(s),o.appendChild(u),"remote"===e){var l=document.createElement("progress");l.classList.add("overlapping-icon"),l.classList.add("hidden"),o.appendChild(l)}return"function"==typeof a&&(o.onclick=function(){a(n)}),c.observe("name",function(e){t(r,e)}),c.observe("descriptionText",function(t){i(o,s,t,e)}),o}function t(e,t){""!==t?(e.removeAttribute("data-l10n-id"),e.textContent=t):e.setAttribute("data-l10n-id","unnamed-device")}function i(e,t,i,a){switch(d("_updateItemDescriptionText(): descriptionText = "+i),i){case"tapToConnect":e.removeAttribute("aria-disabled"),t.setAttribute("data-l10n-id","device-status-tap-connect"),"remote"===a&&(e.querySelector("progress").classList.add("hidden"),e.classList.remove("icon-hidden"));break;case"pairing":e.setAttribute("aria-disabled",!0),t.setAttribute("data-l10n-id","device-status-pairing"),"remote"===a&&(e.classList.add("icon-hidden"),e.querySelector("progress").classList.remove("hidden"));break;case"paired":e.removeAttribute("aria-disabled"),t.setAttribute("data-l10n-id","device-status-paired"),"remote"===a&&(e.querySelector("progress").classList.add("hidden"),e.classList.remove("icon-hidden"));break;case"connecting":e.setAttribute("aria-disabled",!0),t.setAttribute("data-l10n-id","device-status-connecting");break;case"connectedWithDeviceMedia":e.removeAttribute("aria-disabled"),t.setAttribute("data-l10n-id","device-status-connected-device-media");break;case"connectedWithDevice":e.removeAttribute("aria-disabled"),t.setAttribute("data-l10n-id","device-status-connected-device");break;case"connectedWithMedia":e.removeAttribute("aria-disabled"),t.setAttribute("data-l10n-id","device-status-connected-media");break;case"connectedWithNoProfileInfo":e.removeAttribute("aria-disabled"),t.setAttribute("data-l10n-id","device-status-connected");break;case"disconnected":e.removeAttribute("aria-disabled"),t.removeAttribute("data-l10n-id"),t.textContent=""}}var a=!1,d=function(){};return a&&(d=function(e){console.log("--> [BluetoothTemplateFactory]: "+e)}),function(t,i){return e.bind(null,t,i)}});
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* CannotTransferDialog is responsible for:
|
||||
* Alert a user that the activity request is not able to transfer via
|
||||
* Bluetooth with some reason. There is an alert handling via here.
|
||||
*/
|
||||
define([],function() {
|
||||
'use strict';
|
||||
|
||||
var CannotTransferDialog = {
|
||||
init: function() {
|
||||
this.dialog = document.getElementById('alert-view');
|
||||
this.confirmBtn =
|
||||
document.getElementById('alert-button-ok');
|
||||
},
|
||||
|
||||
showConfirm: function() {
|
||||
return new Promise((resolve) => {
|
||||
this.confirmBtn.onclick = () => {
|
||||
this.close();
|
||||
resolve();
|
||||
};
|
||||
this.dialog.hidden = false;
|
||||
});
|
||||
},
|
||||
|
||||
get isVisible() {
|
||||
return (!this.dialog.hidden);
|
||||
},
|
||||
|
||||
close : function() {
|
||||
this.dialog.hidden = true;
|
||||
}
|
||||
};
|
||||
|
||||
CannotTransferDialog.init();
|
||||
return CannotTransferDialog;
|
||||
});
|
||||
|
|
@ -0,0 +1,413 @@
|
|||
/*
|
||||
* DevicePickerPanel is responsible for:
|
||||
* - Render paired/found devices list in this panel.
|
||||
* - Emit 'devicePicked' event with device address while a user click on a
|
||||
* paired device.
|
||||
* - Pair with the picked device while a user click on a found device.
|
||||
* Emit 'devicePicked' event with device address until the picked device
|
||||
* is pared.
|
||||
* - Emit 'cancelSelection' event while a user click close button on the
|
||||
* header for leaving.
|
||||
*
|
||||
* @module DevicePickerPanel
|
||||
*/
|
||||
define(['require','modules/bluetooth/bluetooth_context','views/bt_template_factory','modules/mvvm/list_view'],function(require) {
|
||||
'use strict';
|
||||
|
||||
var BtContext = require('modules/bluetooth/bluetooth_context');
|
||||
var BtTemplateFactory = require('views/bt_template_factory');
|
||||
var ListView = require('modules/mvvm/list_view');
|
||||
|
||||
var _debug = false;
|
||||
var debug = function() {};
|
||||
if (_debug) {
|
||||
debug = function btdpp_debug(msg) {
|
||||
console.log('--> [DevicePickerPanel]: ' + msg);
|
||||
};
|
||||
}
|
||||
|
||||
var DevicePickerPanel = {
|
||||
/**
|
||||
* A instance to maintain paired devices list view.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf DevicePickerPanel
|
||||
* @type {Object}
|
||||
*/
|
||||
_pairedDevicesListView: null,
|
||||
|
||||
/**
|
||||
* A instance to maintain found devices list view.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf DevicePickerPanel
|
||||
* @type {Object}
|
||||
*/
|
||||
_foundDevicesListView: null,
|
||||
|
||||
/**
|
||||
* Construct elements.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf DevicePickerPanel
|
||||
* @type {Object}
|
||||
*/
|
||||
_elements: {
|
||||
view: {
|
||||
viewDevicePicker: document.querySelector('.devices-list-view')
|
||||
},
|
||||
header: {
|
||||
headerActionClose: document.querySelector('.devices-list-header')
|
||||
},
|
||||
paired: {
|
||||
pairedDevicesHeader: document.querySelector('.bluetooth-paired-title'),
|
||||
pairedDevicesList: document.querySelector('.bluetooth-paired-devices')
|
||||
},
|
||||
found: {
|
||||
foundDevicesHeader: document.querySelector('.bluetooth-found-title'),
|
||||
foundDevicesList: document.querySelector('.bluetooth-devices')
|
||||
},
|
||||
search: {
|
||||
searchingItem: document.querySelector('.bluetooth-searching'),
|
||||
searchItem: document.querySelector('.bluetooth-search'),
|
||||
searchButton: document.querySelector('.search-device')
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* The object maintains listeners' callback per property name.
|
||||
* Each listener would be called as following definition.
|
||||
* 'devicePicked' - be called when device is picked and paired successfully.
|
||||
* 'cancelSelection': - be called when user click on close button on header.
|
||||
*
|
||||
* @memberOf DevicePickerPanel
|
||||
* @type {Object}
|
||||
*/
|
||||
_listeners: {
|
||||
'devicePicked': [],
|
||||
'cancelSelection': []
|
||||
},
|
||||
|
||||
/**
|
||||
* Default adapter of Bluetooth.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf DevicePickerPanel
|
||||
* @type {Object} BluetoothAdapter
|
||||
*/
|
||||
_defaultAdapter: null,
|
||||
|
||||
/**
|
||||
* The function init the paired/found devices list and search button.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf DevicePickerPanel
|
||||
*/
|
||||
_init: function btdpp__init() {
|
||||
// Bind click event for header.
|
||||
this._elements.header.headerActionClose.addEventListener('action',
|
||||
this._onHeaderActionCloseClick.bind(this));
|
||||
|
||||
// Bind click event for search button.
|
||||
this._elements.search.searchButton.addEventListener('click',
|
||||
this._onSearchButtonClick.bind(this));
|
||||
|
||||
// Bind click event for paired device item.
|
||||
var pairedDeviceTemplate =
|
||||
BtTemplateFactory('paired', this._onPairedDeviceItemClick.bind(this));
|
||||
|
||||
// Create paired devices list view.
|
||||
this._pairedDevicesListView =
|
||||
ListView(this._elements.paired.pairedDevicesList,
|
||||
BtContext.getPairedDevices(),
|
||||
pairedDeviceTemplate);
|
||||
|
||||
// Bind click event for found device item.
|
||||
var foundDeviceTemplate =
|
||||
BtTemplateFactory('remote', this._onFoundDeviceItemClick.bind(this));
|
||||
|
||||
// Create found devices list view.
|
||||
this._foundDevicesListView =
|
||||
ListView(this._elements.found.foundDevicesList,
|
||||
BtContext.getRemoteDevices(),
|
||||
foundDeviceTemplate);
|
||||
|
||||
// Init items state.
|
||||
this._initItemsState();
|
||||
|
||||
// search devices automatically while the panel is inited.
|
||||
BtContext.startDiscovery().then(() => {
|
||||
debug('_init(): startDiscovery successfully');
|
||||
}, (reason) => {
|
||||
debug('_init(): startDiscovery failed, ' +
|
||||
'reason = ' + reason);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* The function init these elements which are displayed in this panel.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf DevicePickerPanel
|
||||
*/
|
||||
_initItemsState: function btdpp__initItemsState() {
|
||||
// paired devices header
|
||||
BtContext.observe('hasPairedDevice',
|
||||
this._updatePairedDevicesHeader.bind(this));
|
||||
this._updatePairedDevicesHeader(BtContext.hasPairedDevice);
|
||||
|
||||
// paired devices list
|
||||
BtContext.observe('state', this._updatePairedDevicesList.bind(this));
|
||||
this._updatePairedDevicesList(BtContext.state);
|
||||
|
||||
// found devices list
|
||||
BtContext.observe('state', this._updateFoundDevicesList.bind(this));
|
||||
this._updateFoundDevicesList(BtContext.state);
|
||||
|
||||
this._pairedDevicesListView.enabled = true;
|
||||
this._foundDevicesListView.enabled = true;
|
||||
|
||||
// search
|
||||
BtContext.observe('state', this._updateSearchItem.bind(this));
|
||||
this._updateSearchItem(BtContext.state);
|
||||
|
||||
BtContext.observe('discovering', this._updateSearchingItem.bind(this));
|
||||
this._updateSearchingItem(BtContext.discovering);
|
||||
},
|
||||
|
||||
/**
|
||||
* Start discovery devices while the search button is clicked.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf DevicePickerPanel
|
||||
*/
|
||||
_onHeaderActionCloseClick: function btdpp__onHeaderActionCloseClick() {
|
||||
debug('_onHeaderActionCloseClick():');
|
||||
// Emit 'cancelSelection' event to outer modules.
|
||||
var event = {
|
||||
type: 'cancelSelection'
|
||||
};
|
||||
this._emitEvent(event);
|
||||
},
|
||||
|
||||
/**
|
||||
* Start discovery devices while the search button is clicked.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf DevicePickerPanel
|
||||
*/
|
||||
_onSearchButtonClick: function btdpp__onSearchButtonClick() {
|
||||
debug('_onSearchButtonClick():');
|
||||
BtContext.startDiscovery().then(() => {
|
||||
debug('_onSearchButtonClick(): startDiscovery successfully');
|
||||
}, (reason) => {
|
||||
debug('_onSearchButtonClick(): startDiscovery failed, ' +
|
||||
'reason = ' + reason);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* While the paired device is clicked, emit event to outer modules with
|
||||
* the device item.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf DevicePickerPanel
|
||||
*/
|
||||
_onPairedDeviceItemClick:
|
||||
function btdpp__onPairedDeviceItemClick(deviceItem) {
|
||||
debug('_onPairedDeviceItemClick(): deviceItem.address = ' +
|
||||
deviceItem.address);
|
||||
debug('_onPairedDeviceItemClick(): deviceItem.paired = ' +
|
||||
deviceItem.paired);
|
||||
// Emit 'devicePicked' event to outer modules with the device item.
|
||||
var event = {
|
||||
type: 'devicePicked',
|
||||
detail: {
|
||||
address: deviceItem.address
|
||||
}
|
||||
};
|
||||
this._emitEvent(event);
|
||||
},
|
||||
|
||||
/**
|
||||
* Pair with the clicked device. While the device is paired successfully,
|
||||
* emit event to outer modules with the device item.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf DevicePickerPanel
|
||||
*/
|
||||
_onFoundDeviceItemClick:
|
||||
function btdpp__onFoundDeviceItemClick(deviceItem) {
|
||||
debug('_onFoundDeviceItemClick(): deviceItem.address = ' +
|
||||
deviceItem.address);
|
||||
// Update device pairing status first.
|
||||
deviceItem.paired = 'pairing';
|
||||
// Pair with the remote device.
|
||||
BtContext.pair(deviceItem.address).then(() => {
|
||||
debug('_onFoundDeviceItemClick(): pair successfully');
|
||||
// Emit 'devicePicked' event to outer modules with the
|
||||
// paired device item.
|
||||
var event = {
|
||||
type: 'devicePicked',
|
||||
detail: {
|
||||
address: deviceItem.address
|
||||
}
|
||||
};
|
||||
this._emitEvent(event);
|
||||
}, (reason) => {
|
||||
debug('_onFoundDeviceItemClick(): pair failed, ' +
|
||||
'reason = ' + reason);
|
||||
// Reset the paired status back to false,
|
||||
// since the 'pairing' status is given in Gaia side.
|
||||
deviceItem.paired = false;
|
||||
// Show alert message while pair device failed.
|
||||
this._alertPairFailedErrorMessage(reason);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Show an alert dialog since the device is failed in pairing process.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf DevicePickerPanel
|
||||
*/
|
||||
_alertPairFailedErrorMessage:
|
||||
function btdpp__alertPairFailedErrorMessage(errorReason) {
|
||||
var _ = navigator.mozL10n.get;
|
||||
var msg = _('error-pair-title'); // This is a default message.
|
||||
if (errorReason === 'Repeated Attempts') {
|
||||
msg = msg + '\n' + _('error-pair-toofast');
|
||||
} else if (errorReason === 'Authentication Failed') {
|
||||
msg = msg + '\n' + _('error-pair-pincode');
|
||||
}
|
||||
window.alert(msg);
|
||||
},
|
||||
|
||||
/**
|
||||
* A function to show/hide paired devices header.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf DevicePickerPanel
|
||||
*/
|
||||
_updatePairedDevicesHeader:
|
||||
function btdpp__updatePairedDevicesHeader(hasPairedDevice) {
|
||||
debug('_updatePairedDevicesHeader(): ' +
|
||||
'callback from observe "hasPairedDevice" = ' + hasPairedDevice);
|
||||
this._elements.paired.pairedDevicesHeader.hidden =
|
||||
!(hasPairedDevice && (BtContext.state === 'enabled'));
|
||||
},
|
||||
|
||||
/**
|
||||
* A function to show/hide paired devices list.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf DevicePickerPanel
|
||||
*/
|
||||
_updatePairedDevicesList: function btdpp__updatePairedDevicesList(state) {
|
||||
debug('_updatePairedDevicesList(): ' +
|
||||
'callback from observe "state" = ' + state);
|
||||
this._elements.paired.pairedDevicesHeader.hidden =
|
||||
!((state === 'enabled') && (BtContext.hasPairedDevice));
|
||||
this._elements.paired.pairedDevicesList.hidden = (state !== 'enabled');
|
||||
},
|
||||
|
||||
/**
|
||||
* A function to show/hide found devices list.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf DevicePickerPanel
|
||||
*/
|
||||
_updateFoundDevicesList: function btdpp__updateFoundDevicesList(state) {
|
||||
debug('_updateFoundDevicesList(): ' +
|
||||
'callback from observe "state" = ' + state);
|
||||
this._elements.found.foundDevicesHeader.hidden = (state !== 'enabled');
|
||||
this._elements.found.foundDevicesList.hidden = (state !== 'enabled');
|
||||
},
|
||||
|
||||
/**
|
||||
* A function to show/hide search item.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf DevicePickerPanel
|
||||
*/
|
||||
_updateSearchItem: function btdpp__updateSearchItem(state) {
|
||||
debug('_updateSearchItem(): ' +
|
||||
'callback from observe "state" = ' + state);
|
||||
this._elements.search.searchItem.hidden = (state !== 'enabled');
|
||||
},
|
||||
|
||||
/**
|
||||
* A function to show/hide searching description item.
|
||||
*
|
||||
* @access private
|
||||
* @memberOf DevicePickerPanel
|
||||
*/
|
||||
_updateSearchingItem: function btdpp__updateSearchingItem(discovering) {
|
||||
debug('_updateSearchingItem(): ' +
|
||||
'callback from observe "discovering" = ' + discovering);
|
||||
this._elements.search.searchingItem.hidden = !discovering;
|
||||
this._elements.search.searchButton.disabled = discovering;
|
||||
},
|
||||
|
||||
/**
|
||||
* A function to emit event to each registered listener by the input type.
|
||||
*
|
||||
* @memberOf DevicePickerPanel
|
||||
* @param {Object} options
|
||||
* @param {String} options.type - type of event name
|
||||
* @param {Object} options.detail - the object pass to the listener
|
||||
*/
|
||||
_emitEvent: function btdpp__emitEvent(options) {
|
||||
this._listeners[options.type].forEach(function(listener) {
|
||||
listener(options);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* The method will provide event listener for outer modules to regist.
|
||||
*
|
||||
* @access public
|
||||
* @memberOf DevicePickerPanel
|
||||
* @param {String} eventName
|
||||
* @param {Function} callback
|
||||
*/
|
||||
addEventListener: function btdpp_addEventListener(eventName, callback) {
|
||||
if (callback && (this._listeners.hasOwnProperty(eventName))) {
|
||||
this._listeners[eventName].push(callback);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* The method will provide event listener for outer modules to un-regist.
|
||||
*
|
||||
* @access public
|
||||
* @memberOf DevicePickerPanel
|
||||
* @param {String} eventName
|
||||
* @param {Function} callback
|
||||
*/
|
||||
removeEventListener:
|
||||
function btdpp_removeEventListener(eventName, callback) {
|
||||
if (callback && (this._listeners.hasOwnProperty(eventName))) {
|
||||
var index = this._listeners[eventName].indexOf(callback);
|
||||
if (index >= 0) {
|
||||
this._listeners[eventName].splice(index, 1);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* The setter will show/hide the device picker view for outer modules.
|
||||
*
|
||||
* @access public
|
||||
* @memberOf DevicePickerPanel
|
||||
* @param {Boolean} enabled
|
||||
*/
|
||||
set visible(enabled) {
|
||||
// display device picker view
|
||||
this._elements.view.viewDevicePicker.hidden = !enabled;
|
||||
}
|
||||
};
|
||||
|
||||
DevicePickerPanel._init();
|
||||
return DevicePickerPanel;
|
||||
});
|
||||
|
|
@ -0,0 +1 @@
|
|||
define(["require"],function(i){"use strict";var n={init:function(){this.dialog=document.getElementById("pairing-request-timeout"),this.confirmBtn=document.getElementById("incoming-pairing-timeout-confirm")},showConfirm:function(i){var n=this;this.confirmBtn.onclick=function(){n.close(),i&&i()},this.dialog.hidden=!1},get isVisible(){return!this.dialog.hidden},close:function(){this.dialog.hidden=!0}};return n.init(),n});
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
;var Pairview={_debug:false,_remoteDeviceName:null,_pairMethod:null,_options:null,_passkey:'',pairview:document.getElementById('pair-view'),nameLabel:document.getElementById('label-name'),pairDescription:document.getElementById('pair-description'),pairButton:document.getElementById('button-pair'),closeButton:document.getElementById('button-close'),comfirmationItem:document.getElementById('confirmation-method'),pinInputItem:document.getElementById('pin-input-method'),passkey:document.getElementById('passkey'),pinInput:document.getElementById('pin-input'),get isFullAttentionMode(){return(window.innerHeight>200);},show:function pv_show(){if(!this.isFullAttentionMode){this.close();return;}
|
||||
var _=navigator.mozL10n.get;this.pairButton.addEventListener('click',this);this.closeButton.addEventListener('click',this);window.addEventListener('resize',this);this._remoteDeviceName=this._remoteDeviceName||_('unnamed-device');var truncatedDeviceName=getTruncated(this._remoteDeviceName,{node:this.nameLabel,maxLine:2,ellipsisIndex:3});this.nameLabel.textContent=truncatedDeviceName;this.pairview.hidden=false;var stringName='passive-pair-'+this._pairMethod;this.pairDescription.textContent=_(stringName,{device:truncatedDeviceName});switch(this._pairMethod){case'displaypasskey':case'confirmation':this.passkey.textContent=this._passkey;this.comfirmationItem.hidden=false;this.pinInputItem.hidden=true;break;case'enterpincode':this.pinInputItem.hidden=false;this.comfirmationItem.hidden=true;this.pinInput.focus();break;case'consent':this.comfirmationItem.hidden=false;this.pinInputItem.hidden=true;break;}},init:function pv_init(method,options){this._pairMethod=method;this._options=options;this._remoteDeviceName=options.deviceName;if(options.handle&&options.handle.passkey){var passkey=options.handle.passkey;var len=passkey.toString().length;var zeros=(len<6)?(new Array((6-len)+1)).join('0'):'';this._passkey=zeros+passkey;}
|
||||
navigator.mozL10n.once(Pairview.show.bind(Pairview));},close:function pv_close(){this._options.handle.reject().then(()=>{this.debug('Resolved in reject '+this._pairMethod+' request.');window.close();},(aReason)=>{this.debug('Rejected in reject '+this._pairMethod+' request with reason: '+aReason);window.close();});},closeInput:function pv_closeInput(){if(!this.pinInputItem.hidden){this.pinInput.blur();}},handleEvent:function pv_handleEvent(evt){var _=navigator.mozL10n.get;if(!evt.target){return;}
|
||||
switch(evt.type){case'click':evt.preventDefault();switch(evt.target.id){case'button-pair':this.pairDescription.textContent=_('device-status-waiting');this.pairButton.disabled=true;this.closeButton.disabled=true;switch(this._pairMethod){case'displaypasskey':window.close();break;case'enterpincode':var pinCode=this.pinInput.value;this._options.handle.setPinCode(pinCode).then(()=>{this.debug('Resolved setPinCode operation');window.close();},(aReason)=>{this.debug('Rejected setPinCode with reason: '+aReason);window.close();});break;case'confirmation':case'consent':this._options.handle.accept().then(()=>{this.debug('Resolved in '+this._pairMethod+' request');window.close();},(aReason)=>{this.debug('Rejected in '+this._pairMethod+' request '+' with reason: '+aReason);window.close();});break;}
|
||||
break;case'button-close':this.close();break;}
|
||||
break;case'resize':if(!this.isFullAttentionMode){this.close();}
|
||||
break;default:break;}},debug:function(msg){if(this._debug){console.log('Pairview(): '+msg);}}};
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* TurnBluetoothOnDialog is responsible for:
|
||||
* A confirmation dialog to ask user to turn Bluetooth on or not.
|
||||
* There is a confirmation handling via here.
|
||||
*/
|
||||
define([],function() {
|
||||
'use strict';
|
||||
|
||||
var TurnBluetoothOnDialog = {
|
||||
init: function() {
|
||||
this.dialog = document.getElementById('enable-bluetooth-view');
|
||||
this.cancelBtn =
|
||||
document.getElementById('enable-bluetooth-button-cancel');
|
||||
this.confirmBtn =
|
||||
document.getElementById('enable-bluetooth-button-turn-on');
|
||||
},
|
||||
|
||||
showConfirm: function() {
|
||||
return new Promise((resolve) => {
|
||||
this.cancelBtn.onclick = (evt) => {
|
||||
if (evt) {
|
||||
evt.preventDefault();
|
||||
}
|
||||
this.close();
|
||||
resolve('cancel');
|
||||
};
|
||||
|
||||
this.confirmBtn.onclick = (evt) => {
|
||||
if (evt) {
|
||||
evt.preventDefault();
|
||||
}
|
||||
this.close();
|
||||
resolve('confirm');
|
||||
};
|
||||
|
||||
this.dialog.hidden = false;
|
||||
});
|
||||
},
|
||||
|
||||
get isVisible() {
|
||||
return (!this.dialog.hidden);
|
||||
},
|
||||
|
||||
close: function() {
|
||||
this.dialog.hidden = true;
|
||||
}
|
||||
};
|
||||
|
||||
TurnBluetoothOnDialog.init();
|
||||
return TurnBluetoothOnDialog;
|
||||
});
|
||||
18
assets/webapps/bluetooth.gaiamobile.org/jsdoc.json
Normal file
18
assets/webapps/bluetooth.gaiamobile.org/jsdoc.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"name": "bluetooth",
|
||||
"tags": {
|
||||
"dictionaries": ["jsdoc"]
|
||||
},
|
||||
"source": {
|
||||
"include": ["apps/bluetooth/js/**/*.js"]
|
||||
},
|
||||
"plugins": [
|
||||
"plugins/markdown",
|
||||
"plugins/summarize"
|
||||
],
|
||||
"opts": {
|
||||
"private": true,
|
||||
"recurse": true,
|
||||
"destination": "docs/bluetooth"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue