Publish Vulpes Preview 15 sources for desktop, Android and Tundra
Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
This commit is contained in:
parent
93e3a48004
commit
b38dea0597
25901 changed files with 9982925 additions and 369 deletions
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
/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
|
||||||
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"]
|
||||||
|
}
|
||||||
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.
|
||||||
257
README.fr.md
257
README.fr.md
|
|
@ -1,219 +1,92 @@
|
||||||
<p align="center">
|
<p align="center"><a href="https://nnsprod.com/git/oversu/VulpesOS">🇬🇧 English</a> · <strong>🇫🇷 Français</strong></p>
|
||||||
<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 align="center"><img src="docs/media/vulpes-logo.png" width="160" alt="Logo renard de Vulpes OS"></p>
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<img src="docs/media/vulpes-logo.png" width="160" alt="Logo Vulpes OS — un renard blanc et bleu">
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h1 align="center">Vulpes OS</h1>
|
<h1 align="center">Vulpes OS</h1>
|
||||||
|
<p align="center"><strong>L'esprit de Firefox OS. Un Gecko moderne. Une base Gaia commune.</strong><br>Un projet communautaire pour retrouver une expérience mobile fondée sur le Web, sur PC, Android et téléphone natif.</p>
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<strong>L’esprit Firefox OS. Un moteur Gecko d’aujourd’hui.</strong><br>
|
<img src="https://img.shields.io/badge/Vulpes-2.7_preview.15-0755be?style=flat-square" alt="Vulpes Preview 15">
|
||||||
A community continuation of Firefox OS.
|
<img src="https://img.shields.io/badge/Gecko_bureau-156.0.1-0099cc?style=flat-square" alt="Gecko bureau 156.0.1">
|
||||||
|
<img src="https://img.shields.io/badge/Statut-Expérimental-f3a712?style=flat-square" alt="Expérimental">
|
||||||
</p>
|
</p>
|
||||||
|
<p align="center"><a href="https://vulpes-os.org">🌐 Le projet</a> · <a href="https://nnsprod.com/git/oversu/VulpesOS/releases">📦 Téléchargements</a> · <a href="https://vulpes-os.org/changelog.php">📝 Changelog</a> · <a href="https://nnsprod.com/git/oversu/VulpesOS/issues">🐞 Signaler un bug</a></p>
|
||||||
|
|
||||||
<p align="center">
|
## 🦊 Le Web comme interface
|
||||||
<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">
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
Accueil, paramètres, appareil photo, galerie, messages : **Gaia reste l'interface**, composée de HTML, CSS et JavaScript. Vulpes adapte les API attendues par ces applications Firefox OS à un moteur Gecko moderne et à des services propres à chaque plateforme.
|
||||||
<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>
|
|
||||||
|
|
||||||
---
|
Ce dépôt contient désormais l'interface commune, l'hôte PC, l'application Android, le runtime Tundra et les outils de développement. C'est une **première préversion pour développeurs**, pas un système mobile terminé.
|
||||||
|
|
||||||
## 🦊 Le Web comme plateforme d’applications
|
<p align="center"><img src="docs/media/vulpes-desktop.png" width="290" alt="Capture réelle du bureau Gaia en anglais"> <img src="docs/media/vulpes-gecko.png" width="290" alt="Capture réelle des informations système et du moteur Gecko"></p>
|
||||||
|
<p align="center"><em>Captures de l'environnement de développement PC. Les fonctionnalités dépendent de la plateforme et du matériel disponible.</em></p>
|
||||||
|
|
||||||
Et si l’interface de votre téléphone était faite avec les mêmes technologies que vos sites web ?
|
## 📥 Choisissez votre plateforme
|
||||||
|
|
||||||
**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.
|
| Plateforme | Disponible aujourd'hui | Moteur |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **PC Linux** | Sources, fenêtre Gaia au format téléphone, développement et tests d'applications | Gecko **156.0.1** |
|
||||||
|
| **APK Android** | Preview installable ; Android et ses permissions restent en dessous | GeckoView **156.0** |
|
||||||
|
| **Pixel 3a · Sargo** | Sources Tundra ; build natif de développement testé sur notre appareil | Gecko **156.0.1** ARM64 |
|
||||||
|
|
||||||
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.
|
### Android — Preview 15
|
||||||
|
|
||||||
**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.
|
Téléchargez l'APK et `SHA256SUMS.txt` depuis [la release Preview 15](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/v2.7-preview.15). Une installation par-dessus une ancienne Preview Vulpes signée conserve ses données.
|
||||||
|
|
||||||
> **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.
|
- Minimum déclaré : **Android 8 / API 26**. Cible : **API 37**.
|
||||||
|
- APK universel : **ARM64, ARMv7 et x86_64** ; environ **600 Mio**, moteur GeckoView inclus.
|
||||||
|
- Photo et Galerie utilisent Gaia, avec les permissions et l'import de fichiers Android.
|
||||||
|
- Les appels et SMS sont transmis aux applications Android ; Vulpes n'est pas l'application Téléphone/SMS par défaut.
|
||||||
|
- Des parcours sur émulateur Android 16 sont testés. Le minimum SDK déclaré ne signifie pas que tous les appareils Android ont été validés.
|
||||||
|
- Il s'agit d'un **build de développement signé**, avec débogage Gecko accessible via ADB autorisé, pas d'une application durcie pour la production.
|
||||||
|
|
||||||
<p align="center">
|
### Linux — lancer depuis les sources
|
||||||
<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>
|
Prérequis : Linux x86_64, Python 3, Git et les bibliothèques système nécessaires à Firefox. Sur Debian/Ubuntu : `git python3 libgtk-3-0 libdbus-glib-1-2 libasound2` (les noms de paquets peuvent varier selon la distribution).
|
||||||
|
|
||||||
<a name="telechargements"></a>
|
```sh
|
||||||
|
git clone https://nnsprod.com/git/oversu/VulpesOS.git
|
||||||
## 📥 Tester la Preview Android
|
cd VulpesOS
|
||||||
|
./setup.sh
|
||||||
**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`.
|
./run.sh
|
||||||
|
|
||||||
| 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Une frontière claire entre les applications et le système
|
`setup.sh` télécharge le moteur Mozilla verrouillé et vérifie son empreinte. Le lancement normal ouvre Gaia sans débogueur. Les profils et moteurs téléchargés restent hors Git.
|
||||||
|
|
||||||
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.
|
### Pixel 3a — développement natif Tundra
|
||||||
|
|
||||||
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.
|
**Le build natif démarre directement dans Vulpes**, avec des services matériels Android sélectionnés, un espace utilisateur Linux, Gecko et Gaia. Phosh et l'environnement applicatif Android ne sont pas démarrés.
|
||||||
|
|
||||||
### Des mises à jour vérifiées avant activation
|
Notre image de développement Sargo installée a passé les contrôles de démarrage et de persistance. Des appels réels et des SMS dans les deux sens ont été observés ; les derniers essais couvrent les conversations, les bascules de capteur photo, la capture JPEG pleine résolution, le focus tactile, le zoom numérique et l'arrêt du vibreur.
|
||||||
|
|
||||||
```text
|
**Cette release ne contient pas d'image générique à flasher.** Le boot privé dépend encore de la disposition du stockage du téléphone inventorié, d'images système/données déjà déployées et d'accès de maintenance. Publier ce fichier ne fournirait pas une installation fonctionnelle à un autre Pixel. Les recettes Tundra sont incluses pour les contributeurs ; elles nécessitent un inventaire, des sauvegardes vérifiées et des composants matériels fournis localement. Ne réutilisez pas la sauvegarde d'un autre appareil et ne considérez pas un boot isolé comme une ROM complète.
|
||||||
Télécharger → Vérifier l’empreinte → Tester les interfaces
|
|
||||||
↓
|
## ✨ Ce que nous partageons
|
||||||
Ancien moteur conservé ← Activer ← Tester Gaia et la persistance
|
|
||||||
|
- **Une base Gaia :** interface et services communs pour PC, Android et Tundra.
|
||||||
|
- **Les applications d'origine :** Photo, Galerie, Paramètres et Messages conservent leur interface Gaia, reliée aux adaptateurs de chaque plateforme.
|
||||||
|
- **Du code Web éditable :** les ressources servies sont dans `assets/webapps/`, la compatibilité commune dans `host/`, les remplacements dans `overrides/`. Rechargez ou redémarrez pour tester, sans recompiler Gecko. Certaines sources historiques de `gaia/` nécessitent encore la mise à jour de leur version regroupée.
|
||||||
|
- **Un moteur remplaçable :** téléchargements verrouillés, contrôle d'empreinte, tests isolés et outils de retour arrière. Une mise à jour du moteur se teste et se sélectionne explicitement ; la compatibilité n'est pas garantie automatiquement.
|
||||||
|
- **Des versions distinctes :** Vulpes, Gaia, Gecko et adaptation matérielle sont identifiés séparément. GeckoView 156.0 n'est pas présenté comme Gecko bureau 156.0.1.
|
||||||
|
|
||||||
|
## 🧪 Limites actuelles
|
||||||
|
|
||||||
|
Cette Preview contient des adaptateurs matériels incomplets et des comportements hérités à corriger. Sur Sargo, l'aperçu photo tourne autour de 16 images/s ; flash/vidéo, rotation automatique par capteur et suspension complète restent incomplets. L'enregistrement réseau peut être instable et l'audio d'une conversation dans les deux sens reste à qualifier. Un grand dialogue signalé après envoi de SMS est encore en cours d'analyse.
|
||||||
|
|
||||||
|
Sur Android, les intégrations suivent les permissions et les restrictions de cycle de vie du système. Les alarmes Gaia ne sont pas garanties après l'arrêt de Vulpes par Android. Certaines commandes non prises en charge restent visibles mais désactivées. Des applications de développement/test sont incluses.
|
||||||
|
|
||||||
|
Conservez une sauvegarde avant vos essais. Pour signaler un problème, indiquez la plateforme, les versions Vulpes/Gecko et les étapes de reproduction ; retirez des logs les numéros, contacts et autres informations personnelles.
|
||||||
|
|
||||||
|
## 🛠️ Construire l'APK Android
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd android
|
||||||
|
./build-apk.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
Le script installe les outils verrouillés, prépare les ressources Gaia communes et construit un APK de développement signé dans `android/dist/`. L'acceptation des licences SDK Android peut être nécessaire. Un clone neuf génère sa propre clé de signature : il ne pourra pas mettre à jour un APK signé avec la clé privée du projet.
|
||||||
|
|
||||||
### Une base de travail testée
|
## 📜 Remerciements et licences
|
||||||
|
|
||||||
Les contrôles automatisés couvrent notamment :
|
Vulpes OS est un projet communautaire d'**oversu / NNS Production**, inspiré de Firefox OS. Merci à **Mozilla, aux contributeurs Gaia, à Capyloon, Droidian, UBports, libhybris, Debian, AOSP** et aux communautés qui font vivre les systèmes mobiles ouverts.
|
||||||
|
|
||||||
- le démarrage de la vraie interface Gaia et le chargement des icônes ;
|
Les nouveaux fichiers Vulpes utilisent **MPL 2.0**. Gaia conserve **Apache 2.0** ; les composants hérités conservent leurs licences et mentions. Voir [LICENSE](LICENSE), [NOTICE](NOTICE) et [la licence Gaia](gaia/LICENSE). Les forks doivent conserver les mentions d'attribution et de licence applicables.
|
||||||
- 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.
|
|
||||||
|
|
||||||
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.
|
Vulpes OS est indépendant de Mozilla et n'est pas une version officielle de Firefox OS.
|
||||||
|
|
||||||
<a name="suite"></a>
|
|
||||||
|
|
||||||
## 🗺️ La suite
|
|
||||||
|
|
||||||
- **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>
|
|
||||||
|
|
|
||||||
254
README.md
254
README.md
|
|
@ -1,218 +1,92 @@
|
||||||
<p align="center">
|
<p align="center"><strong>🇬🇧 English</strong> · <a href="https://nnsprod.com/git/oversu/VulpesOS/src/branch/main/README.fr.md">🇫🇷 Français</a></p>
|
||||||
<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 align="center"><img src="docs/media/vulpes-logo.png" width="160" alt="Vulpes OS fox logo"></p>
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<img src="docs/media/vulpes-logo.png" width="160" alt="Vulpes OS logo — a white and blue fox">
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h1 align="center">Vulpes OS</h1>
|
<h1 align="center">Vulpes OS</h1>
|
||||||
|
<p align="center"><strong>The spirit of Firefox OS. A modern Gecko. One shared Gaia.</strong><br>A community project bringing a web-based phone experience to desktop, Android and native hardware.</p>
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<strong>The spirit of Firefox OS. Powered by a modern Gecko.</strong><br>
|
<img src="https://img.shields.io/badge/Vulpes-2.7_preview.15-0755be?style=flat-square" alt="Vulpes Preview 15">
|
||||||
A community continuation of Firefox OS.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<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/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/Status-Experimental-f3a712?style=flat-square" alt="Experimental">
|
||||||
<img src="https://img.shields.io/badge/Platforms-Linux_%C2%B7_Android-334155?style=flat-square" alt="Linux and Android">
|
|
||||||
</p>
|
</p>
|
||||||
|
<p align="center"><a href="https://vulpes-os.org">🌐 Website</a> · <a href="https://nnsprod.com/git/oversu/VulpesOS/releases">📦 Downloads</a> · <a href="https://vulpes-os.org/changelog.php">📝 Changelog</a> · <a href="https://nnsprod.com/git/oversu/VulpesOS/issues">🐞 Report a bug</a></p>
|
||||||
|
|
||||||
<p align="center">
|
## 🦊 The Web as your interface
|
||||||
<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>
|
|
||||||
|
|
||||||
---
|
Home screen, settings, camera, gallery, messages: **Gaia remains the interface**, built from HTML, CSS and JavaScript. Vulpes adapts the APIs that these Firefox OS applications expect to a modern Gecko engine and platform-specific services.
|
||||||
|
|
||||||
## 🦊 The Web as an application platform
|
This repository now contains the shared interface, desktop host, Android application, Tundra runtime and development tools. This is an **early developer preview**, not a finished phone operating system.
|
||||||
|
|
||||||
What if your phone’s interface were built with the same technologies as your websites?
|
<p align="center"><img src="docs/media/vulpes-desktop.png" width="290" alt="Actual desktop screenshot of the Gaia home screen in English"> <img src="docs/media/vulpes-gecko.png" width="290" alt="Actual desktop screenshot of device information showing the Gecko engine"></p>
|
||||||
|
<p align="center"><em>Desktop development screenshots. Capabilities depend on the platform and available hardware.</em></p>
|
||||||
|
|
||||||
**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.
|
## 📥 Choose your platform
|
||||||
|
|
||||||
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.
|
| Platform | Available today | Engine |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Linux desktop** | Source checkout, phone-sized Gaia window, development and app tests | Gecko **156.0.1** |
|
||||||
|
| **Android APK** | Installable Preview; keeps Android and its permissions underneath | GeckoView **156.0** |
|
||||||
|
| **Pixel 3a · Sargo** | Tundra sources; native development build tested on our device | Gecko **156.0.1** ARM64 |
|
||||||
|
|
||||||
**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.
|
### Android — Preview 15
|
||||||
|
|
||||||
> **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.
|
Download the APK and `SHA256SUMS.txt` from [the Preview 15 release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/v2.7-preview.15). Installing over an earlier signed Vulpes Preview preserves its data.
|
||||||
|
|
||||||
<p align="center">
|
- Minimum declared: **Android 8 / API 26**. Target: **API 37**.
|
||||||
<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>
|
- Universal APK: **ARM64, ARMv7 and x86_64**; approximately **600 MiB** including GeckoView.
|
||||||
|
- Camera and Gallery use Gaia, with Android permissions and file import.
|
||||||
<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>
|
- Calls and SMS hand off to Android's applications; Vulpes is not the default SMS or phone app.
|
||||||
</p>
|
- Android 16 emulator workflows are tested. Minimum SDK support is not a claim that every Android device is validated.
|
||||||
|
- This is a signed **development build** with Gecko debugging available through authorized ADB, not a production-hardened app.
|
||||||
|
|
||||||
<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>
|
### Linux — run from source
|
||||||
|
|
||||||
<a name="downloads"></a>
|
Linux x86_64, Python 3, Git and the system libraries needed by Firefox are required. On Debian/Ubuntu, install `git python3 libgtk-3-0 libdbus-glib-1-2 libasound2` (package names may differ by distribution).
|
||||||
|
|
||||||
## 📥 Try the Android Preview
|
```sh
|
||||||
|
git clone https://nnsprod.com/git/oversu/VulpesOS.git
|
||||||
**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.
|
cd VulpesOS
|
||||||
|
./setup.sh
|
||||||
| Version | Changes | Use |
|
./run.sh
|
||||||
| :--- | :--- | :--- |
|
|
||||||
| [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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### A clear boundary between applications and the system
|
`setup.sh` downloads the pinned Mozilla engine and verifies its checksum. Normal startup opens the Gaia interface without a debugger. Profiles and downloaded engines stay outside Git.
|
||||||
|
|
||||||
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.
|
### Pixel 3a — native Tundra development
|
||||||
|
|
||||||
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.
|
**The native build boots directly into Vulpes**, with selected Android hardware services, Linux userspace, Gecko and Gaia; Phosh and the Android application framework are not started.
|
||||||
|
|
||||||
### Updates checked before activation
|
Our installed Sargo development image has passed startup and persistence checks. Real calls and two-way SMS have been observed; the latest checks cover conversation rendering, camera sensor switching, full-resolution JPEG capture, touch focus, digital zoom and bounded vibration.
|
||||||
|
|
||||||
```text
|
**A generic flashable image is not included in this release.** The current private boot depends on the inventoried phone's storage layout, staged system/data images and maintenance credentials. Publishing that file would not give another Pixel a working installation. Tundra's source recipes are included for contributors; they require device inventory, verified backups and locally supplied hardware components. Do not use another device's backup or treat a boot image as a complete ROM.
|
||||||
Download → Verify checksum → Test engine interfaces
|
|
||||||
↓
|
## ✨ What is shared
|
||||||
Keep old engine ← Activate ← Test Gaia and persistence
|
|
||||||
|
- **One Gaia base:** interface and common services serve desktop, Android and Tundra.
|
||||||
|
- **Original applications:** Camera, Gallery, Settings and Messages keep their Gaia UI, backed by platform adapters.
|
||||||
|
- **Editable web code:** change served assets in `assets/webapps/`, shared compatibility code in `host/`, and replacements in `overrides/`. Restart/reload to test; no Gecko rebuild is needed for these changes. Some historical `gaia/` sources still need their bundled counterparts updated.
|
||||||
|
- **A replaceable engine:** pinned downloads, checksum verification, isolated compatibility checks and rollback tools. An engine update is tested and explicitly selected; compatibility is not guaranteed automatically.
|
||||||
|
- **Separate version numbers:** Vulpes release, Gaia, Gecko and hardware adaptation are reported independently. GeckoView 156.0 is not presented as desktop Gecko 156.0.1.
|
||||||
|
|
||||||
|
## 🧪 Current limits
|
||||||
|
|
||||||
|
This preview contains incomplete hardware adapters and legacy application behavior. On Sargo, camera preview is around 16 fps; flash/video, automatic sensor rotation and full suspend remain incomplete. Network registration can be unstable and two-way call audio still needs end-to-end qualification. A reported large dialog after SMS sending remains under investigation.
|
||||||
|
|
||||||
|
On Android, platform integration follows Android permissions and lifecycle restrictions. Gaia alarms are not guaranteed after Android stops Vulpes. Unsupported controls may remain visible but disabled. Development/test applications are included.
|
||||||
|
|
||||||
|
Keep a backup before testing. Include the platform, Vulpes/Gecko versions and reproduction steps when reporting a problem; remove phone numbers, contacts and other personal information from logs.
|
||||||
|
|
||||||
|
## 🛠️ Build the Android APK
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd android
|
||||||
|
./build-apk.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
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 script installs the pinned toolchain, packages the shared Gaia resources and builds a signed development APK in `android/dist/`. Android SDK licenses may need acceptance. A fresh checkout generates its own signing key; it cannot update an APK signed by the project's private release key.
|
||||||
|
|
||||||
### A tested development base
|
## 📜 Credits and licenses
|
||||||
|
|
||||||
Automated checks cover, among other things:
|
Vulpes OS is a community project by **oversu / NNS Production**, inspired by Firefox OS. Thanks to **Mozilla, Gaia contributors, Capyloon, Droidian, UBports, libhybris, Debian, AOSP** and the communities keeping open mobile systems alive.
|
||||||
|
|
||||||
- booting the actual Gaia interface and loading application icons;
|
New Vulpes code uses **MPL 2.0**. Gaia retains **Apache 2.0** and inherited components retain their own licenses and notices. See [LICENSE](LICENSE), [NOTICE](NOTICE) and [Gaia's license](gaia/LICENSE). Preserve the applicable attribution and license notices in forks.
|
||||||
- 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.
|
|
||||||
|
|
||||||
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.
|
Vulpes OS is independent of Mozilla and is not an official Firefox OS release.
|
||||||
|
|
||||||
<a name="roadmap"></a>
|
|
||||||
|
|
||||||
## 🗺️ What’s next
|
|
||||||
|
|
||||||
- **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>
|
|
||||||
|
|
|
||||||
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}" }
|
||||||
30
android/app/src/main/AndroidManifest.xml
Normal file
30
android/app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<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,508 @@
|
||||||
|
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 ExecutorService io = Executors.newSingleThreadExecutor();
|
||||||
|
final Handler main = new Handler(Looper.getMainLooper());
|
||||||
|
GeckoResult<Object> pending;
|
||||||
|
String pendingType;
|
||||||
|
|
||||||
|
AndroidServices(VulpesApplication app) {
|
||||||
|
this.app = 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.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.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 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
161
android/app/src/main/java/org/vulpes_os/preview/GaiaServer.java
Normal file
161
android/app/src/main/java/org/vulpes_os/preview/GaiaServer.java
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
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;
|
||||||
|
private final ZipFile zip;
|
||||||
|
private final ServerSocket socket;
|
||||||
|
private final ExecutorService workers = Executors.newFixedThreadPool(4);
|
||||||
|
|
||||||
|
GaiaServer(Context context) throws IOException {
|
||||||
|
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 (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";
|
||||||
|
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,647 @@
|
||||||
|
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.systemBars() |
|
||||||
|
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()
|
||||||
|
);
|
||||||
|
return insets;
|
||||||
|
});
|
||||||
|
setContentView(root);
|
||||||
|
// 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 au bureau");
|
||||||
|
back.setOnClickListener(v -> closeBrowser());
|
||||||
|
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);
|
||||||
|
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 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void closeBrowser() {
|
||||||
|
webPanel.setVisibility(View.GONE);
|
||||||
|
gaiaView.setVisibility(View.VISIBLE);
|
||||||
|
setHomeSurface(app.homeVisible);
|
||||||
|
syncActive();
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private 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;
|
||||||
|
if (!camera || (audio != null && audio.length > 0) || video == null || video.length == 0) {
|
||||||
|
callback.reject();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
permission(new String[] { android.Manifest.permission.CAMERA }, granted -> {
|
||||||
|
if (granted) callback.grant(video[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,134 @@
|
||||||
|
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 homeVisible;
|
||||||
|
boolean homeAtBottom;
|
||||||
|
|
||||||
|
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;
|
||||||
|
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("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 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 (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>
|
||||||
438
android/extension/background.js
Normal file
438
android/extension/background.js
Normal file
|
|
@ -0,0 +1,438 @@
|
||||||
|
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 apps, values, settings, dataStores, contacts, messages, connections, alarms, storage;
|
||||||
|
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' && !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) {
|
||||||
|
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(
|
||||||
|
'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();
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
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 systemReady = false;
|
||||||
|
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 (['home', 'holdhome'].includes(message.type)) broadcast(message.type, {}, 'system');
|
||||||
|
if (message.type === 'android-resume') await refreshAndroid().catch(console.warn);
|
||||||
|
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.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;
|
||||||
|
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 === '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 === '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.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 contacts.request(app, params);
|
||||||
|
if (method === 'messages.request') return messages.request(app, params);
|
||||||
|
if (method === 'alarms.request') return alarms.request(peer, app, params);
|
||||||
|
if (method === 'iac.subscribe') {
|
||||||
|
connections.subscribe(peer);
|
||||||
|
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.start') {
|
||||||
|
if (params.name === 'record' && params.data?.type === 'photos') {
|
||||||
|
if (app.id === 'gallery') {
|
||||||
|
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);
|
||||||
|
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 === '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),
|
||||||
|
};
|
||||||
|
peers.add(peer);
|
||||||
|
});
|
||||||
|
port.onMessage.addListener(async (message) => {
|
||||||
|
let reply;
|
||||||
|
try {
|
||||||
|
await attached;
|
||||||
|
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 (peer.app.id === 'system' && ![...peers].some((p) => p.app.id === 'system'))
|
||||||
|
systemReady = false;
|
||||||
|
connections.cleanup(peer);
|
||||||
|
alarms.cleanup(peer);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
});
|
||||||
|
});
|
||||||
76
android/extension/content.js
Normal file
76
android/extension/content.js
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
/* Extension world: the browser provides origin/tab/frame identity, never Gaia. */
|
||||||
|
(() => {
|
||||||
|
if (location.port !== '18765') 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.event) {
|
||||||
|
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() {
|
||||||
|
for (const callback of pending.values())
|
||||||
|
callback.reject(new window.Error('VULPES_HOST_CLOSED'));
|
||||||
|
pending.clear();
|
||||||
|
clearTimeout(reconnect);
|
||||||
|
reconnect = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
connect();
|
||||||
|
} catch (_) {
|
||||||
|
onDisconnect();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
connect();
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
})();
|
||||||
1
android/extension/manifest.json
Normal file
1
android/extension/manifest.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"manifest_version":2,"name":"Vulpes Android host","version":"1.1","browser_specific_settings":{"gecko":{"id":"android-host@vulpes-os.org"}},"permissions":["storage","unlimitedStorage","nativeMessaging","geckoViewAddons","http://*.localhost/*"],"background":{"page":"background.html","persistent":true},"content_scripts":[{"matches":["http://*.localhost/*"],"js":["content.js"],"all_frames":true,"run_at":"document_start"}]}
|
||||||
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('../ui/settings-codec.js', import.meta.url), 'utf8'),
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
const { encode, decode } = context.window.VulpesAndroidSettingsCodec;
|
||||||
|
// A binary wallpaper larger than the original 1 MiB JSON ceiling, including
|
||||||
|
// every byte value. Exercise the actual service, JSON storage and observer path.
|
||||||
|
const pixels = Uint8Array.from({ length: 1200000 }, (_, i) => i % 256);
|
||||||
|
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["unavailable_controls"] = ev(
|
||||||
|
"camera",
|
||||||
|
'[".js-flash",".mode-switch"].every(s=>document.querySelector(s).getAttribute("aria-disabled")==="true")',
|
||||||
|
)
|
||||||
|
assert result["unavailable_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()
|
||||||
191
android/tools/pack-assets.py
Normal file
191
android/tools/pack-assets.py
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
#!/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 build_info, validate_assets, release_info
|
||||||
|
|
||||||
|
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=json.loads(translate(manifest.read_text())),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
info = build_info()
|
||||||
|
release = release_info()
|
||||||
|
revision = info["revision"]
|
||||||
|
commit_time = str(info["timestamp"])
|
||||||
|
source_overrides = {
|
||||||
|
"system": [
|
||||||
|
"js/launcher.js",
|
||||||
|
"js/gaia_scheduler.js",
|
||||||
|
"js/keyboard_manager.js",
|
||||||
|
"js/core.js",
|
||||||
|
"js/rocketbar.js",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
textsuffix = {".html", ".js", ".css", ".json", ".webapp", ".properties", ".svg"}
|
||||||
|
head = b'<head><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-services.js"></script><script src="/_vulpes/gaia-styles.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-services.js", "gaia-styles.js", "gaia-restoration.js", "gaia-wifi.js", "desktop.css", "platform-ui.js", "gaia-camera.js"]:
|
||||||
|
text = (
|
||||||
|
(ROOT / "host" / name)
|
||||||
|
.read_text()
|
||||||
|
)
|
||||||
|
put("_vulpes/" + name, translate(text).encode())
|
||||||
|
put("_vulpes/settings-codec.js", (ROOT / "android/ui/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 / "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()
|
||||||
43
android/tools/probe-android.py
Normal file
43
android/tools/probe-android.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
#!/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):
|
||||||
|
r=RDP()
|
||||||
|
try:
|
||||||
|
for process in r.call('root','listProcesses')['processes']:
|
||||||
|
if process['isParent']: continue
|
||||||
|
target=r.call(process['actor'],'getTarget')['process']
|
||||||
|
if 'webIsolated' not in target.get('remoteType',''): continue
|
||||||
|
text='''(() => { const w=[...Services.ww.getWindowEnumerator()].find(w=>w.location.hostname===%s); if(!w)return null; return w.wrappedJSObject.eval(%s); })()''' % (json.dumps(host+'.localhost'),json.dumps(script))
|
||||||
|
response=r.call(target['consoleActor'],'evaluateJSAsync',text=text)
|
||||||
|
if 'resultID' not in response: continue
|
||||||
|
while True:
|
||||||
|
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:r.s.close()
|
||||||
|
|
||||||
|
def run(host,expression,timeout=30):
|
||||||
|
start=time.monotonic()+timeout
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
evaluate(host,'window.__androidProbe=null; Promise.resolve().then(async()=>('+expression+')).then(value=>window.__androidProbe=JSON.stringify({value}),error=>window.__androidProbe=JSON.stringify({error:String(error),stack:error.stack})); "started"')
|
||||||
|
break
|
||||||
|
except (RuntimeError, EOFError, OSError) as e:
|
||||||
|
if time.monotonic()>=start:raise
|
||||||
|
if isinstance(e, RuntimeError) and 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"')
|
||||||
|
if isinstance(result,str) and result != 'pending':return json.loads(result)
|
||||||
|
time.sleep(.3)
|
||||||
|
raise TimeoutError(host)
|
||||||
|
|
||||||
|
if __name__=='__main__':
|
||||||
|
print(json.dumps(run(sys.argv[1],sys.argv[2]),ensure_ascii=False,indent=2))
|
||||||
60
android/tools/rdp.py
Normal file
60
android/tools/rdp.py
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
#!/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 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'))
|
||||||
1
android/ui/android-ui.js
Normal file
1
android/ui/android-ui.js
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
// Home layout recovery now lives in the shared host/platform-ui.js.
|
||||||
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
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