mirror of
https://github.com/Benjamin-Wiegand/Flywheel.git
synced 2026-07-20 05:22:25 -07:00
initial commit
This commit is contained in:
+15
@@ -0,0 +1,15 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea/caches
|
||||
/.idea/libraries
|
||||
/.idea/modules.xml
|
||||
/.idea/workspace.xml
|
||||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
@@ -0,0 +1,71 @@
|
||||
# Geargrinder
|
||||
> [!caution]
|
||||
> This is experimental software in active development. DO NOT USE WHILE DRIVING.
|
||||
|
||||
An Android Auto alternative for de-googled phones.
|
||||
|
||||
## warnings
|
||||
|
||||
This is experimental software:
|
||||
|
||||
- Do not use it while driving.
|
||||
- I am not responsible for bricked headunits.
|
||||
- There are no published builds yet.
|
||||
|
||||
## status
|
||||
|
||||
Still in development.
|
||||
|
||||
### headunit compatibility
|
||||
|
||||
> [!warning]
|
||||
> I have no idea how your car will react to communications from this app. Be prepared for unforeseen consequences.
|
||||
|
||||
Only tested with emulators so far. Looking in to testing on OEM hardware soon (hopefully).
|
||||
|
||||
If you accept the risks and want to help by testing on your hardware, I would appreciate if you open an issue with the results (regardless of success).
|
||||
|
||||
Tested and working on these emulators:
|
||||
- [OpenAuto](https://github.com/f1xpl/openauto/)
|
||||
- [Headunit Reloaded](https://xdaforums.com/t/android-4-1-headunit-reloaded-for-android-auto-with-wifi.3432348/)
|
||||
|
||||
### phone compatibility
|
||||
|
||||
#### root
|
||||
|
||||
Launching the projection currently requires root for ease of development. This will change as the project matures.
|
||||
|
||||
You should be able to use audio capture without root.
|
||||
|
||||
#### video
|
||||
|
||||
Geargrinder uses the MediaCodec library for video and is subject to device-specific issues. Please report them if you encounter any.
|
||||
|
||||
Tested on:
|
||||
- Qualcomm
|
||||
- Mediatek
|
||||
- Exynos
|
||||
|
||||
#### audio
|
||||
|
||||
> [!NOTE]
|
||||
> DRM-protected audio is currently not captured.
|
||||
|
||||
Currently, your phone must natively support the audio sample rate for your headunit. The only easy way to check this is to just try it.
|
||||
|
||||
|
||||
### working
|
||||
- USB communication
|
||||
- Video
|
||||
- Audio
|
||||
- Touch Input
|
||||
- Basic UI
|
||||
|
||||
|
||||
## thanks
|
||||
|
||||
These other projects have proved very helpful during development:
|
||||
|
||||
- [aasdk](https://github.com/f1xpl/aasdk/)
|
||||
- [OpenAuto](https://github.com/f1xpl/OpenAuto/)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
/build
|
||||
/release
|
||||
/debug
|
||||
@@ -0,0 +1,43 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.benwiegand.projection.geargrinder"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "io.benwiegand.projection.geargrinder"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "0.0.1"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
targetCompatibility = JavaVersion.VERSION_21
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
implementation(libs.appcompat)
|
||||
implementation(libs.material)
|
||||
implementation(libs.bouncycastle)
|
||||
implementation(libs.localbroadcastmanager)
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.ext.junit)
|
||||
androidTestImplementation(libs.espresso.core)
|
||||
}
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package io.benwiegand.projection.geargrinder;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class ExampleInstrumentedTest {
|
||||
@Test
|
||||
public void useAppContext() {
|
||||
// Context of the app under test.
|
||||
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||
assertEquals("io.benwiegand.projection.geargrinder", appContext.getPackageName());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-feature android:name="android.hardware.usb.accessory" />
|
||||
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" tools:ignore="ForegroundServicesPolicy" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
|
||||
<!-- needed to launch arbitrary apps -->
|
||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" tools:ignore="PackageVisibilityPolicy,QueryAllPackagesPermission" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Geargrinder">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleInstance"
|
||||
>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".ConnectionRequestActivity"
|
||||
android:exported="false"
|
||||
android:launchMode="standard"
|
||||
android:theme="@style/Theme.Geargrinder.Invisible"
|
||||
>
|
||||
<intent-filter>
|
||||
<action android:name="android.hardware.usb.action.USB_ACCESSORY_ATTACHED" />
|
||||
<action android:name="android.hardware.usb.action.USB_ACCESSORY_DETACHED" />
|
||||
<action android:name="io.benwiegand.projection.geargrinder.USB_PERMISSION" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.hardware.usb.action.USB_ACCESSORY_ATTACHED"
|
||||
android:resource="@xml/usb_accessory_filter"
|
||||
/>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".ProjectionActivity"
|
||||
android:exported="false"
|
||||
android:launchMode="singleInstance"
|
||||
android:taskAffinity="io.benwiegand.projection.geargrinder.Projection"
|
||||
android:theme="@style/Theme.Geargrinder.Projection"
|
||||
/>
|
||||
|
||||
<service
|
||||
android:name=".ConnectionService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="connectedDevice|mediaProjection"
|
||||
/>
|
||||
|
||||
<service
|
||||
android:name=".AccessibilityInputService"
|
||||
android:exported="false"
|
||||
android:label="@string/app_name"
|
||||
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
|
||||
tools:ignore="AccessibilityPolicy">
|
||||
<intent-filter>
|
||||
<action android:name="android.accessibilityservice.AccessibilityService" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.accessibilityservice"
|
||||
android:resource="@xml/accessibility_service_meta" />
|
||||
</service>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
package io.benwiegand.projection.geargrinder;
|
||||
|
||||
import android.accessibilityservice.AccessibilityService;
|
||||
import android.accessibilityservice.GestureDescription;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Intent;
|
||||
import android.graphics.Path;
|
||||
import android.os.Binder;
|
||||
import android.os.Handler;
|
||||
import android.os.IBinder;
|
||||
import android.os.Looper;
|
||||
import android.util.Log;
|
||||
import android.view.accessibility.AccessibilityEvent;
|
||||
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
|
||||
import io.benwiegand.projection.geargrinder.makeshiftbind.MakeshiftBind;
|
||||
import io.benwiegand.projection.geargrinder.makeshiftbind.MakeshiftBindCallback;
|
||||
import io.benwiegand.projection.geargrinder.proto.data.readable.input.event.TouchEvent;
|
||||
|
||||
@SuppressLint("AccessibilityPolicy")
|
||||
public class AccessibilityInputService extends AccessibilityService implements MakeshiftBindCallback {
|
||||
private static final String TAG = AccessibilityInputService.class.getSimpleName();
|
||||
|
||||
private final Handler handler = new Handler(Looper.getMainLooper());
|
||||
|
||||
private final ServiceBinder binder = new ServiceBinder();
|
||||
private MakeshiftBind makeshiftBind = null;
|
||||
|
||||
@Override
|
||||
protected void onServiceConnected() {
|
||||
super.onServiceConnected();
|
||||
Log.i(TAG, "service connected");
|
||||
makeshiftBind = new MakeshiftBind(this, new ComponentName(this, AccessibilityInputService.class), this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onUnbind(Intent intent) {
|
||||
Log.i(TAG, "service death");
|
||||
makeshiftBind.destroy();
|
||||
return super.onUnbind(intent);
|
||||
}
|
||||
|
||||
@Override
|
||||
public IBinder onMakeshiftBind(Intent intent) {
|
||||
return binder;
|
||||
}
|
||||
|
||||
private final GestureResultCallback gestureResultCallback = new GestureResultCallback() {
|
||||
@Override
|
||||
public void onCompleted(GestureDescription gestureDescription) {
|
||||
Log.i(TAG, "Gesture completed");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCancelled(GestureDescription gestureDescription) {
|
||||
Log.w(TAG, "Gesture cancelled");
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public void onAccessibilityEvent(AccessibilityEvent event) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onInterrupt() {
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
private record TouchPointer(
|
||||
GestureDescription.StrokeDescription stroke,
|
||||
int x, int y
|
||||
) { }
|
||||
|
||||
private TouchPointer primaryPointer = null;
|
||||
|
||||
private boolean emulateTouchEventInternal(TouchEvent event) {
|
||||
// doesn't work on virtual displays, sadly. should be useful for screen mirroring mode.
|
||||
GestureDescription.Builder builder = new GestureDescription.Builder();
|
||||
|
||||
// TODO: multitouch should be possible
|
||||
|
||||
TouchEvent.PointerLocation pointerLocation = event.pointerLocations()[0];
|
||||
int x = pointerLocation.x(), y = pointerLocation.y();
|
||||
|
||||
GestureDescription.StrokeDescription strokeDescription = null;
|
||||
|
||||
switch (event.action()) {
|
||||
case DOWN -> {
|
||||
Path path = new Path();
|
||||
path.moveTo(x, y);
|
||||
strokeDescription = new GestureDescription.StrokeDescription(path, 0, 1, true);
|
||||
primaryPointer = new TouchPointer(strokeDescription, x, y);
|
||||
}
|
||||
case UP -> {
|
||||
if (primaryPointer == null) break;
|
||||
Path path = new Path();
|
||||
path.moveTo(x, y);
|
||||
strokeDescription = primaryPointer.stroke().continueStroke(path, 0, 1, false);
|
||||
primaryPointer = null;
|
||||
}
|
||||
case MOVE -> {
|
||||
if (primaryPointer == null) break;
|
||||
Path path = new Path();
|
||||
path.moveTo(primaryPointer.x(), primaryPointer.y());
|
||||
path.lineTo(x, y);
|
||||
strokeDescription = primaryPointer.stroke().continueStroke(path, 0, 1, true);
|
||||
primaryPointer = new TouchPointer(strokeDescription, x, y);
|
||||
}
|
||||
case CANCEL -> {
|
||||
//TODO
|
||||
}
|
||||
case OUTSIDE -> {
|
||||
//TODO
|
||||
}
|
||||
case POINTER_DOWN -> {
|
||||
//TODO: multitouch
|
||||
}
|
||||
case POINTER_UP -> {
|
||||
//TODO: multitouch
|
||||
}
|
||||
}
|
||||
|
||||
if (strokeDescription == null) return false;
|
||||
builder.addStroke(strokeDescription);
|
||||
return dispatchGesture(builder.build(), gestureResultCallback, null);
|
||||
}
|
||||
|
||||
public class ServiceBinder extends Binder {
|
||||
|
||||
public boolean emulateTouchEvent(TouchEvent event) {
|
||||
if (Looper.getMainLooper().isCurrentThread()) {
|
||||
emulateTouchEventInternal(event);
|
||||
}
|
||||
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
boolean[] result = new boolean[] {false};
|
||||
boolean posted = handler.post(() -> {
|
||||
try {
|
||||
result[0] = emulateTouchEventInternal(event);
|
||||
} finally {
|
||||
latch.countDown();
|
||||
}
|
||||
});
|
||||
|
||||
if (!posted) return false;
|
||||
try {
|
||||
latch.await(/* TODO */);
|
||||
} catch (InterruptedException e) {
|
||||
Log.e(TAG, "emulateTouchEvent() interrupted");
|
||||
}
|
||||
return result[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
package io.benwiegand.projection.geargrinder;
|
||||
|
||||
import static android.app.Service.STOP_FOREGROUND_DETACH;
|
||||
import static android.app.Service.STOP_FOREGROUND_REMOVE;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.app.Service;
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.RequiresApi;
|
||||
import androidx.annotation.StringRes;
|
||||
|
||||
public class ConnectionNotificationService {
|
||||
private static final String TAG = ConnectionNotificationService.class.getSimpleName();
|
||||
|
||||
private static final String FOREGROUND_NOTIFICATION_CHANNEL = "connection";
|
||||
private static final String ERROR_NOTIFICATION_CHANNEL = "connection_error";
|
||||
|
||||
private static final int FOREGROUND_NOTIFICATION_ID = 1;
|
||||
private static final int ERROR_NOTIFICATION_ID = 2;
|
||||
|
||||
private final Service context;
|
||||
private final NotificationManager nm;
|
||||
|
||||
private @StringRes int connectionStatusText = R.string.looking_for_car;
|
||||
private String carName = null;
|
||||
private int foregroundFlags = 0;
|
||||
|
||||
public ConnectionNotificationService(Service context) {
|
||||
this.context = context;
|
||||
nm = context.getSystemService(NotificationManager.class);
|
||||
}
|
||||
|
||||
public void destroy() {
|
||||
context.stopForeground(STOP_FOREGROUND_REMOVE);
|
||||
}
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.Q)
|
||||
public void addForegroundFlag(int flag) {
|
||||
if ((foregroundFlags & flag) != 0) return;
|
||||
foregroundFlags |= flag;
|
||||
updateForegroundNotification();
|
||||
}
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.Q)
|
||||
public void removeForegroundFlag(int flag) {
|
||||
if ((foregroundFlags & flag) == 0) return;
|
||||
foregroundFlags -= flag;
|
||||
updateForegroundNotification();
|
||||
if (foregroundFlags == 0)
|
||||
context.stopForeground(STOP_FOREGROUND_DETACH);
|
||||
}
|
||||
|
||||
public void setCarName(String carName) {
|
||||
this.carName = carName;
|
||||
updateForegroundNotification();
|
||||
}
|
||||
|
||||
public void setConnectionStatusText(int connectionStatusText) {
|
||||
this.connectionStatusText = connectionStatusText;
|
||||
updateForegroundNotification();
|
||||
}
|
||||
|
||||
private void initForegroundNotificationChannel() {
|
||||
if (nm.getNotificationChannel(FOREGROUND_NOTIFICATION_CHANNEL) != null) return;
|
||||
nm.createNotificationChannel(new NotificationChannel(
|
||||
FOREGROUND_NOTIFICATION_CHANNEL,
|
||||
context.getString(R.string.connection_foreground_notification_channel_name),
|
||||
android.app.NotificationManager.IMPORTANCE_LOW
|
||||
));
|
||||
}
|
||||
|
||||
private void initErrorNotificationChannel() {
|
||||
if (nm.getNotificationChannel(ERROR_NOTIFICATION_CHANNEL) != null) return;
|
||||
nm.createNotificationChannel(new NotificationChannel(
|
||||
ERROR_NOTIFICATION_CHANNEL,
|
||||
context.getString(R.string.connection_error_notification_channel_name),
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
public void clearError() {
|
||||
nm.cancel(ERROR_NOTIFICATION_ID);
|
||||
}
|
||||
|
||||
public void postError(@StringRes int title, @StringRes int content) {
|
||||
initErrorNotificationChannel();
|
||||
|
||||
// TODO: error activity
|
||||
Intent intent = new Intent(context, MainActivity.class);
|
||||
intent.setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT);
|
||||
PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
|
||||
|
||||
Notification notification = new Notification.Builder(context, ERROR_NOTIFICATION_CHANNEL)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setContentTitle(context.getString(title))
|
||||
.setContentText(context.getString(content))
|
||||
.setVisibility(Notification.VISIBILITY_PUBLIC)
|
||||
.build();
|
||||
|
||||
nm.notify(ERROR_NOTIFICATION_ID, notification);
|
||||
}
|
||||
|
||||
private void updateForegroundNotification() {
|
||||
initForegroundNotificationChannel();
|
||||
|
||||
Intent intent = new Intent(context, MainActivity.class);
|
||||
intent.setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT);
|
||||
PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
|
||||
Notification notification = new Notification.Builder(context, FOREGROUND_NOTIFICATION_CHANNEL)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_map)
|
||||
.setOngoing(true)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setContentTitle(context.getString(connectionStatusText))
|
||||
.setSubText(carName)
|
||||
.setVisibility(Notification.VISIBILITY_PUBLIC) // can't see notification with media projection active without this
|
||||
.build();
|
||||
|
||||
if (foregroundFlags == 0) {
|
||||
Log.w(TAG, "no foreground justification set");
|
||||
nm.notify(FOREGROUND_NOTIFICATION_ID, notification);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
context.startForeground(FOREGROUND_NOTIFICATION_ID, notification, foregroundFlags);
|
||||
} else {
|
||||
context.startForeground(FOREGROUND_NOTIFICATION_ID, notification);
|
||||
}
|
||||
} catch (SecurityException e) {
|
||||
// android system doesn't think the justification is valid at this time. not much that can be done.
|
||||
Log.wtf(TAG, "unable to start foreground context", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
package io.benwiegand.projection.geargrinder;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Intent;
|
||||
import android.hardware.usb.UsbAccessory;
|
||||
import android.hardware.usb.UsbManager;
|
||||
import android.media.projection.MediaProjectionConfig;
|
||||
import android.media.projection.MediaProjectionManager;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
|
||||
public class ConnectionRequestActivity extends AppCompatActivity {
|
||||
private static final String TAG = ConnectionRequestActivity.class.getSimpleName();
|
||||
|
||||
public static final String INTENT_ACTION_REQUEST_MEDIA_PROJECTION = "io.benwiegand.projection.geargrinder.REQUEST_MEDIA_PROJECTION";
|
||||
|
||||
private static final String INTENT_ACTION_USB_PERMISSION_RESULT = "io.benwiegand.projection.geargrinder.USB_PERMISSION";
|
||||
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(new View(this));
|
||||
onIntent(getIntent());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
onIntent(intent);
|
||||
}
|
||||
|
||||
private void onIntent(Intent intent) {
|
||||
if (intent == null) {
|
||||
Log.e(TAG, "intent is null");
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
String action = intent.getAction();
|
||||
Log.v(TAG, "Intent get: " + action);
|
||||
if (action == null) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
UsbAccessory accessory = intent.getParcelableExtra(UsbManager.EXTRA_ACCESSORY);
|
||||
|
||||
switch (action) {
|
||||
case INTENT_ACTION_USB_PERMISSION_RESULT -> {
|
||||
if (!intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
|
||||
Log.e(TAG, "user denied permission for accessory");
|
||||
break;
|
||||
}
|
||||
|
||||
Log.i(TAG, "permission granted for accessory: " + accessory);
|
||||
connectToUsbHeadunit();
|
||||
}
|
||||
case UsbManager.ACTION_USB_ACCESSORY_ATTACHED -> {
|
||||
Log.v(TAG, "accessory attached");
|
||||
|
||||
if (accessory == null) {
|
||||
Log.e(TAG, "accessory is null");
|
||||
break;
|
||||
}
|
||||
|
||||
UsbManager usbManager = getSystemService(UsbManager.class);
|
||||
if (!usbManager.hasPermission(accessory)) {
|
||||
requestUsbPermission(usbManager, accessory);
|
||||
return; // not done yet
|
||||
}
|
||||
|
||||
connectToUsbHeadunit();
|
||||
}
|
||||
case INTENT_ACTION_REQUEST_MEDIA_PROJECTION -> {
|
||||
Log.v(TAG, "request for media projection");
|
||||
requestMediaProjection();
|
||||
return; // not done yet
|
||||
}
|
||||
}
|
||||
|
||||
finish();
|
||||
}
|
||||
|
||||
private final ActivityResultLauncher<Intent> startMediaProjection = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(),
|
||||
result -> {
|
||||
if (result.getResultCode() != Activity.RESULT_OK) {
|
||||
Log.e(TAG, "media projection permission result not ok:" + result.getResultCode());
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
startForegroundService(new Intent(this, ConnectionService.class)
|
||||
.setAction(ConnectionService.INTENT_ACTION_START_MEDIA_PROJECTION)
|
||||
.putExtra(ConnectionService.INTENT_EXTRA_MEDIA_PROJECTION_PERMISSION_RESULT, result));
|
||||
|
||||
finish();
|
||||
}
|
||||
);
|
||||
|
||||
private void connectToUsbHeadunit() {
|
||||
Log.i(TAG, "starting connection service");
|
||||
startService(new Intent(this, ConnectionService.class)
|
||||
.setAction(ConnectionService.INTENT_ACTION_CONNECT_USB));
|
||||
}
|
||||
|
||||
private void requestUsbPermission(UsbManager usbManager, UsbAccessory accessory) {
|
||||
Log.v(TAG, "requesting permission for accessory: " + accessory);
|
||||
PendingIntent permissionIntent = PendingIntent.getBroadcast(this, 0, new Intent(INTENT_ACTION_USB_PERMISSION_RESULT), PendingIntent.FLAG_IMMUTABLE);
|
||||
usbManager.requestPermission(accessory, permissionIntent);
|
||||
}
|
||||
|
||||
private void requestMediaProjection() {
|
||||
MediaProjectionManager mediaProjectionManager = getSystemService(MediaProjectionManager.class);
|
||||
Intent intent;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
intent = mediaProjectionManager.createScreenCaptureIntent(MediaProjectionConfig.createConfigForDefaultDisplay());
|
||||
} else {
|
||||
intent = mediaProjectionManager.createScreenCaptureIntent();
|
||||
}
|
||||
startMediaProjection.launch(intent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package io.benwiegand.projection.geargrinder;
|
||||
|
||||
import static io.benwiegand.projection.geargrinder.util.UsbUtil.findUsbHeadunit;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.Service;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.ServiceInfo;
|
||||
import android.hardware.usb.UsbAccessory;
|
||||
import android.hardware.usb.UsbManager;
|
||||
import android.media.projection.MediaProjection;
|
||||
import android.media.projection.MediaProjectionManager;
|
||||
import android.os.Binder;
|
||||
import android.os.Build;
|
||||
import android.os.Handler;
|
||||
import android.os.IBinder;
|
||||
import android.os.Looper;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.activity.result.ActivityResult;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
import javax.net.ssl.KeyManager;
|
||||
import javax.net.ssl.TrustManager;
|
||||
|
||||
import io.benwiegand.projection.geargrinder.crypto.KeystoreManager;
|
||||
import io.benwiegand.projection.geargrinder.crypto.TLSService;
|
||||
import io.benwiegand.projection.geargrinder.message.AAFrame;
|
||||
import io.benwiegand.projection.geargrinder.message.MessageBroker;
|
||||
import io.benwiegand.projection.geargrinder.protocol.AAConstants;
|
||||
import io.benwiegand.projection.geargrinder.callback.ControlListener;
|
||||
import io.benwiegand.projection.geargrinder.channel.ControlChannel;
|
||||
import io.benwiegand.projection.geargrinder.transfer.UsbTransferInterface;
|
||||
|
||||
public class ConnectionService extends Service implements ControlListener {
|
||||
private static final String TAG = ConnectionService.class.getSimpleName();
|
||||
|
||||
public static final String INTENT_ACTION_CONNECT_USB = "io.benwiegand.projection.geargrinder.USB_HEADUNIT_CONNECTED";
|
||||
public static final String INTENT_ACTION_START_MEDIA_PROJECTION = "io.benwiegand.projection.geargrinder.START_MEDIA_PROJECTION";
|
||||
|
||||
public static final String INTENT_EXTRA_MEDIA_PROJECTION_PERMISSION_RESULT = "projection_result";
|
||||
|
||||
private final Handler handler = new Handler(Looper.getMainLooper());
|
||||
private final ServiceBinder binder = new ServiceBinder();
|
||||
|
||||
private final Object lock = new Object();
|
||||
private Thread connectionThread = null;
|
||||
|
||||
private KeyManager[] keyManagers;
|
||||
private TrustManager[] trustManagers;
|
||||
private ConnectionNotificationService notificationService;
|
||||
|
||||
private MediaProjection mediaProjection = null;
|
||||
private MediaProjectionRequestCallback mediaProjectionRequestCallback = null;
|
||||
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
Log.d(TAG, "on create");
|
||||
|
||||
notificationService = new ConnectionNotificationService(this);
|
||||
|
||||
try {
|
||||
KeystoreManager keystoreManager = new KeystoreManager(this);
|
||||
keystoreManager.loadKeystore();
|
||||
keystoreManager.initKeypair("deez"); // TODO
|
||||
keystoreManager.saveKeystore();
|
||||
|
||||
keyManagers = keystoreManager.getKeyManagers();
|
||||
trustManagers = keystoreManager.getTrustManagers();
|
||||
|
||||
} catch (Throwable t) {
|
||||
// TODO: properly handle this
|
||||
throw new RuntimeException(t);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
Log.d(TAG, "on destroy");
|
||||
notificationService.destroy();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return binder;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
if (intent == null) {
|
||||
Log.e(TAG, "intent is null");
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
Log.d(TAG, "start intent: " + intent);
|
||||
switch (intent.getAction()) {
|
||||
case INTENT_ACTION_CONNECT_USB -> connectUsb();
|
||||
case INTENT_ACTION_START_MEDIA_PROJECTION -> startMediaProjection(intent);
|
||||
case null -> Log.e(TAG, "no intent action");
|
||||
default -> Log.wtf(TAG, "intent action not handled: " + intent.getAction());
|
||||
}
|
||||
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
private final MediaProjection.Callback mediaProjectionCallback = new MediaProjection.Callback() {
|
||||
@Override
|
||||
public void onStop() {
|
||||
synchronized (lock) {
|
||||
Log.i(TAG, "media projection stopped");
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
||||
notificationService.removeForegroundFlag(ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION);
|
||||
mediaProjection = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private void startMediaProjection(Intent intent) {
|
||||
ActivityResult startResult;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
startResult = intent.getParcelableExtra(INTENT_EXTRA_MEDIA_PROJECTION_PERMISSION_RESULT, ActivityResult.class);
|
||||
} else {
|
||||
if (intent.getExtras() == null) {
|
||||
Log.wtf(TAG, "intent has no extras");
|
||||
return;
|
||||
}
|
||||
startResult = (ActivityResult) intent.getExtras().get(INTENT_EXTRA_MEDIA_PROJECTION_PERMISSION_RESULT);
|
||||
}
|
||||
|
||||
if (startResult == null || startResult.getResultCode() != Activity.RESULT_OK || startResult.getData() == null) {
|
||||
Log.wtf(TAG, "media projection intent without successful media projection permission result: " + startResult);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mediaProjection != null)
|
||||
mediaProjection.stop();
|
||||
|
||||
synchronized (lock) {
|
||||
Log.i(TAG, "starting media projection");
|
||||
|
||||
// need this context to start the media projection
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
||||
notificationService.addForegroundFlag(ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION);
|
||||
|
||||
MediaProjectionManager mediaProjectionManager = getSystemService(MediaProjectionManager.class);
|
||||
mediaProjection = mediaProjectionManager.getMediaProjection(startResult.getResultCode(), startResult.getData());
|
||||
if (mediaProjection == null) {
|
||||
// this probably won't happen
|
||||
Log.wtf(TAG, "failed to start media projection");
|
||||
return;
|
||||
}
|
||||
|
||||
mediaProjection.registerCallback(mediaProjectionCallback, handler);
|
||||
|
||||
if (mediaProjectionRequestCallback != null) {
|
||||
mediaProjectionRequestCallback.onAccepted(mediaProjection);
|
||||
mediaProjectionRequestCallback = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void connectUsb() {
|
||||
Thread thread = new Thread(this::usbConnectionLoop, "Geargrinder USB connection loop");
|
||||
|
||||
synchronized (lock) {
|
||||
if (connectionThread != null) {
|
||||
Log.e(TAG, "connection thread already active");
|
||||
return;
|
||||
}
|
||||
|
||||
Log.i(TAG, "trying to connect over USB");
|
||||
connectionThread = thread;
|
||||
}
|
||||
|
||||
connectionThread.start();
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCarNameDiscovered(String carName) {
|
||||
notificationService.setCarName(carName);
|
||||
}
|
||||
|
||||
private void usbConnectionLoop() {
|
||||
assert !Looper.getMainLooper().isCurrentThread(); // never run on main thread
|
||||
|
||||
try {
|
||||
notificationService.setConnectionStatusText(R.string.looking_for_car);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
||||
notificationService.addForegroundFlag(ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE);
|
||||
|
||||
UsbManager usbManager = getSystemService(UsbManager.class);
|
||||
|
||||
UsbAccessory headunit = findUsbHeadunit(usbManager);
|
||||
if (headunit == null) {
|
||||
Log.e(TAG, "no headunit found");
|
||||
notificationService.postError(R.string.car_connection_error, R.string.error_no_usb_headunit);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!usbManager.hasPermission(headunit)) {
|
||||
Log.e(TAG, "no permission for usb accessory");
|
||||
notificationService.postError(R.string.car_connection_error, R.string.error_grant_usb_permission);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Log.i(TAG, "headunit found");
|
||||
notificationService.setConnectionStatusText(R.string.connecting_to_car);
|
||||
|
||||
// TODO: open accessory more efficiently (see openAccessory()
|
||||
try (ParcelFileDescriptor pfd = usbManager.openAccessory(headunit);
|
||||
FileInputStream is = new FileInputStream(pfd.getFileDescriptor());
|
||||
FileOutputStream os = new FileOutputStream(pfd.getFileDescriptor())) {
|
||||
|
||||
Log.d(TAG, "opened usb file descriptor [" + pfd.getFd() + "]: " + pfd);
|
||||
notificationService.setConnectionStatusText(R.string.connected_to_car);
|
||||
notificationService.clearError();
|
||||
|
||||
Log.d(TAG, "starting services");
|
||||
TLSService tlsService = new TLSService(trustManagers, keyManagers);
|
||||
UsbTransferInterface usbTransferInterface = new UsbTransferInterface(pfd, is, os, AAFrame.MAX_LENGTH);
|
||||
MessageBroker messageBroker = new MessageBroker(usbTransferInterface, tlsService);
|
||||
ControlChannel controlChannel = new ControlChannel(this, messageBroker, tlsService, this, binder);
|
||||
try {
|
||||
messageBroker.registerForChannel(AAConstants.CHANNEL_CONTROL, controlChannel);
|
||||
messageBroker.loop();
|
||||
} finally {
|
||||
controlChannel.destroy();
|
||||
messageBroker.destroy();
|
||||
}
|
||||
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "IOException in car connection", e);
|
||||
notificationService.postError(R.string.car_connection_unexpected_error, R.string.error_car_io_usb_generic);
|
||||
}
|
||||
|
||||
} finally {
|
||||
// die
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
||||
notificationService.removeForegroundFlag(ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE);
|
||||
notificationService.setConnectionStatusText(R.string.looking_for_car);
|
||||
connectionThread = null;
|
||||
}
|
||||
}
|
||||
|
||||
public interface MediaProjectionRequestCallback {
|
||||
|
||||
void onAccepted(MediaProjection mediaProjection);
|
||||
|
||||
}
|
||||
|
||||
public class ServiceBinder extends Binder {
|
||||
|
||||
public void requestMediaProjection(MediaProjectionRequestCallback callback) {
|
||||
synchronized (lock) {
|
||||
if (mediaProjection != null) {
|
||||
callback.onAccepted(mediaProjection);
|
||||
return;
|
||||
}
|
||||
|
||||
Log.i(TAG, "requesting to start media projection");
|
||||
mediaProjectionRequestCallback = callback;
|
||||
startActivity(new Intent(ConnectionService.this, ConnectionRequestActivity.class)
|
||||
.setAction(ConnectionRequestActivity.INTENT_ACTION_REQUEST_MEDIA_PROJECTION)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package io.benwiegand.projection.geargrinder;
|
||||
|
||||
import android.Manifest;
|
||||
import android.app.AlertDialog;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import android.widget.EditText;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.activity.EdgeToEdge;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.core.graphics.Insets;
|
||||
import androidx.core.view.ViewCompat;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
|
||||
import io.benwiegand.projection.geargrinder.logs.LogUiAdapter;
|
||||
import io.benwiegand.projection.geargrinder.logs.LogcatReader;
|
||||
|
||||
public class MainActivity extends AppCompatActivity {
|
||||
private static final String TAG = MainActivity.class.getSimpleName();
|
||||
|
||||
private LogcatReader logcatReader;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
EdgeToEdge.enable(this);
|
||||
setContentView(R.layout.activity_main);
|
||||
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.root), (v, insets) -> {
|
||||
Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
|
||||
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
|
||||
return insets;
|
||||
});
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
Log.i(TAG, "requesting notification permission");
|
||||
if (shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS)) {
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle(R.string.post_notifications_permission_request)
|
||||
.setMessage(R.string.post_notifications_permission_rationale)
|
||||
.setPositiveButton(R.string.grant_permission_button, (d, i) ->
|
||||
requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS}, 69))
|
||||
.setNegativeButton(R.string.not_now_button, null)
|
||||
.setCancelable(false)
|
||||
.show();
|
||||
} else {
|
||||
requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS}, 69);
|
||||
}
|
||||
}
|
||||
|
||||
findViewById(R.id.force_start_service_button).setOnClickListener(v ->
|
||||
startService(new Intent(this, ConnectionService.class)
|
||||
.setAction(ConnectionService.INTENT_ACTION_CONNECT_USB)));
|
||||
|
||||
findViewById(R.id.start_audio_capture_button).setOnClickListener(v ->
|
||||
startActivity(new Intent(this, ConnectionRequestActivity.class)
|
||||
.setAction(ConnectionRequestActivity.INTENT_ACTION_REQUEST_MEDIA_PROJECTION)));
|
||||
|
||||
findViewById(R.id.log_marker_button).setOnClickListener(v -> logcatReader.addMarker());
|
||||
|
||||
findViewById(R.id.toggle_recording_button).setOnClickListener(v -> {
|
||||
if (logcatReader.isRecording()) {
|
||||
Throwable error = logcatReader.stopRecording();
|
||||
if (error == null) {
|
||||
Toast.makeText(this, "recording stopped", Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
Log.e(TAG, "error during recording", error);
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("Log recording error")
|
||||
.setMessage("an error happened during the recording:\n\n" + error.getClass().getSimpleName() + ": " + error.getMessage())
|
||||
.setPositiveButton("close", null)
|
||||
.show();
|
||||
return;
|
||||
}
|
||||
|
||||
EditText et = new EditText(this);
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("recording name")
|
||||
.setView(et)
|
||||
.setPositiveButton("start", (d, i) -> {
|
||||
String name = et.getText() + ".log";
|
||||
Log.d(TAG, "starting log recording to: " + name);
|
||||
File logFile = getFilesDir().toPath().resolve(name).toFile();
|
||||
try {
|
||||
logcatReader.startRecording(logFile);
|
||||
Toast.makeText(this, "recording started", Toast.LENGTH_SHORT).show();
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "failed to start recording", e);
|
||||
}
|
||||
})
|
||||
.setNegativeButton("cancel", null)
|
||||
.setCancelable(false)
|
||||
.show();
|
||||
|
||||
});
|
||||
|
||||
RecyclerView logRecyclerView = findViewById(R.id.log_recycler);
|
||||
LogUiAdapter logUiAdapter = new LogUiAdapter();
|
||||
|
||||
logRecyclerView.setLayoutManager(new LinearLayoutManager(this));
|
||||
logRecyclerView.setAdapter(logUiAdapter);
|
||||
logRecyclerView.setItemAnimator(null); // does not work with fast-paced logs
|
||||
|
||||
logUiAdapter.registerAdapterDataObserver(new RecyclerView.AdapterDataObserver() {
|
||||
@Override
|
||||
public void onItemRangeInserted(int positionStart, int itemCount) {
|
||||
logRecyclerView.scrollToPosition(positionStart + itemCount - 1);
|
||||
}
|
||||
});
|
||||
|
||||
logcatReader = new LogcatReader(logUiAdapter);
|
||||
logcatReader.start();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
super.onDestroy();
|
||||
logcatReader.destroy();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package io.benwiegand.projection.geargrinder;
|
||||
|
||||
import static io.benwiegand.projection.geargrinder.util.UiUtil.getDisplayId;
|
||||
|
||||
import android.content.ComponentName;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.Binder;
|
||||
import android.os.Bundle;
|
||||
import android.os.IBinder;
|
||||
import android.util.Log;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import io.benwiegand.projection.geargrinder.callback.InputEventListener;
|
||||
import io.benwiegand.projection.geargrinder.makeshiftbind.MakeshiftBind;
|
||||
import io.benwiegand.projection.geargrinder.makeshiftbind.MakeshiftBindCallback;
|
||||
import io.benwiegand.projection.geargrinder.projection.InputEventMuxer;
|
||||
import io.benwiegand.projection.geargrinder.projection.VirtualActivity;
|
||||
import io.benwiegand.projection.geargrinder.shell.RootShell;
|
||||
import io.benwiegand.projection.geargrinder.util.RootUtil;
|
||||
import io.benwiegand.projection.geargrinder.util.UiUtil;
|
||||
|
||||
public class ProjectionActivity extends AppCompatActivity implements MakeshiftBindCallback {
|
||||
private static final String TAG = ProjectionActivity.class.getSimpleName();
|
||||
|
||||
private final ActivityBinder binder = new ActivityBinder();
|
||||
private MakeshiftBind makeshiftBind;
|
||||
|
||||
private final List<VirtualActivity> virtualActivities = new ArrayList<>();
|
||||
private InputEventMuxer inputEventMuxer;
|
||||
private RootShell rootShell = null;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
Log.d(TAG, "onCreate");
|
||||
setContentView(R.layout.activity_projection);
|
||||
|
||||
findViewById(R.id.map_button).setOnClickListener(v ->
|
||||
launchActivity("net.osmand.plus/.activities.MapActivity"));
|
||||
|
||||
findViewById(R.id.music_button).setOnClickListener(v ->
|
||||
launchActivity("org.videolan.vlc/.gui.MainActivity"));
|
||||
|
||||
findViewById(R.id.app_picker_button).setOnClickListener(v ->
|
||||
UiUtil.createActivityPickerDialog(this, R.string.launch_app, this::launchActivity).show());
|
||||
|
||||
findViewById(R.id.test_button).setOnClickListener(v -> {
|
||||
|
||||
// temporarily show splash
|
||||
// for (VirtualActivity virtualActivity : virtualActivities)
|
||||
// virtualActivity.showSplash();
|
||||
|
||||
// test swap
|
||||
if (virtualActivities.size() >= 2) {
|
||||
LinearLayout ll = findViewById(R.id.split_screen_layout);
|
||||
View view = ll.getChildAt(0);
|
||||
ll.removeView(view);
|
||||
ll.addView(view);
|
||||
}
|
||||
|
||||
|
||||
});
|
||||
|
||||
try {
|
||||
rootShell = new RootShell();
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "failed to launch root shell", e);
|
||||
}
|
||||
|
||||
inputEventMuxer = new InputEventMuxer((event, translator) -> {
|
||||
try {
|
||||
if (rootShell == null) return;
|
||||
RootUtil.simulateTouchEventRoot(rootShell, getDisplayId(this), event, translator);
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "failed to simulate touch", e);
|
||||
}
|
||||
});
|
||||
|
||||
makeshiftBind = new MakeshiftBind(this, new ComponentName(this, ProjectionActivity.class), this);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
super.onDestroy();
|
||||
Log.d(TAG, "onDestroy");
|
||||
|
||||
for (VirtualActivity virtualActivity : virtualActivities)
|
||||
virtualActivity.destroy();
|
||||
|
||||
makeshiftBind.destroy();
|
||||
if (rootShell != null) rootShell.destroy();
|
||||
}
|
||||
|
||||
|
||||
private void launchActivity(ComponentName componentName) {
|
||||
ViewGroup splitScreenLayout = findViewById(R.id.split_screen_layout);
|
||||
try {
|
||||
if (rootShell == null) return;
|
||||
Log.i(TAG, "launching virtual activity: " + componentName);
|
||||
VirtualActivity virtualActivity = new VirtualActivity(rootShell, inputEventMuxer, componentName, splitScreenLayout);
|
||||
View view = virtualActivity.getRootView();
|
||||
|
||||
virtualActivities.add(virtualActivity);
|
||||
|
||||
view.findViewById(R.id.virtual_activity_close_button).setOnClickListener(v -> {
|
||||
virtualActivities.remove(virtualActivity);
|
||||
splitScreenLayout.removeView(view);
|
||||
virtualActivity.destroy();
|
||||
});
|
||||
|
||||
splitScreenLayout.addView(view, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT, 1));
|
||||
} catch (IOException | PackageManager.NameNotFoundException e) {
|
||||
Log.e(TAG, "failed to launch virtual activity", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void launchActivity(String component) {
|
||||
// TODO: remove this
|
||||
launchActivity(ComponentName.unflattenFromString(component));
|
||||
}
|
||||
|
||||
@Override
|
||||
public IBinder onMakeshiftBind(Intent intent) {
|
||||
return binder;
|
||||
}
|
||||
|
||||
public class ActivityBinder extends Binder {
|
||||
|
||||
public void setMargins(int horizontal, int vertical) {
|
||||
runOnUiThread(() -> {
|
||||
View root = findViewById(R.id.root);
|
||||
root.setPadding(
|
||||
horizontal / 2,
|
||||
vertical / 2,
|
||||
horizontal - horizontal / 2,
|
||||
vertical - vertical / 2
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public InputEventListener getInputEventListener() {
|
||||
return inputEventMuxer;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package io.benwiegand.projection.geargrinder.audio;
|
||||
|
||||
public interface AudioCapture {
|
||||
|
||||
enum Error {
|
||||
NO_ERROR,
|
||||
TRY_AGAIN,
|
||||
FAILURE,
|
||||
END_OF_STREAM
|
||||
}
|
||||
|
||||
class Result {
|
||||
public Error error;
|
||||
public int length;
|
||||
public long timestamp;
|
||||
public boolean silent;
|
||||
|
||||
}
|
||||
|
||||
void begin();
|
||||
void destroy();
|
||||
|
||||
void nextBuffer(Result result, byte[] buffer, int offset, int length);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package io.benwiegand.projection.geargrinder.audio;
|
||||
|
||||
import static android.media.AudioTimestamp.TIMEBASE_BOOTTIME;
|
||||
|
||||
import android.Manifest;
|
||||
import android.media.AudioPlaybackCaptureConfiguration;
|
||||
import android.media.AudioRecord;
|
||||
import android.media.AudioTimestamp;
|
||||
import android.os.Build;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.RequiresApi;
|
||||
import androidx.annotation.RequiresPermission;
|
||||
|
||||
import io.benwiegand.projection.geargrinder.proto.data.readable.av.preset.AudioPreset;
|
||||
|
||||
public class AudioRecordCapture implements AudioCapture {
|
||||
private static final String TAG = AudioRecordCapture.class.getSimpleName();
|
||||
|
||||
private final AudioRecord audioRecord;
|
||||
private final AudioTimestamp timestamp;
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.Q)
|
||||
@RequiresPermission(Manifest.permission.RECORD_AUDIO)
|
||||
public AudioRecordCapture(AudioPlaybackCaptureConfiguration config, AudioPreset preset, int bufferSize) {
|
||||
|
||||
audioRecord = new AudioRecord.Builder()
|
||||
.setAudioFormat(preset.createAudioFormat())
|
||||
.setBufferSizeInBytes(bufferSize)
|
||||
.setAudioPlaybackCaptureConfig(config)
|
||||
.build();
|
||||
|
||||
timestamp = new AudioTimestamp();
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void begin() {
|
||||
audioRecord.startRecording();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void destroy() {
|
||||
audioRecord.release();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void nextBuffer(Result result, byte[] buffer, int offset, int length) {
|
||||
int ret;
|
||||
|
||||
if (audioRecord.getState() == AudioRecord.STATE_UNINITIALIZED) {
|
||||
Log.w(TAG, "not initialized yet");
|
||||
result.error = Error.TRY_AGAIN;
|
||||
return;
|
||||
}
|
||||
|
||||
ret = audioRecord.getTimestamp(timestamp, TIMEBASE_BOOTTIME);
|
||||
switch (ret) {
|
||||
case AudioRecord.SUCCESS -> {}
|
||||
case AudioRecord.ERROR_INVALID_OPERATION -> {
|
||||
// not ready yet
|
||||
result.error = Error.TRY_AGAIN;
|
||||
return;
|
||||
}
|
||||
default -> {
|
||||
Log.wtf(TAG, "unexpected error code while getting timestamp: " + ret);
|
||||
result.error = Error.TRY_AGAIN;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
ret = audioRecord.read(buffer, offset, length);
|
||||
if (ret < 0) {
|
||||
Log.e(TAG, "AudioRecord error: " + ret);
|
||||
switch (ret) {
|
||||
case AudioRecord.ERROR,
|
||||
AudioRecord.ERROR_BAD_VALUE -> {
|
||||
result.error = Error.FAILURE;
|
||||
return;
|
||||
}
|
||||
case AudioRecord.ERROR_INVALID_OPERATION -> {
|
||||
result.error = Error.TRY_AGAIN;
|
||||
return;
|
||||
}
|
||||
case AudioRecord.ERROR_DEAD_OBJECT -> {
|
||||
result.error = Error.END_OF_STREAM;
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else if (ret == 0) {
|
||||
Log.w(TAG, "empty buffer");
|
||||
result.error = Error.TRY_AGAIN;
|
||||
return;
|
||||
}
|
||||
|
||||
boolean silent = true;
|
||||
for (int i = offset; i < offset + length; i++) {
|
||||
if (buffer[i] == 0) continue;
|
||||
silent = false;
|
||||
}
|
||||
|
||||
result.error = Error.NO_ERROR;
|
||||
result.length = ret;
|
||||
result.timestamp = timestamp.nanoTime;
|
||||
result.silent = silent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package io.benwiegand.projection.geargrinder.callback;
|
||||
|
||||
public interface ControlListener {
|
||||
|
||||
void onCarNameDiscovered(String carName);
|
||||
|
||||
|
||||
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package io.benwiegand.projection.geargrinder.callback;
|
||||
|
||||
import io.benwiegand.projection.geargrinder.coordinate.CoordinateTranslator;
|
||||
import io.benwiegand.projection.geargrinder.proto.data.readable.input.event.TouchEvent;
|
||||
|
||||
public interface InputEventListener {
|
||||
|
||||
void onTouchEvent(TouchEvent event, CoordinateTranslator<TouchEvent.PointerLocation> translator);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package io.benwiegand.projection.geargrinder.callback;
|
||||
|
||||
public interface MessageListener {
|
||||
|
||||
void onMessage(int channelId, int flags, byte[] buffer, int payloadOffset, int payloadLength);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package io.benwiegand.projection.geargrinder.channel;
|
||||
|
||||
import static io.benwiegand.projection.geargrinder.message.AAFrame.COMMAND_ID_LENGTH;
|
||||
import static io.benwiegand.projection.geargrinder.protocol.AAConstants.AV_CMD_MEDIA_ACK;
|
||||
import static io.benwiegand.projection.geargrinder.protocol.AAConstants.AV_CMD_SETUP_REQUEST;
|
||||
import static io.benwiegand.projection.geargrinder.protocol.AAConstants.AV_CMD_SETUP_RESPONSE;
|
||||
import static io.benwiegand.projection.geargrinder.protocol.AAConstants.AV_CMD_START;
|
||||
import static io.benwiegand.projection.geargrinder.protocol.AAConstants.AV_CMD_STOP;
|
||||
import static io.benwiegand.projection.geargrinder.protocol.AAConstants.AV_CMD_VIDEO_FOCUSED;
|
||||
import static io.benwiegand.projection.geargrinder.protocol.AAConstants.CMD_CHANNEL_OPEN_REQUEST;
|
||||
import static io.benwiegand.projection.geargrinder.protocol.AAConstants.CMD_CHANNEL_OPEN_RESPONSE;
|
||||
import static io.benwiegand.projection.geargrinder.util.ByteUtil.hexDump;
|
||||
import static io.benwiegand.projection.geargrinder.util.ByteUtil.readUInt16;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import io.benwiegand.projection.geargrinder.message.MessageBroker;
|
||||
import io.benwiegand.projection.geargrinder.callback.MessageListener;
|
||||
import io.benwiegand.projection.geargrinder.proto.data.readable.av.AVSetupResponse;
|
||||
import io.benwiegand.projection.geargrinder.proto.data.readable.ChannelOpenResponse;
|
||||
import io.benwiegand.projection.geargrinder.proto.data.writable.av.AVSetupRequest;
|
||||
import io.benwiegand.projection.geargrinder.proto.data.writable.av.AVStartIndication;
|
||||
import io.benwiegand.projection.geargrinder.proto.data.writable.ChannelOpenRequest;
|
||||
|
||||
public abstract class AVChannel<T> implements MessageListener {
|
||||
private final String TAG = getTag();
|
||||
|
||||
// some headunits set the max outstanding ack number too high which would cause major latency if the headunit lags behind
|
||||
// for video this is a quarter second at 60 fps, and half a second at 30 fps
|
||||
// for 48000 Hz audio this is over half a second
|
||||
// this should probably be even lower
|
||||
private static final int MAX_OUTSTANDING_ACK_LIMIT = 15;
|
||||
|
||||
protected final long AV_ACK_TIMEOUT = 500;
|
||||
|
||||
private final Object avAckLock = new Object();
|
||||
private int outstandingAcks = 0;
|
||||
private int maxOutstandingAcks = 1;
|
||||
|
||||
private final Object avThreadLock = new Object();
|
||||
private Thread avThread = null;
|
||||
|
||||
protected final byte[] buffer;
|
||||
|
||||
|
||||
protected final MessageBroker mb;
|
||||
|
||||
protected final MessageBroker.MessageSendParameters controlParams;
|
||||
protected final MessageBroker.MessageSendParameters mediaParams;
|
||||
protected final int channelPriority;
|
||||
|
||||
protected record AVPreset<T>(int index, T preset) {}
|
||||
protected final List<AVPreset<T>> avPresets = new ArrayList<>();
|
||||
|
||||
protected boolean dead = false;
|
||||
|
||||
public AVChannel(MessageBroker mb, int channelId, int channelPriority, int bufferSize) {
|
||||
this.mb = mb;
|
||||
this.channelPriority = channelPriority;
|
||||
buffer = new byte[bufferSize];
|
||||
|
||||
// TODO: determine correct places to use control flag, this is just a guess
|
||||
controlParams = new MessageBroker.MessageSendParameters(channelId, true, true);
|
||||
mediaParams = new MessageBroker.MessageSendParameters(channelId, true, false);
|
||||
|
||||
mb.registerForChannel(channelId, this);
|
||||
}
|
||||
|
||||
public void destroy() {
|
||||
dead = true;
|
||||
}
|
||||
|
||||
public void openChannel() {
|
||||
Log.i(TAG, "sending channel open request");
|
||||
mb.sendMessage(controlParams, CMD_CHANNEL_OPEN_REQUEST,
|
||||
new ChannelOpenRequest(channelPriority, controlParams.channelId()).serialize());
|
||||
}
|
||||
|
||||
protected void onAck() {
|
||||
synchronized (avAckLock) {
|
||||
outstandingAcks--;
|
||||
avAckLock.notify();
|
||||
}
|
||||
}
|
||||
|
||||
protected void expectAck() {
|
||||
synchronized (avAckLock) {
|
||||
outstandingAcks++;
|
||||
}
|
||||
}
|
||||
|
||||
protected boolean waitForAck(long timeout) throws InterruptedException {
|
||||
synchronized (avAckLock) {
|
||||
if (outstandingAcks >= maxOutstandingAcks) {
|
||||
avAckLock.wait(timeout);
|
||||
}
|
||||
return outstandingAcks < maxOutstandingAcks;
|
||||
}
|
||||
}
|
||||
|
||||
protected void start() {
|
||||
synchronized (avThreadLock) {
|
||||
|
||||
if (avThread != null) {
|
||||
Log.w(TAG, "av thread already running");
|
||||
return;
|
||||
}
|
||||
|
||||
outstandingAcks = 0;
|
||||
|
||||
avThread = new Thread(this::avLoop);
|
||||
avThread.start();
|
||||
}
|
||||
}
|
||||
|
||||
protected void sendStartIndication(AVStartIndication startIndication) {
|
||||
Log.i(TAG, "sending start indication");
|
||||
mb.sendMessage(controlParams, AV_CMD_START, startIndication.serialize());
|
||||
}
|
||||
|
||||
protected void sendStopIndication() {
|
||||
Log.i(TAG, "sending stop indication");
|
||||
mb.sendMessage(controlParams, AV_CMD_STOP, new byte[0]);
|
||||
}
|
||||
|
||||
protected void sendAvBuffer(int offset, int length) {
|
||||
mb.sendMessage(mediaParams, buffer, offset, length);
|
||||
expectAck();
|
||||
}
|
||||
|
||||
protected abstract void updatePresets(int[] acceptedPresets);
|
||||
protected abstract void avLoop();
|
||||
|
||||
protected abstract AVSetupRequest getAvSetupRequest();
|
||||
|
||||
@Override
|
||||
public void onMessage(int channelId, int flags, byte[] buffer, int payloadOffset, int payloadLength) {
|
||||
if (payloadLength < COMMAND_ID_LENGTH) {
|
||||
Log.wtf(TAG, "message payload too small!", new RuntimeException());
|
||||
return;
|
||||
}
|
||||
|
||||
int command = readUInt16(buffer, payloadOffset);
|
||||
switch (command) {
|
||||
case CMD_CHANNEL_OPEN_RESPONSE -> {
|
||||
ChannelOpenResponse response = ChannelOpenResponse.parse(buffer, payloadOffset + COMMAND_ID_LENGTH, payloadLength - COMMAND_ID_LENGTH);
|
||||
Log.i(TAG, "channel open response: " + response);
|
||||
if (response == null) {
|
||||
Log.e(TAG, "failed to parse channel open response, bailing!");
|
||||
// TODO: terminate connection
|
||||
return;
|
||||
}
|
||||
|
||||
switch (response.status()) {
|
||||
case OK -> {}
|
||||
case UNKNOWN -> Log.w(TAG, "channel open status unknown"); // still go ahead anyway
|
||||
case ERROR -> {
|
||||
Log.e(TAG, "failed to open channel");
|
||||
// TODO: terminate connection
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Log.i(TAG, "sending av setup request");
|
||||
mb.sendMessage(controlParams, AV_CMD_SETUP_REQUEST, getAvSetupRequest().serialize());
|
||||
}
|
||||
case AV_CMD_SETUP_RESPONSE -> {
|
||||
AVSetupResponse response = AVSetupResponse.parse(buffer, payloadOffset + COMMAND_ID_LENGTH, payloadLength - COMMAND_ID_LENGTH);
|
||||
Log.d(TAG, "av setup response: " + response);
|
||||
if (response == null) {
|
||||
Log.e(TAG, "failed to parse av setup response, bailing!");
|
||||
// TODO: terminate connection
|
||||
return;
|
||||
}
|
||||
|
||||
switch (response.status()) {
|
||||
case OK, NO_ERROR -> {}
|
||||
case UNKNOWN -> Log.w(TAG, "av setup status unknown");
|
||||
case ERROR -> {
|
||||
Log.e(TAG, "av setup failed");
|
||||
// TODO: terminate connection
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
maxOutstandingAcks = response.maxOutstandingAck();
|
||||
if (maxOutstandingAcks > MAX_OUTSTANDING_ACK_LIMIT) {
|
||||
// some headunits return excessive numbers for this which causes latency issues
|
||||
Log.w(TAG, "limiting maxOutstandingAcks from " + maxOutstandingAcks + " to " + MAX_OUTSTANDING_ACK_LIMIT);
|
||||
maxOutstandingAcks = MAX_OUTSTANDING_ACK_LIMIT;
|
||||
}
|
||||
|
||||
updatePresets(response.acceptedPresets());
|
||||
|
||||
start();
|
||||
}
|
||||
case AV_CMD_MEDIA_ACK -> {
|
||||
onAck();
|
||||
}
|
||||
case AV_CMD_VIDEO_FOCUSED -> {
|
||||
Log.d(TAG, "video focus indication");
|
||||
// TODO: more unparsed
|
||||
}
|
||||
|
||||
default -> {
|
||||
Log.w(TAG, "av command not handled: " + command);
|
||||
Log.d(TAG, "payload: " + hexDump(buffer, payloadOffset, payloadLength));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
protected abstract String getTag();
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package io.benwiegand.projection.geargrinder.channel;
|
||||
|
||||
import static io.benwiegand.projection.geargrinder.message.AAFrame.COMMAND_ID_LENGTH;
|
||||
import static io.benwiegand.projection.geargrinder.protocol.AAConstants.AV_CMD_MEDIA_WITH_TIMESTAMP;
|
||||
import static io.benwiegand.projection.geargrinder.util.ByteUtil.writeInt64;
|
||||
import static io.benwiegand.projection.geargrinder.util.ByteUtil.writeUInt16;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import io.benwiegand.projection.geargrinder.audio.AudioCapture;
|
||||
import io.benwiegand.projection.geargrinder.message.MessageBroker;
|
||||
import io.benwiegand.projection.geargrinder.proto.data.readable.av.AudioChannelMeta;
|
||||
import io.benwiegand.projection.geargrinder.proto.data.readable.av.preset.AudioPreset;
|
||||
import io.benwiegand.projection.geargrinder.proto.data.writable.av.AVSetupRequest;
|
||||
import io.benwiegand.projection.geargrinder.proto.data.writable.av.AVStartIndication;
|
||||
|
||||
public class AudioChannel extends AVChannel<AudioPreset> {
|
||||
private static final String TAG = AudioChannel.class.getSimpleName();
|
||||
|
||||
private static final boolean LOG_FRAME_DEBUG = false;
|
||||
|
||||
private static final int AUDIO_BUFFER_RESERVED = COMMAND_ID_LENGTH + 8; // command + 64-bit timestamp
|
||||
private static final int TRAILING_SILENCE_BUFFERS = 2; // stop sending silence after this many silent buffers
|
||||
|
||||
private final AudioCaptureProvider audioCaptureProvider;
|
||||
|
||||
private final AudioChannelMeta channelMeta;
|
||||
|
||||
|
||||
public interface AudioCaptureProvider {
|
||||
AudioCapture getInstance(AudioPreset audioPreset, int bufferSize);
|
||||
}
|
||||
|
||||
private static int calculateBufferSize(AudioChannelMeta channelMeta) {
|
||||
int bufferSize = 0;
|
||||
for (AudioPreset preset : channelMeta.presets()) {
|
||||
if (!preset.isSupported()) continue;
|
||||
if (preset.getMinBufferSize() < bufferSize) continue;
|
||||
bufferSize = preset.getMinBufferSize();
|
||||
}
|
||||
Log.i(TAG, "selected buffer size of " + bufferSize + " (plus " + AUDIO_BUFFER_RESERVED + " reserved)");
|
||||
return bufferSize + AUDIO_BUFFER_RESERVED;
|
||||
}
|
||||
|
||||
|
||||
public AudioChannel(MessageBroker mb, AudioChannelMeta channelMeta, AudioCaptureProvider audioCaptureProvider) {
|
||||
super(mb, channelMeta.channelId(), 0, calculateBufferSize(channelMeta));
|
||||
this.channelMeta = channelMeta;
|
||||
this.audioCaptureProvider = audioCaptureProvider;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
protected void updatePresets(int[] acceptedPresets) {
|
||||
// TODO: presets in the metadata can be null
|
||||
|
||||
boolean presetListValid = true;
|
||||
if (acceptedPresets.length == 0) {
|
||||
Log.w(TAG, "no preset selection provided, assuming all are valid");
|
||||
presetListValid = false;
|
||||
} else {
|
||||
for (int i : acceptedPresets) {
|
||||
if (i >= 0 && i < channelMeta.presets().length) continue;
|
||||
Log.wtf(TAG, "index out of range in accepted presets");
|
||||
presetListValid = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!presetListValid) {
|
||||
acceptedPresets = new int[channelMeta.presets().length];
|
||||
for (int i = 0; i < acceptedPresets.length; i++) acceptedPresets[i] = i;
|
||||
}
|
||||
|
||||
Log.d(TAG, "accepted presets: " + Arrays.toString(acceptedPresets));
|
||||
|
||||
avPresets.clear();
|
||||
|
||||
for (int i : acceptedPresets) {
|
||||
AudioPreset preset = channelMeta.presets()[i];
|
||||
if (!preset.isSupported()) continue;
|
||||
|
||||
Log.i(TAG, "found supported audio preset: " + preset);
|
||||
avPresets.add(new AVPreset<>(i, preset));
|
||||
}
|
||||
|
||||
if (avPresets.isEmpty())
|
||||
Log.e(TAG, "failed to find any supported accepted audio presets");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected AVSetupRequest getAvSetupRequest() {
|
||||
return AVSetupRequest.createAudio();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void avLoop() {
|
||||
Log.i(TAG, "audio loop start");
|
||||
int silence = 0;
|
||||
AudioCapture.Result result = new AudioCapture.Result();
|
||||
|
||||
// find working preset
|
||||
AVPreset<AudioPreset> avPreset = null;
|
||||
AudioCapture audioCapture = null;
|
||||
for (AVPreset<AudioPreset> p : avPresets) {
|
||||
audioCapture = audioCaptureProvider.getInstance(p.preset(), buffer.length - AUDIO_BUFFER_RESERVED);
|
||||
if (audioCapture == null) {
|
||||
Log.e(TAG, "failed to init audio capture with preset: " + p.preset());
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
audioCapture.begin();
|
||||
} catch (Throwable t) {
|
||||
Log.e(TAG, "failed to start audio capture with preset: " + p.preset(), t);
|
||||
audioCapture.destroy();
|
||||
audioCapture = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
avPreset = p;
|
||||
break;
|
||||
}
|
||||
|
||||
if (avPreset == null) {
|
||||
Log.e(TAG, "no working audio presets!");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
sendStartIndication(new AVStartIndication(0, avPreset.index()));
|
||||
|
||||
while (!dead) {
|
||||
if (!waitForAck(AV_ACK_TIMEOUT)) continue;
|
||||
|
||||
audioCapture.nextBuffer(result, buffer, AUDIO_BUFFER_RESERVED, buffer.length - AUDIO_BUFFER_RESERVED);
|
||||
switch (result.error) {
|
||||
case NO_ERROR -> { }
|
||||
case TRY_AGAIN -> {
|
||||
// TODO: calculate and wait buffer time
|
||||
if (LOG_FRAME_DEBUG) Log.w(TAG, "try again");
|
||||
continue;
|
||||
}
|
||||
case FAILURE -> {
|
||||
Log.e(TAG, "audio capture failure");
|
||||
return;
|
||||
}
|
||||
case END_OF_STREAM -> {
|
||||
Log.v(TAG, "end of stream");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// when no audio is playing, no audio packets are sent
|
||||
// but some trailing empty buffers must be sent to avoid the receiver indefinitely repeating the last 40 ms or so of audio
|
||||
if (result.silent) {
|
||||
if (silence > TRAILING_SILENCE_BUFFERS) continue;
|
||||
silence++;
|
||||
} else {
|
||||
silence = 0;
|
||||
}
|
||||
|
||||
writeUInt16(AV_CMD_MEDIA_WITH_TIMESTAMP, buffer, 0);
|
||||
writeInt64(result.timestamp, buffer, COMMAND_ID_LENGTH);
|
||||
|
||||
sendAvBuffer(0, result.length + AUDIO_BUFFER_RESERVED);
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Log.e(TAG, "interrupted", e);
|
||||
} finally {
|
||||
Log.d(TAG, "audio loop death");
|
||||
sendStopIndication();
|
||||
audioCapture.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getTag() {
|
||||
return TAG;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package io.benwiegand.projection.geargrinder.channel;
|
||||
|
||||
import static android.media.AudioAttributes.USAGE_ALARM;
|
||||
import static io.benwiegand.projection.geargrinder.message.AAFrame.COMMAND_ID_LENGTH;
|
||||
import static io.benwiegand.projection.geargrinder.protocol.AAConstants.*;
|
||||
import static io.benwiegand.projection.geargrinder.util.ByteUtil.hexDump;
|
||||
import static io.benwiegand.projection.geargrinder.util.ByteUtil.readUInt16;
|
||||
import static io.benwiegand.projection.geargrinder.util.ByteUtil.writeUInt16;
|
||||
|
||||
import android.content.Context;
|
||||
import android.media.AudioPlaybackCaptureConfiguration;
|
||||
import android.util.Log;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import javax.net.ssl.SSLException;
|
||||
|
||||
import io.benwiegand.projection.geargrinder.ConnectionService;
|
||||
import io.benwiegand.projection.geargrinder.audio.AudioRecordCapture;
|
||||
import io.benwiegand.projection.geargrinder.crypto.TLSService;
|
||||
import io.benwiegand.projection.geargrinder.message.MessageBroker;
|
||||
import io.benwiegand.projection.geargrinder.callback.MessageListener;
|
||||
import io.benwiegand.projection.geargrinder.projection.ProjectionService;
|
||||
import io.benwiegand.projection.geargrinder.proto.data.readable.av.AudioChannelMeta;
|
||||
import io.benwiegand.projection.geargrinder.proto.data.readable.ChannelMeta;
|
||||
import io.benwiegand.projection.geargrinder.proto.data.readable.input.InputChannelMeta;
|
||||
import io.benwiegand.projection.geargrinder.proto.data.writable.ServiceDiscoveryRequest;
|
||||
import io.benwiegand.projection.geargrinder.proto.data.readable.ServiceDiscoveryResponse;
|
||||
import io.benwiegand.projection.geargrinder.proto.data.readable.av.VideoChannelMeta;
|
||||
import io.benwiegand.projection.geargrinder.callback.ControlListener;
|
||||
|
||||
public class ControlChannel implements MessageListener {
|
||||
private static final String TAG = ControlChannel.class.getSimpleName();
|
||||
|
||||
private static final int VERSION_CODE_MAJOR = 1;
|
||||
private static final int VERSION_CODE_MINOR = 7;
|
||||
|
||||
private final ConnectionService.ServiceBinder connectionServiceBinder;
|
||||
private final MessageBroker mb;
|
||||
private final TLSService tlsService;
|
||||
|
||||
private final MessageBroker.MessageSendParameters unencryptedParams;
|
||||
private final MessageBroker.MessageSendParameters encryptedParams;
|
||||
|
||||
private final ControlListener controlListener;
|
||||
|
||||
private final ProjectionService projectionService;
|
||||
private VideoChannel videoChannel = null;
|
||||
private AudioChannel mediaAudioChannel = null;
|
||||
private InputChannel inputChannel = null;
|
||||
|
||||
private VideoChannelMeta videoChannelMeta = null;
|
||||
private AudioChannelMeta mediaAudioChannelMeta = null;
|
||||
private InputChannelMeta inputChannelMeta = null;
|
||||
|
||||
|
||||
public ControlChannel(Context context, MessageBroker mb, TLSService tlsService, ControlListener controlListener, ConnectionService.ServiceBinder connectionServiceBinder) {
|
||||
this.connectionServiceBinder = connectionServiceBinder;
|
||||
this.mb = mb;
|
||||
this.tlsService = tlsService;
|
||||
this.controlListener = controlListener;
|
||||
projectionService = new ProjectionService(context);
|
||||
|
||||
unencryptedParams = new MessageBroker.MessageSendParameters(CHANNEL_CONTROL, false, false);
|
||||
encryptedParams = new MessageBroker.MessageSendParameters(CHANNEL_CONTROL, true, false);
|
||||
}
|
||||
|
||||
public void destroy() {
|
||||
projectionService.destroy();
|
||||
if (videoChannel != null) videoChannel.destroy();
|
||||
if (mediaAudioChannel != null) mediaAudioChannel.destroy();
|
||||
if (inputChannel != null) inputChannel.destroy();
|
||||
}
|
||||
|
||||
private void handleServiceDiscoveryResponse(ServiceDiscoveryResponse response) {
|
||||
controlListener.onCarNameDiscovered(response.friendlyName());
|
||||
|
||||
for (ChannelMeta channelMeta : response.channelMetadata()) switch (channelMeta) {
|
||||
case VideoChannelMeta vcm -> {
|
||||
if (videoChannelMeta != null) Log.w(TAG, "multiple video channels detected."); // this maybe won't happen
|
||||
videoChannelMeta = vcm;
|
||||
Log.d(TAG, "found video channel: " + videoChannelMeta);
|
||||
Log.d(TAG, " - presets: " + Arrays.toString(videoChannelMeta.presets()));
|
||||
}
|
||||
case AudioChannelMeta acm -> {
|
||||
if (acm.audioType() != AudioChannelMeta.AudioType.MEDIA) {
|
||||
Log.d(TAG, "found audio channel: " + acm);
|
||||
continue;
|
||||
}
|
||||
if (mediaAudioChannelMeta != null) Log.w(TAG, "multiple media audio channels detected."); // this probably won't happen
|
||||
mediaAudioChannelMeta = acm;
|
||||
Log.d(TAG, "found media audio channel: " + mediaAudioChannelMeta);
|
||||
Log.d(TAG, " - presets: " + Arrays.toString(mediaAudioChannelMeta.presets()));
|
||||
}
|
||||
case InputChannelMeta icm -> {
|
||||
if (inputChannelMeta != null) Log.w(TAG, "multiple input channels detected."); // this probably won't happen
|
||||
inputChannelMeta = icm;
|
||||
Log.d(TAG, "found input channel: " + inputChannelMeta);
|
||||
if (!inputChannelMeta.hasTouchScreen()) Log.w(TAG, "no touch screen found");
|
||||
}
|
||||
case null -> {}
|
||||
default -> Log.d(TAG, "found channel: " + channelMeta);
|
||||
}
|
||||
|
||||
if (videoChannelMeta == null) Log.w(TAG, "can't start video: no video channel");
|
||||
if (mediaAudioChannelMeta == null) Log.w(TAG, "can't start audio: no media audio channel");
|
||||
if (inputChannelMeta == null) Log.w(TAG, "can't start input: no input channel");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessage(int channelId, int flags, byte[] buffer, int payloadOffset, int payloadLength) {
|
||||
if (payloadLength < COMMAND_ID_LENGTH) {
|
||||
Log.wtf(TAG, "message payload too small!", new RuntimeException());
|
||||
return;
|
||||
}
|
||||
|
||||
int command = readUInt16(buffer, payloadOffset);
|
||||
switch (command) {
|
||||
case CMD_PING_REQUEST -> {
|
||||
Log.d(TAG, "ping!");
|
||||
mb.sendMessage(unencryptedParams, CMD_PING_RESPONSE);
|
||||
}
|
||||
|
||||
case CMD_VERSION_REQUEST -> {
|
||||
Log.d(TAG, "version request: " + hexDump(buffer, payloadOffset, payloadLength));
|
||||
|
||||
// TODO: rework
|
||||
if (payloadLength >= COMMAND_ID_LENGTH + 2) {
|
||||
int major = readUInt16(buffer, COMMAND_ID_LENGTH);
|
||||
int minor = readUInt16(buffer, COMMAND_ID_LENGTH + 2);
|
||||
Log.v(TAG, "headunit version code: " + major + "." + minor);
|
||||
}
|
||||
|
||||
// TODO: rework
|
||||
int i = 0;
|
||||
byte[] payload = new byte[8];
|
||||
i += writeUInt16(CMD_VERSION_RESPONSE, payload, i);
|
||||
i += writeUInt16(VERSION_CODE_MAJOR, payload, i);
|
||||
i += writeUInt16(VERSION_CODE_MINOR, payload, i);
|
||||
i += writeUInt16(0, payload, i); // version code status. I assume this is for negotiation. (todo)
|
||||
mb.sendMessage(unencryptedParams, payload);
|
||||
}
|
||||
|
||||
case CMD_SSL_HANDSHAKE -> {
|
||||
Log.d(TAG, "recv SSL/TLS handshake data, len = " + payloadLength);
|
||||
|
||||
try {
|
||||
tlsService.doHandshake(buffer, payloadOffset + COMMAND_ID_LENGTH, payloadLength - COMMAND_ID_LENGTH, out -> {
|
||||
byte[] data = new byte[out.remaining()];
|
||||
out.get(data);
|
||||
mb.sendMessage(unencryptedParams, CMD_SSL_HANDSHAKE, data);
|
||||
});
|
||||
} catch (SSLException e) {
|
||||
Log.e(TAG, "exception during SSL handshake", e);
|
||||
mb.closeConnection();
|
||||
}
|
||||
}
|
||||
|
||||
case CMD_AUTH_COMPLETE -> {
|
||||
Log.i(TAG, "auth complete");
|
||||
|
||||
if (tlsService.needsHandshake()) {
|
||||
Log.wtf(TAG, "auth complete before handshake completed?");
|
||||
mb.closeConnection();
|
||||
return;
|
||||
}
|
||||
|
||||
Log.i(TAG, "sending service discovery request");
|
||||
mb.sendMessage(encryptedParams, CMD_SERVICE_DISCOVERY_REQUEST, ServiceDiscoveryRequest.getDefault().serialize());
|
||||
}
|
||||
|
||||
case CMD_SERVICE_DISCOVERY_RESPONSE -> {
|
||||
Log.d(TAG, "service discovery response");
|
||||
|
||||
if (tlsService.needsHandshake()) {
|
||||
Log.wtf(TAG, "service discovery response before handshake completed?"); // a request shouldn't have been sent yet
|
||||
mb.closeConnection();
|
||||
return;
|
||||
}
|
||||
|
||||
if (videoChannel != null) {
|
||||
Log.wtf(TAG, "service discovery response after video init?");
|
||||
mb.closeConnection();
|
||||
return;
|
||||
}
|
||||
|
||||
ServiceDiscoveryResponse response = ServiceDiscoveryResponse.parse(buffer, payloadOffset + COMMAND_ID_LENGTH, payloadLength - COMMAND_ID_LENGTH);
|
||||
if (response == null) {
|
||||
Log.e(TAG, "failed to parse service discovery response, bailing!");
|
||||
mb.closeConnection();
|
||||
return;
|
||||
}
|
||||
|
||||
Log.d(TAG, "response data: " + response);
|
||||
handleServiceDiscoveryResponse(response);
|
||||
|
||||
if (videoChannelMeta != null) {
|
||||
Log.d(TAG, "init video channel");
|
||||
videoChannel = new VideoChannel(mb, projectionService, videoChannelMeta);
|
||||
videoChannel.openChannel();
|
||||
}
|
||||
|
||||
if (mediaAudioChannelMeta != null) {
|
||||
connectionServiceBinder.requestMediaProjection(mediaProjection -> {
|
||||
if (!mb.isAlive()) return;
|
||||
|
||||
Log.d(TAG, "init media audio channel");
|
||||
mediaAudioChannel = new AudioChannel(mb, mediaAudioChannelMeta, (preset, bufferSize) -> {
|
||||
// TODO: handle permission and api requirement
|
||||
return new AudioRecordCapture(
|
||||
new AudioPlaybackCaptureConfiguration.Builder(mediaProjection)
|
||||
.excludeUsage(USAGE_ALARM)
|
||||
.build(),
|
||||
preset,
|
||||
bufferSize
|
||||
);
|
||||
});
|
||||
mediaAudioChannel.openChannel();
|
||||
});
|
||||
}
|
||||
|
||||
if (inputChannelMeta != null) {
|
||||
Log.d(TAG, "init input channel");
|
||||
inputChannel = new InputChannel(mb, inputChannelMeta);
|
||||
inputChannel.openChannel();
|
||||
projectionService.setInput(inputChannel);
|
||||
}
|
||||
}
|
||||
|
||||
default -> {
|
||||
Log.w(TAG, "control command not handled: " + command);
|
||||
Log.d(TAG, "payload: " + hexDump(buffer, payloadOffset, payloadLength));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package io.benwiegand.projection.geargrinder.channel;
|
||||
|
||||
import static io.benwiegand.projection.geargrinder.message.AAFrame.COMMAND_ID_LENGTH;
|
||||
import static io.benwiegand.projection.geargrinder.protocol.AAConstants.CMD_CHANNEL_OPEN_REQUEST;
|
||||
import static io.benwiegand.projection.geargrinder.protocol.AAConstants.CMD_CHANNEL_OPEN_RESPONSE;
|
||||
import static io.benwiegand.projection.geargrinder.protocol.AAConstants.INPUT_CMD_BINDING_REQUEST;
|
||||
import static io.benwiegand.projection.geargrinder.protocol.AAConstants.INPUT_CMD_BINDING_RESPONSE;
|
||||
import static io.benwiegand.projection.geargrinder.protocol.AAConstants.INPUT_CMD_EVENT;
|
||||
import static io.benwiegand.projection.geargrinder.util.ByteUtil.hexDump;
|
||||
import static io.benwiegand.projection.geargrinder.util.ByteUtil.readUInt16;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import io.benwiegand.projection.geargrinder.callback.InputEventListener;
|
||||
import io.benwiegand.projection.geargrinder.coordinate.CoordinateTranslator;
|
||||
import io.benwiegand.projection.geargrinder.message.MessageBroker;
|
||||
import io.benwiegand.projection.geargrinder.callback.MessageListener;
|
||||
import io.benwiegand.projection.geargrinder.proto.ProtoParser;
|
||||
import io.benwiegand.projection.geargrinder.proto.data.readable.ChannelOpenResponse;
|
||||
import io.benwiegand.projection.geargrinder.proto.data.readable.input.InputChannelMeta;
|
||||
import io.benwiegand.projection.geargrinder.proto.data.readable.input.InputEventData;
|
||||
import io.benwiegand.projection.geargrinder.proto.data.readable.input.event.ButtonEvent;
|
||||
import io.benwiegand.projection.geargrinder.proto.data.readable.input.event.TouchEvent;
|
||||
import io.benwiegand.projection.geargrinder.proto.data.writable.ChannelOpenRequest;
|
||||
|
||||
public class InputChannel implements MessageListener {
|
||||
private static final String TAG = InputChannel.class.getSimpleName();
|
||||
|
||||
private static final boolean LOG_EVENT_DEBUG = false;
|
||||
|
||||
private final MessageBroker mb;
|
||||
|
||||
private final InputChannelMeta channelMeta;
|
||||
private final MessageBroker.MessageSendParameters messageParams;
|
||||
|
||||
private InputEventListener inputEventListener = null;
|
||||
private final CoordinateTranslator<TouchEvent.PointerLocation> touchCoordinateTranslator = CoordinateTranslator.createPassthrough(TouchEvent.PointerLocation.class);
|
||||
|
||||
public InputChannel(MessageBroker mb, InputChannelMeta channelMeta) {
|
||||
this.mb = mb;
|
||||
this.channelMeta = channelMeta;
|
||||
messageParams = new MessageBroker.MessageSendParameters(channelMeta.channelId(), true, false);
|
||||
|
||||
mb.registerForChannel(channelMeta.channelId(), this);
|
||||
}
|
||||
|
||||
public void destroy() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
public void openChannel() {
|
||||
Log.i(TAG, "sending channel open request");
|
||||
mb.sendMessage(messageParams, CMD_CHANNEL_OPEN_REQUEST, new ChannelOpenRequest(0, channelMeta.channelId()).serialize());
|
||||
}
|
||||
|
||||
public void setInputEventListener(InputEventListener listener) {
|
||||
inputEventListener = listener;
|
||||
}
|
||||
|
||||
public InputChannelMeta getMetadata() {
|
||||
return channelMeta;
|
||||
}
|
||||
|
||||
private void onInputEvent(InputEventData eventData) {
|
||||
for (ButtonEvent buttonEvent : eventData.buttonEvents()) {
|
||||
Log.i(TAG, "button event: " + buttonEvent);
|
||||
// TODO: parse
|
||||
}
|
||||
|
||||
if (eventData.touchEvent() != null) {
|
||||
if (!channelMeta.hasTouchScreen()) {
|
||||
Log.wtf(TAG, "got touch event with no attached touch screen???");
|
||||
return;
|
||||
}
|
||||
inputEventListener.onTouchEvent(eventData.touchEvent(), touchCoordinateTranslator);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessage(int channelId, int flags, byte[] buffer, int payloadOffset, int payloadLength) {
|
||||
if (payloadLength < COMMAND_ID_LENGTH) {
|
||||
Log.wtf(TAG, "message payload too small!", new RuntimeException());
|
||||
return;
|
||||
}
|
||||
|
||||
int command = readUInt16(buffer, payloadOffset);
|
||||
switch (command) {
|
||||
case CMD_CHANNEL_OPEN_RESPONSE -> {
|
||||
ChannelOpenResponse response = ChannelOpenResponse.parse(buffer, payloadOffset + COMMAND_ID_LENGTH, payloadLength - COMMAND_ID_LENGTH);
|
||||
Log.i(TAG, "channel open response: " + response);
|
||||
if (response == null) {
|
||||
Log.e(TAG, "failed to parse channel open response, bailing!");
|
||||
// TODO: terminate connection
|
||||
return;
|
||||
}
|
||||
|
||||
switch (response.status()) {
|
||||
case OK -> {}
|
||||
case UNKNOWN -> Log.w(TAG, "channel open status unknown"); // still go ahead anyway
|
||||
case ERROR -> {
|
||||
Log.e(TAG, "failed to open channel");
|
||||
// TODO: terminate connection
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Log.i(TAG, "sending input binding request");
|
||||
mb.sendMessage(messageParams, INPUT_CMD_BINDING_REQUEST, new byte[0]);
|
||||
}
|
||||
|
||||
case INPUT_CMD_EVENT -> {
|
||||
InputEventData eventData = InputEventData.parse(buffer, payloadOffset + COMMAND_ID_LENGTH, payloadLength - COMMAND_ID_LENGTH);
|
||||
if (LOG_EVENT_DEBUG) {
|
||||
Log.d(TAG, "got input event: " + eventData);
|
||||
ProtoParser.debugDumpRecursive(buffer, payloadOffset + COMMAND_ID_LENGTH, payloadLength - COMMAND_ID_LENGTH);
|
||||
}
|
||||
if (eventData == null) {
|
||||
Log.e(TAG, "failed to parse input event");
|
||||
return;
|
||||
}
|
||||
|
||||
onInputEvent(eventData);
|
||||
}
|
||||
|
||||
case INPUT_CMD_BINDING_RESPONSE -> {
|
||||
Log.d(TAG, "binding response");
|
||||
|
||||
// TODO: unparsed
|
||||
Log.d(TAG, "payload: " + hexDump(buffer, payloadOffset, payloadLength));
|
||||
}
|
||||
|
||||
default -> {
|
||||
Log.w(TAG, "input command not handled: " + command);
|
||||
Log.d(TAG, "payload: " + hexDump(buffer, payloadOffset, payloadLength));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package io.benwiegand.projection.geargrinder.channel;
|
||||
|
||||
import static io.benwiegand.projection.geargrinder.message.AAFrame.COMMAND_ID_LENGTH;
|
||||
import static io.benwiegand.projection.geargrinder.protocol.AAConstants.*;
|
||||
import static io.benwiegand.projection.geargrinder.util.ByteUtil.writeInt64;
|
||||
import static io.benwiegand.projection.geargrinder.util.ByteUtil.writeUInt16;
|
||||
|
||||
import android.os.SystemClock;
|
||||
import android.util.Log;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import io.benwiegand.projection.geargrinder.message.MessageBroker;
|
||||
import io.benwiegand.projection.geargrinder.projection.ProjectionService;
|
||||
import io.benwiegand.projection.geargrinder.proto.data.readable.av.VideoChannelMeta;
|
||||
import io.benwiegand.projection.geargrinder.proto.data.readable.av.preset.VideoPreset;
|
||||
import io.benwiegand.projection.geargrinder.proto.data.writable.av.AVSetupRequest;
|
||||
import io.benwiegand.projection.geargrinder.proto.data.writable.av.AVStartIndication;
|
||||
import io.benwiegand.projection.geargrinder.video.FrameRateCounter;
|
||||
import io.benwiegand.projection.geargrinder.video.VideoEncoder;
|
||||
|
||||
public class VideoChannel extends AVChannel<VideoPreset> {
|
||||
private static final String TAG = VideoChannel.class.getSimpleName();
|
||||
|
||||
private static final boolean LOG_FRAME_DEBUG = false;
|
||||
private static final boolean LOG_FRAME_RATE_DEBUG = false;
|
||||
|
||||
// TODO: find the ideal limit
|
||||
// private static final int VIDEO_BUFFER_MAX_LENGTH = 0x10000; // 64 KiB
|
||||
private static final int VIDEO_BUFFER_MAX_LENGTH = 0x20000; // 128 KiB
|
||||
|
||||
private static final long VIDEO_FRAME_TIMEOUT_US = 500000; // 500 ms
|
||||
private static final int VIDEO_BUFFER_RESERVED = COMMAND_ID_LENGTH + 8; // command + 64-bit timestamp
|
||||
|
||||
private final FrameRateCounter frameRateCounter = new FrameRateCounter();
|
||||
|
||||
private final ProjectionService projectionService;
|
||||
|
||||
private final VideoChannelMeta channelMeta;
|
||||
|
||||
private static int calculateVideoBufferSize(MessageBroker mb) {
|
||||
int bufferSize = VIDEO_BUFFER_MAX_LENGTH;
|
||||
int maxPayload = mb.getMaxPayloadSize(true);
|
||||
if (bufferSize > maxPayload) {
|
||||
Log.w(TAG, "limiting video buffer to " + maxPayload + " bytes due to max payload size");
|
||||
bufferSize = maxPayload;
|
||||
}
|
||||
return bufferSize;
|
||||
}
|
||||
|
||||
public VideoChannel(MessageBroker mb, ProjectionService projectionService, VideoChannelMeta channelMeta) {
|
||||
super(mb, channelMeta.channelId(), 0, calculateVideoBufferSize(mb));
|
||||
this.channelMeta = channelMeta;
|
||||
this.projectionService = projectionService;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updatePresets(int[] acceptedPresets) {
|
||||
// TODO: presets in the metadata can be null
|
||||
|
||||
boolean presetListValid = true;
|
||||
if (acceptedPresets.length == 0) {
|
||||
Log.w(TAG, "no preset selection provided, assuming all are valid");
|
||||
presetListValid = false;
|
||||
} else {
|
||||
for (int i : acceptedPresets) {
|
||||
if (i >= 0 && i < channelMeta.presets().length) continue;
|
||||
Log.wtf(TAG, "index out of range in accepted presets");
|
||||
presetListValid = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!presetListValid) {
|
||||
acceptedPresets = new int[channelMeta.presets().length];
|
||||
for (int i = 0; i < acceptedPresets.length; i++) acceptedPresets[i] = i;
|
||||
}
|
||||
|
||||
Log.d(TAG, "accepted presets: " + Arrays.toString(acceptedPresets));
|
||||
|
||||
avPresets.clear();
|
||||
|
||||
for (int i : acceptedPresets) {
|
||||
VideoPreset preset = channelMeta.presets()[i];
|
||||
Log.i(TAG, "found video preset: " + preset);
|
||||
avPresets.add(new AVPreset<>(i, preset));
|
||||
}
|
||||
|
||||
// TODO: Sort presets
|
||||
|
||||
if (avPresets.isEmpty())
|
||||
Log.wtf(TAG, "no presets!");
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
protected AVSetupRequest getAvSetupRequest() {
|
||||
return AVSetupRequest.createVideo();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void avLoop() {
|
||||
Log.i(TAG, "video loop start");
|
||||
long lastFrameGeneratedAt = 0;
|
||||
long minFrameInterval, frameTs, timeToNextFrame;
|
||||
int payloadOffset, payloadLength;
|
||||
VideoEncoder.FrameResult result = new VideoEncoder.FrameResult();
|
||||
|
||||
// find working preset
|
||||
AVPreset<VideoPreset> avPreset = null;
|
||||
VideoEncoder videoEncoder = null;
|
||||
for (AVPreset<VideoPreset> p : avPresets) {
|
||||
videoEncoder = new VideoEncoder(
|
||||
p.preset().width(),
|
||||
p.preset().height(),
|
||||
p.preset().refreshRate().hz(),
|
||||
buffer.length - VIDEO_BUFFER_RESERVED
|
||||
);
|
||||
try {
|
||||
Log.i(TAG, "video mode: " + p.preset().width() + " x " + p.preset().height() + " @ " + p.preset().refreshRate().hz() + ", density = " + p.preset().density());
|
||||
videoEncoder.init();
|
||||
} catch (Throwable t) {
|
||||
Log.e(TAG, "failed to initialize video with preset: " + p.preset(), t);
|
||||
videoEncoder.destroy();
|
||||
videoEncoder = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
avPreset = p;
|
||||
break;
|
||||
}
|
||||
|
||||
if (avPreset == null) {
|
||||
Log.e(TAG, "no working video presets!");
|
||||
return;
|
||||
}
|
||||
|
||||
minFrameInterval = 1000 / avPreset.preset().refreshRate().hz();
|
||||
|
||||
try {
|
||||
sendStartIndication(new AVStartIndication(0, avPreset.index()));
|
||||
|
||||
// video resolution and projection resolution are different sometimes to account for screen margin
|
||||
// AA always uses a 16:9 video, but the headunit screen may differ
|
||||
projectionService.setOutput(
|
||||
videoEncoder.getInputSurface(),
|
||||
avPreset.preset()
|
||||
);
|
||||
|
||||
while (!dead) {
|
||||
waitForAck(AV_ACK_TIMEOUT);
|
||||
|
||||
timeToNextFrame = minFrameInterval - (SystemClock.elapsedRealtime() - lastFrameGeneratedAt);
|
||||
if (timeToNextFrame > 0) {
|
||||
if (LOG_FRAME_DEBUG) Log.d(TAG, "too early for next frame, waiting " + timeToNextFrame);
|
||||
// noinspection BusyWait: this is for throttling
|
||||
Thread.sleep(timeToNextFrame);
|
||||
} else if (timeToNextFrame < 0) {
|
||||
if (LOG_FRAME_DEBUG) Log.d(TAG, "late for next frame by " + -timeToNextFrame);
|
||||
}
|
||||
|
||||
frameTs = SystemClock.elapsedRealtime();
|
||||
videoEncoder.getFrame(result, buffer, VIDEO_BUFFER_RESERVED, VIDEO_FRAME_TIMEOUT_US);
|
||||
|
||||
switch (result.error) {
|
||||
case NO_ERROR -> {}
|
||||
case NO_FRAME -> {
|
||||
if (LOG_FRAME_DEBUG) Log.d(TAG, "no frame");
|
||||
lastFrameGeneratedAt = frameTs;
|
||||
continue;
|
||||
}
|
||||
case TRY_AGAIN -> {
|
||||
if (LOG_FRAME_DEBUG) Log.d(TAG, "try again");
|
||||
continue;
|
||||
}
|
||||
case DROP -> {
|
||||
Log.w(TAG, "dropping frame!!");
|
||||
lastFrameGeneratedAt = frameTs;
|
||||
continue;
|
||||
}
|
||||
case END_OF_STREAM -> {
|
||||
Log.i(TAG, "end of stream");
|
||||
return;
|
||||
}
|
||||
case FAILURE -> {
|
||||
Log.e(TAG, "frame generation failure");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
assert result.length > 0;
|
||||
assert result.length + VIDEO_BUFFER_RESERVED <= buffer.length;
|
||||
|
||||
if (result.timestamp == 0) {
|
||||
// with no timestamp
|
||||
writeUInt16(AV_CMD_MEDIA, buffer, 8);
|
||||
payloadOffset = 8;
|
||||
payloadLength = COMMAND_ID_LENGTH + result.length;
|
||||
} else {
|
||||
// with timestamp
|
||||
writeUInt16(AV_CMD_MEDIA_WITH_TIMESTAMP, buffer, 8);
|
||||
writeInt64(result.timestamp, buffer, 0);
|
||||
payloadOffset = 0;
|
||||
payloadLength = COMMAND_ID_LENGTH + 8 + result.length;
|
||||
}
|
||||
|
||||
lastFrameGeneratedAt = frameTs;
|
||||
frameRateCounter.onFrame();
|
||||
if (LOG_FRAME_RATE_DEBUG) Log.v(TAG, "fps: " + frameRateCounter.getFrameRate());
|
||||
|
||||
if (LOG_FRAME_DEBUG) Log.v(TAG, "sending frame size: " + payloadLength);
|
||||
sendAvBuffer(payloadOffset, payloadLength);
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Log.e(TAG, "interrupted", e);
|
||||
} finally {
|
||||
Log.d(TAG, "video loop death");
|
||||
sendStopIndication();
|
||||
videoEncoder.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getTag() {
|
||||
return TAG;
|
||||
}
|
||||
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package io.benwiegand.projection.geargrinder.coordinate;
|
||||
|
||||
import java.util.function.Function;
|
||||
|
||||
import io.benwiegand.projection.geargrinder.proto.data.readable.input.event.TouchEvent;
|
||||
|
||||
public class CoordinateTranslator<T> {
|
||||
|
||||
private final Function<T, Integer> getX;
|
||||
private final Function<T, Integer> getY;
|
||||
private final Function<Integer, Integer> translateX;
|
||||
private final Function<Integer, Integer> translateY;
|
||||
|
||||
private CoordinateTranslator(Function<T, Integer> getX, Function<T, Integer> getY, Function<Integer, Integer> translateX, Function<Integer, Integer> translateY) {
|
||||
this.getX = getX;
|
||||
this.getY = getY;
|
||||
this.translateX = translateX;
|
||||
this.translateY = translateY;
|
||||
}
|
||||
|
||||
private int getX(T holder) {
|
||||
return getX.apply(holder);
|
||||
}
|
||||
|
||||
private int getY(T holder) {
|
||||
return getY.apply(holder);
|
||||
}
|
||||
|
||||
public int translateX(T holder) {
|
||||
return translateX.apply(getX(holder));
|
||||
}
|
||||
|
||||
public int translateY(T holder) {
|
||||
return translateY.apply(getY(holder));
|
||||
}
|
||||
|
||||
public CoordinateTranslator<T> chain(CoordinateTranslator<T> next) {
|
||||
return new CoordinateTranslator<>(
|
||||
getX, getY,
|
||||
x -> next.translateX.apply(translateX.apply(x)),
|
||||
y -> next.translateY.apply(translateY.apply(y))
|
||||
);
|
||||
}
|
||||
|
||||
public static CoordinateTranslator<TouchEvent.PointerLocation> create(Function<Integer, Integer> translateX, Function<Integer, Integer> translateY, Class<TouchEvent.PointerLocation> ignored) {
|
||||
return new CoordinateTranslator<>(TouchEvent.PointerLocation::x, TouchEvent.PointerLocation::y, translateX, translateY);
|
||||
}
|
||||
|
||||
public static CoordinateTranslator<TouchEvent.PointerLocation> createPassthrough(Class<TouchEvent.PointerLocation> ignored) {
|
||||
return create(x -> x, y -> y, TouchEvent.PointerLocation.class);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
package io.benwiegand.projection.geargrinder.crypto;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import org.bouncycastle.asn1.x509.X509Name;
|
||||
import org.bouncycastle.x509.X509V3CertificateGenerator;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.Key;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.KeyStore;
|
||||
import java.security.KeyStoreException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.SignatureException;
|
||||
import java.security.UnrecoverableKeyException;
|
||||
import java.security.cert.Certificate;
|
||||
import java.security.cert.CertificateEncodingException;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
|
||||
import javax.net.ssl.KeyManager;
|
||||
import javax.net.ssl.KeyManagerFactory;
|
||||
import javax.net.ssl.TrustManager;
|
||||
import javax.net.ssl.TrustManagerFactory;
|
||||
|
||||
public class KeystoreManager {
|
||||
public static class CorruptedKeystoreException extends Exception {
|
||||
public CorruptedKeystoreException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
|
||||
private static final String TAG = KeystoreManager.class.getSimpleName();
|
||||
|
||||
// try to preserve forward-compatibility if possible
|
||||
private static final String KEYSTORE_TYPE = "BKS";
|
||||
private static final String KEYSTORE_TYPE_FALLBACK = KeyStore.getDefaultType();
|
||||
|
||||
// before you scream, the password serves no purpose in this case. it's not part of the threat model
|
||||
private static final char[] KEYSTORE_PASSWORD = "hunter2".toCharArray();
|
||||
|
||||
private static final String KEYPAIR_ALGORITHM = "RSA";
|
||||
private static final String SIGNING_ALGORITHM = "SHA256WithRSAEncryption";
|
||||
|
||||
private static final int KEY_SIZE = 2048;
|
||||
|
||||
public static final String CERTIFICATE_COMMON_NAME = "Bob"; // bob is a pretty common name
|
||||
|
||||
private final File keystoreFile;
|
||||
private KeyStore keystore = null;
|
||||
private boolean modified = false;
|
||||
|
||||
/**
|
||||
* @param context context used for file path resolution
|
||||
* @throws RuntimeException if init data dir init fails
|
||||
*/
|
||||
public KeystoreManager(Context context) {
|
||||
Path path = context.getFilesDir().toPath().resolve("crypt");
|
||||
File dir = path.toFile();
|
||||
keystoreFile = path.resolve("keystore.jks").toFile();
|
||||
|
||||
if (!(dir.isDirectory() || dir.mkdirs()))
|
||||
throw new RuntimeException("cannot make crypt directory");
|
||||
}
|
||||
|
||||
public KeyManager[] getKeyManagers() throws CorruptedKeystoreException {
|
||||
try {
|
||||
KeyManagerFactory kmf = KeyManagerFactory.getInstance("X509");
|
||||
kmf.init(keystore, KEYSTORE_PASSWORD);
|
||||
|
||||
return kmf.getKeyManagers();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new UnsupportedOperationException("keypair algorithm not supported", e);
|
||||
} catch (UnrecoverableKeyException e) {
|
||||
throw new CorruptedKeystoreException("keystore password rejected", e);
|
||||
} catch (KeyStoreException e) {
|
||||
throw new RuntimeException("failed to make a key manager", e);
|
||||
}
|
||||
}
|
||||
|
||||
public TrustManager[] getTrustManagers() {
|
||||
try {
|
||||
TrustManagerFactory tmf = TrustManagerFactory.getInstance("X509");
|
||||
tmf.init(keystore);
|
||||
|
||||
return tmf.getTrustManagers();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new UnsupportedOperationException("keypair algorithm not supported", e);
|
||||
} catch (KeyStoreException e) {
|
||||
throw new RuntimeException("failed to make a key manager", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private InputStream getKeystoreInputStream() throws IOException {
|
||||
try {
|
||||
return new FileInputStream(keystoreFile);
|
||||
} catch (FileNotFoundException e) {
|
||||
// apparently this can throw even if the file exists (if there's another error)
|
||||
if (keystoreFile.isFile()) throw new IOException("keystore file open failed", e);
|
||||
|
||||
Log.d(TAG, "keystore file not found");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private KeyStore getKeystoreInstance() {
|
||||
try {
|
||||
return KeyStore.getInstance(KEYSTORE_TYPE);
|
||||
} catch (KeyStoreException e) {
|
||||
if (KEYSTORE_TYPE.equals(KEYSTORE_TYPE_FALLBACK)) {
|
||||
Log.wtf(TAG, "failed to create keystore", e);
|
||||
throw new UnsupportedOperationException("Cannot create keystore of type " + KEYSTORE_TYPE);
|
||||
}
|
||||
|
||||
Log.d(TAG, "failed to create keystore instance for " + KEYSTORE_TYPE + ", falling back to " + KEYSTORE_TYPE_FALLBACK, e);
|
||||
try {
|
||||
return KeyStore.getInstance(KEYSTORE_TYPE_FALLBACK);
|
||||
} catch (KeyStoreException ex) {
|
||||
Log.wtf(TAG, "couldn't create fallback keystore type either");
|
||||
throw new UnsupportedOperationException("Cannot create keystore of type " + KEYSTORE_TYPE + " or " + KEYSTORE_TYPE_FALLBACK, ex);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* loads keystore from the filesystem or creates a new one if it doesn't exist
|
||||
* @throws CorruptedKeystoreException if the keystore is corrupted
|
||||
* @throws IOException if either there is an io error or the keystore is corrupted
|
||||
* @throws UnsupportedOperationException if no supported algorithm is present
|
||||
*/
|
||||
public void loadKeystore() throws CorruptedKeystoreException, IOException {
|
||||
KeyStore ks = getKeystoreInstance();
|
||||
|
||||
try (InputStream is = getKeystoreInputStream()) {
|
||||
if (is == null)
|
||||
Log.i(TAG, "no keystore file, an empty one will be created");
|
||||
|
||||
ks.load(is, KEYSTORE_PASSWORD);
|
||||
|
||||
// reset modified state (empty keystore creation always counts)
|
||||
modified = is == null;
|
||||
|
||||
} catch (IOException e) { // io error, corrupted, or bad password
|
||||
if (e.getCause() instanceof UnrecoverableKeyException) {
|
||||
Log.wtf(TAG, "Keystore.load() reports that the keystore password is invalid. It's probably corrupted", e);
|
||||
throw new CorruptedKeystoreException("keystore password not working, likely corrupted", e);
|
||||
}
|
||||
|
||||
// the keystore could be corrupted, or there's an io error
|
||||
throw e;
|
||||
|
||||
} catch (CertificateException e) { // a certificate couldn't be loaded
|
||||
throw new CorruptedKeystoreException("unable to load keystore due to a corrupted entry", e);
|
||||
} catch (NoSuchAlgorithmException e) { // no algorithm to check integrity (unsupported?)
|
||||
throw new UnsupportedOperationException("unable to load keystore because there's no matching integrity checking algorithm", e);
|
||||
}
|
||||
|
||||
keystore = ks;
|
||||
}
|
||||
|
||||
public boolean deleteKeystore() {
|
||||
if (keystore != null) throw new IllegalStateException("keystore already loaded, refusing to delete");
|
||||
|
||||
return !keystoreFile.isFile() || keystoreFile.delete();
|
||||
}
|
||||
|
||||
private KeyPair generateKeypair() {
|
||||
try {
|
||||
Log.d(TAG, "generating a " + KEY_SIZE + "-bit " + KEYPAIR_ALGORITHM + " key");
|
||||
KeyPairGenerator keygen = KeyPairGenerator.getInstance(KEYPAIR_ALGORITHM);
|
||||
keygen.initialize(KEY_SIZE);
|
||||
return keygen.genKeyPair();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new UnsupportedOperationException("no sufficient keypair algorithm found", e);
|
||||
}
|
||||
}
|
||||
|
||||
private Certificate signKeypair(KeyPair keypair) {
|
||||
try {
|
||||
Log.d(TAG, "self-signing keypair with X509");
|
||||
X509V3CertificateGenerator certgen = new X509V3CertificateGenerator();
|
||||
X509Name commonName = new X509Name("CN=" + CERTIFICATE_COMMON_NAME);
|
||||
certgen.setIssuerDN(commonName);
|
||||
certgen.setSubjectDN(commonName);
|
||||
certgen.setPublicKey(keypair.getPublic());
|
||||
certgen.setSerialNumber(BigInteger.valueOf(42069));
|
||||
certgen.setNotBefore(Date.from(Instant.ofEpochSecond(0))); // the date might be incorrectly set on first boot
|
||||
certgen.setNotAfter(Date.from(Instant.ofEpochSecond(99999999999L))); // should hold us off until the far-off year of 5138
|
||||
certgen.setSignatureAlgorithm(SIGNING_ALGORITHM);
|
||||
return certgen.generate(keypair.getPrivate());
|
||||
} catch (CertificateEncodingException e) {
|
||||
throw new RuntimeException("failed to encode certificate", e);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new UnsupportedOperationException("no suitable signing algorithm found", e);
|
||||
} catch (SignatureException e) {
|
||||
throw new RuntimeException("failed to sign keypair", e);
|
||||
} catch (InvalidKeyException e) {
|
||||
throw new UnsupportedOperationException("bouncycastle rejected keys", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* determine if there is an existing keypair at the given alias.
|
||||
* if this method returns true, it is safe to skip initKeypair() for this alias
|
||||
* @param alias the alias of the keypair
|
||||
* @return true if it exists in the keystore, false otherwise
|
||||
*/
|
||||
public boolean hasKeypair(String alias) {
|
||||
if (keystore == null) throw new IllegalStateException("keystore must be loaded first");
|
||||
try {
|
||||
return keystore.containsAlias(alias);
|
||||
} catch (KeyStoreException e) {
|
||||
throw new IllegalStateException("keystore not initialized?", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* generates a keypair for the alias if one does not exist, otherwise returns immediately
|
||||
* @param alias alias for the keypair
|
||||
* @throws UnsupportedOperationException if the device lacks required cryptography features
|
||||
* @throws RuntimeException if something unexpected happens
|
||||
*/
|
||||
public void initKeypair(String alias) {
|
||||
if (keystore == null) throw new IllegalStateException("keystore must be loaded first");
|
||||
|
||||
try {
|
||||
if (hasKeypair(alias)) return; // for now assume any existing key is sufficient
|
||||
|
||||
KeyPair keypair = generateKeypair();
|
||||
Certificate cert = signKeypair(keypair);
|
||||
|
||||
keystore.setKeyEntry(alias, keypair.getPrivate(), KEYSTORE_PASSWORD, new Certificate[] {cert});
|
||||
modified = true;
|
||||
} catch (KeyStoreException e) {
|
||||
throw new RuntimeException("failed to store self-signed keypair", e);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* gets the stored certificate for the keypair alias
|
||||
* @param alias the alias
|
||||
* @return the certificate, or null if there is none
|
||||
*/
|
||||
public Certificate getCertificate(String alias) {
|
||||
if (keystore == null) throw new IllegalStateException("keystore must be loaded first");
|
||||
|
||||
try {
|
||||
Log.d(TAG, "loading certificate: " + alias);
|
||||
Certificate cert = keystore.getCertificate(alias);
|
||||
if (cert == null) Log.e(TAG, "no certificate at alias");
|
||||
return cert;
|
||||
} catch (KeyStoreException e) {
|
||||
throw new IllegalStateException("keystore not initialized?", e);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* returns the stored private key at the specified alias
|
||||
* @param alias the alias
|
||||
* @return the private key, or null if there is none at that alias
|
||||
* @throws CorruptedKeystoreException if the keystore is corrupted
|
||||
* @throws UnsupportedOperationException if the key recovery algorithm is missing
|
||||
*/
|
||||
public PrivateKey getPrivateKey(String alias) throws CorruptedKeystoreException {
|
||||
if (keystore == null) throw new IllegalStateException("keystore must be loaded first");
|
||||
|
||||
try {
|
||||
Log.d(TAG, "loading private key: " + alias);
|
||||
|
||||
Key key = keystore.getKey(alias, KEYSTORE_PASSWORD);
|
||||
if (key == null) {
|
||||
Log.e(TAG, "no key at alias");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (key instanceof PrivateKey) {
|
||||
return (PrivateKey) key;
|
||||
} else {
|
||||
Log.e(TAG, "key at alias is not a private key");
|
||||
return null;
|
||||
}
|
||||
|
||||
} catch (KeyStoreException e) {
|
||||
throw new IllegalStateException("keystore not initialized?", e);
|
||||
} catch (UnrecoverableKeyException e) {
|
||||
Log.wtf(TAG, "Keystore.getKey() reports that the key password is invalid. It's probably corrupted", e);
|
||||
throw new CorruptedKeystoreException("keystore password not working, likely corrupted", e);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new UnsupportedOperationException("unable to read the key because the required algorithm is not supported", e);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* determine whether the keystore has been modified and needs to be saved
|
||||
* @return true if modifications have been made, false otherwise
|
||||
*/
|
||||
public boolean isModified() {
|
||||
return modified;
|
||||
}
|
||||
|
||||
/**
|
||||
* writes the keystore to storage
|
||||
* @throws IOException if there was an io error writing the keystore
|
||||
* @throws CorruptedKeystoreException if the keystore is corrupted
|
||||
* @throws UnsupportedOperationException if required integrity checking algorithms are missing
|
||||
*/
|
||||
public void saveKeystore() throws IOException, CorruptedKeystoreException {
|
||||
if (keystore == null) throw new IllegalStateException("no currently loaded keystore to save");
|
||||
if (!modified) {
|
||||
Log.d(TAG, "not saving keystore because it wasn't modified");
|
||||
return;
|
||||
}
|
||||
|
||||
if (keystoreFile.isFile()) Log.v(TAG, "overwriting existing keystore at: " + keystoreFile);
|
||||
else Log.v(TAG, "saving keystore as: " + keystoreFile);
|
||||
|
||||
try (FileOutputStream os = new FileOutputStream(keystoreFile)) {
|
||||
|
||||
keystore.store(os, KEYSTORE_PASSWORD);
|
||||
modified = false;
|
||||
|
||||
} catch (FileNotFoundException e) {
|
||||
Log.e(TAG, "cannot open keystore file for writing", e);
|
||||
throw e;
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "failed to write keystore", e);
|
||||
throw e;
|
||||
} catch (CertificateException e) {
|
||||
Log.wtf(TAG, "failed to store a certificate within the keystore", e);
|
||||
throw new CorruptedKeystoreException("failed to store certificate", e);
|
||||
} catch (KeyStoreException e) {
|
||||
Log.wtf(TAG, "keystore was never loaded", e);
|
||||
throw new IllegalStateException("keystore was never loaded?", e);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
Log.e(TAG, "algorithm for verifying keystore integrity not supported", e);
|
||||
throw new UnsupportedOperationException("unable to save keystore because there's no matching integrity checking algorithm", e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package io.benwiegand.projection.geargrinder.crypto;
|
||||
|
||||
import static javax.net.ssl.SSLEngineResult.HandshakeStatus.NOT_HANDSHAKING;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Arrays;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
import javax.net.ssl.KeyManager;
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.SSLEngine;
|
||||
import javax.net.ssl.SSLEngineResult;
|
||||
import javax.net.ssl.SSLException;
|
||||
import javax.net.ssl.SSLSession;
|
||||
import javax.net.ssl.TrustManager;
|
||||
|
||||
public class TLSService {
|
||||
private static final String TAG = TLSService.class.getSimpleName();
|
||||
|
||||
private final SSLEngine sslEngine;
|
||||
private final SSLSession sslSession;
|
||||
|
||||
private final ByteBuffer appRxBuffer;
|
||||
private final ByteBuffer appTxBuffer;
|
||||
private final ByteBuffer devRxBuffer;
|
||||
private final ByteBuffer devTxBuffer;
|
||||
|
||||
private static final Object handshakeLock = new Object();
|
||||
|
||||
private boolean handshakeComplete = false;
|
||||
|
||||
|
||||
public TLSService(TrustManager[] trustManagers, KeyManager[] keyManagers) {
|
||||
try {
|
||||
SSLContext sslContext = SSLContext.getInstance("TLS");
|
||||
Log.i(TAG, "ssl provider: " + sslContext.getProvider());
|
||||
|
||||
sslContext.init(
|
||||
keyManagers,
|
||||
trustManagers,
|
||||
SecureRandom.getInstanceStrong()
|
||||
);
|
||||
|
||||
sslEngine = sslContext.createSSLEngine();
|
||||
sslEngine.setUseClientMode(false); // phone is the server, headunit is the client
|
||||
sslEngine.setEnabledProtocols(new String[]{"TLSv1.2",}); // TLSv1.3 does not work, nor does TLSv1.1
|
||||
|
||||
Log.d(TAG, "default cipher suites: " + Arrays.toString(sslEngine.getEnabledCipherSuites()));
|
||||
Log.d(TAG, "all cipher suites: " + Arrays.toString(sslEngine.getSupportedCipherSuites()));
|
||||
|
||||
// TODO: if wireless is ever implemented, this needs to be removed since it is insecure.
|
||||
// and also the certificate needs to be validated too.
|
||||
sslEngine.setEnabledCipherSuites(sslEngine.getSupportedCipherSuites());
|
||||
|
||||
sslSession = sslEngine.getSession();
|
||||
|
||||
appRxBuffer = ByteBuffer.allocateDirect(sslSession.getApplicationBufferSize());
|
||||
appTxBuffer = ByteBuffer.allocateDirect(sslSession.getApplicationBufferSize());
|
||||
devRxBuffer = ByteBuffer.allocateDirect(sslSession.getPacketBufferSize());
|
||||
devTxBuffer = ByteBuffer.allocateDirect(sslSession.getPacketBufferSize());
|
||||
|
||||
Log.d(TAG, "engine class: " + sslEngine.getClass().getName());
|
||||
Log.d(TAG, "max ciphertext: " + sslSession.getPacketBufferSize());
|
||||
Log.d(TAG, "max plaintext: " + sslSession.getApplicationBufferSize());
|
||||
|
||||
} catch (Throwable t) {
|
||||
Log.e(TAG, "fatal exception during SSL init", t);
|
||||
throw new RuntimeException(t);
|
||||
}
|
||||
}
|
||||
|
||||
public int getMaxPlaintextSize(int maxCiphertextSize) {
|
||||
if (maxCiphertextSize > sslSession.getPacketBufferSize())
|
||||
return sslSession.getApplicationBufferSize();
|
||||
|
||||
int maxOverhead = sslSession.getPacketBufferSize() - sslSession.getApplicationBufferSize();
|
||||
if (maxOverhead > maxCiphertextSize)
|
||||
return 0;
|
||||
|
||||
return maxCiphertextSize - maxOverhead;
|
||||
}
|
||||
|
||||
public boolean needsHandshake() {
|
||||
return !handshakeComplete;
|
||||
}
|
||||
|
||||
public <T> T encrypt(byte[] input, int inOffset, int inLength, Function<ByteBuffer, T> outBufferConsumer) throws IOException {
|
||||
if (!handshakeComplete) throw new IllegalStateException("handshake must be completed before encrypting data");
|
||||
|
||||
appTxBuffer.clear();
|
||||
devTxBuffer.clear();
|
||||
|
||||
appTxBuffer.put(input, inOffset, inLength);
|
||||
appTxBuffer.flip();
|
||||
|
||||
SSLEngineResult result = sslEngine.wrap(appTxBuffer, devTxBuffer);
|
||||
|
||||
if (result.getStatus() == SSLEngineResult.Status.CLOSED) {
|
||||
Log.e(TAG, "ssl engine closed");
|
||||
throw new IOException("ssl/tls session closed");
|
||||
} else if (result.getStatus() != SSLEngineResult.Status.OK) {
|
||||
// overflow is an error, input should not exceed expected size
|
||||
Log.e(TAG, "ssl engine result status = " + result.getStatus());
|
||||
throw new IOException("unexpected ssl engine result " + result.getStatus());
|
||||
}
|
||||
|
||||
assert !appTxBuffer.hasRemaining();
|
||||
|
||||
devTxBuffer.flip();
|
||||
return outBufferConsumer.apply(devTxBuffer);
|
||||
}
|
||||
|
||||
public <T> T decrypt(byte[] input, int inOffset, int inLength, Function<ByteBuffer, T> outBufferConsumer) throws IOException {
|
||||
if (!handshakeComplete) throw new IllegalStateException("handshake must be completed before decrypting data");
|
||||
|
||||
appRxBuffer.clear();
|
||||
devRxBuffer.clear();
|
||||
|
||||
devRxBuffer.put(input, inOffset, inLength);
|
||||
devRxBuffer.flip();
|
||||
|
||||
while (devRxBuffer.hasRemaining()) {
|
||||
SSLEngineResult result = sslEngine.unwrap(devRxBuffer, appRxBuffer);
|
||||
|
||||
if (result.getStatus() == SSLEngineResult.Status.CLOSED) {
|
||||
Log.e(TAG, "ssl engine closed");
|
||||
throw new IOException("ssl/tls session closed");
|
||||
} else if (result.getStatus() != SSLEngineResult.Status.OK) {
|
||||
// headunits likely don't have more than 16 KiB of data to send anyway
|
||||
Log.e(TAG, "ssl engine result status = " + result.getStatus());
|
||||
throw new IOException("unexpected ssl engine result " + result.getStatus());
|
||||
}
|
||||
}
|
||||
|
||||
appRxBuffer.flip();
|
||||
return outBufferConsumer.apply(appRxBuffer);
|
||||
}
|
||||
|
||||
public void doHandshake(byte[] inBuffer, int offset, int length, Consumer<ByteBuffer> bufferSender) throws SSLException {
|
||||
synchronized (handshakeLock) {
|
||||
if (handshakeComplete) {
|
||||
Log.wtf(TAG, "ignoring doHandshake(): handshake already completed");
|
||||
return;
|
||||
}
|
||||
|
||||
if (inBuffer != null) {
|
||||
devRxBuffer.put(inBuffer, offset, length);
|
||||
devRxBuffer.flip();
|
||||
}
|
||||
|
||||
if (sslEngine.getHandshakeStatus() == NOT_HANDSHAKING) {
|
||||
Log.i(TAG, "starting handshake");
|
||||
sslEngine.beginHandshake();
|
||||
}
|
||||
|
||||
while (true) { // until it succeeds or explodes
|
||||
SSLEngineResult.HandshakeStatus hs = sslEngine.getHandshakeStatus();
|
||||
Log.v(TAG, "handshake status: " + hs.name());
|
||||
switch (hs) {
|
||||
case NOT_HANDSHAKING -> {
|
||||
Log.i(TAG, "handshake complete");
|
||||
Log.d(TAG, "cipher suite: " + sslSession.getCipherSuite());
|
||||
handshakeComplete = true;
|
||||
return;
|
||||
}
|
||||
case NEED_WRAP -> {
|
||||
devTxBuffer.clear();
|
||||
sslEngine.wrap(ByteBuffer.allocate(0), devTxBuffer);
|
||||
devTxBuffer.flip();
|
||||
Log.d(TAG, "handshake out: " + devTxBuffer.remaining() + " bytes");
|
||||
bufferSender.accept(devTxBuffer);
|
||||
assert devTxBuffer.remaining() == 0;
|
||||
}
|
||||
case NEED_UNWRAP -> {
|
||||
if (inBuffer == null) {
|
||||
Log.d(TAG, "doHandshake() terminating: need more data");
|
||||
return;
|
||||
}
|
||||
inBuffer = null;
|
||||
|
||||
while (sslEngine.getHandshakeStatus() == SSLEngineResult.HandshakeStatus.NEED_UNWRAP) {
|
||||
sslEngine.unwrap(devRxBuffer, appRxBuffer);
|
||||
}
|
||||
|
||||
devRxBuffer.compact();
|
||||
}
|
||||
case NEED_TASK -> {
|
||||
Runnable task;
|
||||
while ((task = sslEngine.getDelegatedTask()) != null)
|
||||
task.run();
|
||||
}
|
||||
default -> throw new AssertionError("unknown handshake status: " + hs);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package io.benwiegand.projection.geargrinder.logs;
|
||||
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Typeface;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.TypedValue;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
public class LogUiAdapter extends RecyclerView.Adapter<LogUiAdapter.LineViewHolder> implements LogcatReader.UiLogListener {
|
||||
public static final int HISTORY_SIZE = 2000; // lines
|
||||
|
||||
private record Line(int color, String text) {}
|
||||
|
||||
private final Handler handler = new Handler(Looper.getMainLooper());
|
||||
|
||||
private final Line[] lineBuffer = new Line[HISTORY_SIZE];
|
||||
private int lineIndex = 0;
|
||||
private int lineNumber = 0;
|
||||
|
||||
public LogUiAdapter() {
|
||||
Arrays.fill(lineBuffer, new Line(Color.WHITE, "---"));
|
||||
}
|
||||
|
||||
private Line getLine(int lineNumber) {
|
||||
synchronized (lineBuffer) {
|
||||
int reverseIndex = this.lineNumber - lineNumber - 1;
|
||||
int index = lineIndex - reverseIndex - 1;
|
||||
if (index < 0)
|
||||
index = lineBuffer.length + index;
|
||||
|
||||
if (index < 0) return new Line(Color.GRAY, "~");
|
||||
|
||||
return lineBuffer[index];
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLog(String level, String text) {
|
||||
int color = switch (level) {
|
||||
case "F", "E" -> Color.RED;
|
||||
case "W" -> Color.YELLOW;
|
||||
case "I" -> Color.CYAN;
|
||||
case "V" -> Color.GREEN;
|
||||
case "D" -> Color.MAGENTA;
|
||||
case null, default -> Color.WHITE;
|
||||
};
|
||||
|
||||
Line line = new Line(color, text);
|
||||
|
||||
handler.post(() -> {
|
||||
synchronized (lineBuffer) {
|
||||
lineBuffer[lineIndex++] = line;
|
||||
if (lineIndex >= HISTORY_SIZE) lineIndex = 0;
|
||||
int thisLineNumber = lineNumber++;
|
||||
|
||||
notifyItemInserted(thisLineNumber);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public LineViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||
TextView lineView = new TextView(parent.getContext());
|
||||
lineView.setTypeface(Typeface.MONOSPACE);
|
||||
lineView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 7);
|
||||
return new LineViewHolder(lineView);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull LineViewHolder holder, int position) {
|
||||
TextView lineView = holder.getLineView();
|
||||
Line line = getLine(position);
|
||||
|
||||
lineView.setText(line.text());
|
||||
lineView.setTextColor(line.color());
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return lineNumber;
|
||||
}
|
||||
|
||||
public static class LineViewHolder extends RecyclerView.ViewHolder {
|
||||
private final TextView lineView;
|
||||
|
||||
public LineViewHolder(@NonNull TextView lineView) {
|
||||
super(lineView);
|
||||
this.lineView = lineView;
|
||||
}
|
||||
|
||||
public TextView getLineView() {
|
||||
return lineView;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package io.benwiegand.projection.geargrinder.logs;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileWriter;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.Writer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class LogcatReader {
|
||||
private static final String TAG = LogcatReader.class.getSimpleName();
|
||||
|
||||
private static final int LOGCAT_UI_FIXED_TAG_LENGTH = 15;
|
||||
|
||||
private static final Pattern LOGCAT_REGEX = Pattern.compile("^(?<date>[0-9]+-[0-9]+) +(?<time>[0-9]+:[0-9]+:[0-9]+.[0-9]+) +(?<pid>[0-9]+) +(?<tid>[0-9]+) +(?<level>[FEWIVD]) +(?<tag>[^:]+): ?(?<msg>.*)$");
|
||||
|
||||
public interface UiLogListener {
|
||||
void onLog(String level, String text);
|
||||
}
|
||||
|
||||
private final Thread readThread = new Thread(this::readLoop);
|
||||
private boolean alive = true;
|
||||
|
||||
private final UiLogListener uiListener;
|
||||
private Recording activeRecording = null;
|
||||
|
||||
public LogcatReader(UiLogListener uiListener) {
|
||||
this.uiListener = uiListener;
|
||||
}
|
||||
|
||||
public void start() {
|
||||
readThread.start();
|
||||
}
|
||||
|
||||
public void destroy() {
|
||||
alive = false;
|
||||
stopRecording();
|
||||
}
|
||||
|
||||
private void onLine(String rawText) {
|
||||
|
||||
Recording recording = activeRecording;
|
||||
if (recording != null) {
|
||||
recording.onLine(rawText);
|
||||
}
|
||||
|
||||
Matcher m = LOGCAT_REGEX.matcher(rawText);
|
||||
if (m.matches()) {
|
||||
String time = m.group("time");
|
||||
String level = m.group("level");
|
||||
String tag = m.group("tag");
|
||||
String msg = m.group("msg");
|
||||
|
||||
if (tag != null) {
|
||||
if (tag.length() > LOGCAT_UI_FIXED_TAG_LENGTH)
|
||||
tag = tag.substring(0, LOGCAT_UI_FIXED_TAG_LENGTH);
|
||||
|
||||
StringBuilder tagBuilder = new StringBuilder(tag);
|
||||
while (tagBuilder.length() < LOGCAT_UI_FIXED_TAG_LENGTH)
|
||||
tagBuilder.insert(0, " ");
|
||||
tag = String.valueOf(tagBuilder);
|
||||
}
|
||||
|
||||
uiListener.onLog(level, time + " " + tag + " " + level + ": " + msg);
|
||||
} else {
|
||||
uiListener.onLog(null, rawText);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private void readLoop() {
|
||||
char[] buffer = new char[65535];
|
||||
int len, ret, sep;
|
||||
Process p = null;
|
||||
try {
|
||||
p = new ProcessBuilder("logcat", "-D", "-T", "1000")
|
||||
.start();
|
||||
|
||||
InputStreamReader reader = new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8);
|
||||
len = 0;
|
||||
while (alive) {
|
||||
ret = reader.read(buffer, len, buffer.length - len);
|
||||
if (ret < 0) break;
|
||||
len += ret;
|
||||
|
||||
sep = 0;
|
||||
for (int i = 0; i < len; i++) {
|
||||
if (buffer[i] == '\n') {
|
||||
String line = new String(buffer, sep, i - sep);
|
||||
onLine(line);
|
||||
sep = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (sep == len) {
|
||||
len = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
// partial line
|
||||
System.arraycopy(buffer, sep, buffer, 0, len - sep);
|
||||
len -= sep;
|
||||
}
|
||||
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "IOException in read loop", e);
|
||||
} finally {
|
||||
if (p != null && p.isAlive())
|
||||
p.destroyForcibly();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static class Recording {
|
||||
private final Writer writer;
|
||||
private Throwable error = null;
|
||||
private Recording(Writer writer) {
|
||||
this.writer = writer;
|
||||
}
|
||||
|
||||
private void onLine(String line) {
|
||||
if (error != null) return;
|
||||
|
||||
try {
|
||||
writer.write(line + "\n");
|
||||
writer.flush();
|
||||
} catch (Throwable t) {
|
||||
Log.e(TAG, "failed to write line", t);
|
||||
error = t;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
public void startRecording(File file) throws IOException {
|
||||
if (activeRecording != null) throw new IllegalStateException("recording already active");
|
||||
activeRecording = new Recording(new FileWriter(file));
|
||||
}
|
||||
|
||||
public boolean isRecording() {
|
||||
return activeRecording != null;
|
||||
}
|
||||
|
||||
public Throwable stopRecording() {
|
||||
if (activeRecording == null) return null;
|
||||
Throwable error = activeRecording.error;
|
||||
if (error != null) {
|
||||
try {
|
||||
activeRecording.writer.flush();
|
||||
activeRecording.writer.close();
|
||||
} catch (IOException e) {
|
||||
error = e;
|
||||
}
|
||||
}
|
||||
activeRecording = null;
|
||||
return error;
|
||||
}
|
||||
|
||||
public void addMarker() {
|
||||
for (int i = 0; i < 10; i++) {
|
||||
onLine("========================================================");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package io.benwiegand.projection.geargrinder.makeshiftbind;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.os.Bundle;
|
||||
import android.os.IBinder;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
|
||||
|
||||
import java.util.LinkedList;
|
||||
import java.util.Queue;
|
||||
|
||||
// TODO: LocalBroadcastManager is deprecated
|
||||
/**
|
||||
* a recreation of the binder api for services that don't support the binder api.
|
||||
* it excludes some features that aren't used in this app.
|
||||
*/
|
||||
public class MakeshiftBind {
|
||||
private static final String TAG = MakeshiftBind.class.getSimpleName();
|
||||
|
||||
public static final String EXTRA_BINDER_INSTANCE = "binder";
|
||||
public static final String EXTRA_CONNECTION_ID = "id";
|
||||
public static final String EXTRA_COMPONENT_NAME = "component";
|
||||
|
||||
private final BroadcastReceiver bindRequestReceiver = new BindRequestReceiver();
|
||||
private final Queue<String> connectionIds = new LinkedList<>();
|
||||
private final Context context;
|
||||
private final ComponentName component;
|
||||
private final MakeshiftBindCallback bindCallback;
|
||||
|
||||
// service-side. attempts to mirror the general behavior of the normal onBind()
|
||||
|
||||
public MakeshiftBind(Context context, ComponentName component, MakeshiftBindCallback bindCallback) {
|
||||
this.context = context;
|
||||
this.component = component;
|
||||
this.bindCallback = bindCallback;
|
||||
|
||||
IntentFilter filter = new IntentFilter();
|
||||
filter.addAction(createBinderRequestIntentAction(component));
|
||||
LocalBroadcastManager
|
||||
.getInstance(context)
|
||||
.registerReceiver(bindRequestReceiver, filter);
|
||||
|
||||
sendReadyIntent();
|
||||
}
|
||||
|
||||
public void destroy() {
|
||||
LocalBroadcastManager
|
||||
.getInstance(context)
|
||||
.unregisterReceiver(bindRequestReceiver);
|
||||
|
||||
while (!connectionIds.isEmpty()) {
|
||||
sendDeathIntent(connectionIds.poll());
|
||||
}
|
||||
}
|
||||
|
||||
private void sendReadyIntent() {
|
||||
Log.d(TAG, "sending makeshift bind ready intent");
|
||||
Intent intent = new Intent(createServiceUpIntentAction(component));
|
||||
Bundle extras = new Bundle();
|
||||
extras.putString(EXTRA_COMPONENT_NAME, component.flattenToString());
|
||||
intent.putExtras(extras);
|
||||
|
||||
LocalBroadcastManager
|
||||
.getInstance(context)
|
||||
.sendBroadcast(intent);
|
||||
}
|
||||
|
||||
public void sendResponseIntent(String connectionId, IBinder binder) {
|
||||
Log.d(TAG, "sending makeshift bind response intent");
|
||||
Intent intent = new Intent(createBinderResponseIntentAction(component));
|
||||
Bundle extras = new Bundle();
|
||||
extras.putString(EXTRA_COMPONENT_NAME, component.flattenToString());
|
||||
extras.putBinder(EXTRA_BINDER_INSTANCE, binder);
|
||||
extras.putString(EXTRA_CONNECTION_ID, connectionId);
|
||||
intent.putExtras(extras);
|
||||
|
||||
LocalBroadcastManager
|
||||
.getInstance(context)
|
||||
.sendBroadcast(intent);
|
||||
}
|
||||
|
||||
public void sendDeathIntent(String connectionId) {
|
||||
Log.d(TAG, "sending makeshift bind death intent");
|
||||
Intent intent = new Intent(createServiceDownIntentAction(component));
|
||||
Bundle extras = new Bundle();
|
||||
extras.putString(EXTRA_COMPONENT_NAME, component.flattenToString());
|
||||
extras.putString(EXTRA_CONNECTION_ID, connectionId);
|
||||
intent.putExtras(extras);
|
||||
|
||||
LocalBroadcastManager
|
||||
.getInstance(context)
|
||||
.sendBroadcast(intent);
|
||||
}
|
||||
|
||||
public class BindRequestReceiver extends BroadcastReceiver {
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
String connectionId = intent.getStringExtra(EXTRA_CONNECTION_ID);
|
||||
Log.d(TAG, "got binder request: " + connectionId);
|
||||
IBinder binder = bindCallback.onMakeshiftBind(intent);
|
||||
connectionIds.add(connectionId);
|
||||
sendResponseIntent(connectionId, binder);
|
||||
}
|
||||
}
|
||||
|
||||
static String createServiceUpIntentAction(ComponentName component) {
|
||||
return component.getPackageName() + component.getClassName() + ".MAKESHIFT_BIND_READY";
|
||||
}
|
||||
|
||||
static String createBinderRequestIntentAction(ComponentName component) {
|
||||
return component.getPackageName() + component.getClassName() + ".MAKESHIFT_BIND_REQUEST";
|
||||
}
|
||||
|
||||
static String createServiceDownIntentAction(ComponentName component) {
|
||||
return component.getPackageName() + component.getClassName() + ".MAKESHIFT_BINDER_DEATH";
|
||||
}
|
||||
|
||||
static String createBinderResponseIntentAction(ComponentName component) {
|
||||
return component.getPackageName() + component.getClassName() + ".MAKESHIFT_BINDER";
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package io.benwiegand.projection.geargrinder.makeshiftbind;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.IBinder;
|
||||
|
||||
public interface MakeshiftBindCallback {
|
||||
IBinder onMakeshiftBind(Intent intent);
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
package io.benwiegand.projection.geargrinder.makeshiftbind;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.content.ServiceConnection;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.IBinder;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* @see MakeshiftBind
|
||||
*/
|
||||
public abstract class MakeshiftServiceConnection implements ServiceConnection {
|
||||
private static final String TAG = MakeshiftServiceConnection.class.getSimpleName();
|
||||
|
||||
private final String connectionId = UUID.randomUUID().toString();
|
||||
private final BroadcastReceiver binderReceiver = new BinderReceiver();
|
||||
private final IntentFilter filter = new IntentFilter();
|
||||
private Context context = null;
|
||||
|
||||
protected MakeshiftServiceConnection() {}
|
||||
|
||||
@Override
|
||||
public abstract void onServiceConnected(ComponentName name, IBinder service);
|
||||
@Override
|
||||
public abstract void onServiceDisconnected(ComponentName name);
|
||||
|
||||
public void destroy() {
|
||||
if (context == null) return; // never used
|
||||
|
||||
LocalBroadcastManager
|
||||
.getInstance(context)
|
||||
.unregisterReceiver(binderReceiver);
|
||||
}
|
||||
|
||||
private void sendBindIntent(ComponentName component) {
|
||||
Log.d(TAG, "sending makeshift bind intent");
|
||||
Intent intent = new Intent(MakeshiftBind.createBinderRequestIntentAction(component));
|
||||
Bundle extras = new Bundle();
|
||||
extras.putString(MakeshiftBind.EXTRA_CONNECTION_ID, connectionId);
|
||||
intent.putExtras(extras);
|
||||
|
||||
LocalBroadcastManager
|
||||
.getInstance(context)
|
||||
.sendBroadcast(intent);
|
||||
}
|
||||
|
||||
private void bindFor(ComponentName componentName) {
|
||||
filter.addAction(MakeshiftBind.createServiceUpIntentAction(componentName));
|
||||
filter.addAction(MakeshiftBind.createServiceDownIntentAction(componentName));
|
||||
filter.addAction(MakeshiftBind.createBinderResponseIntentAction(componentName));
|
||||
|
||||
LocalBroadcastManager
|
||||
.getInstance(context)
|
||||
.registerReceiver(binderReceiver, filter);
|
||||
|
||||
sendBindIntent(componentName);
|
||||
}
|
||||
|
||||
private class BinderReceiver extends BroadcastReceiver {
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
// these intents should always have extras
|
||||
Bundle extras = intent.getExtras();
|
||||
assert extras != null;
|
||||
|
||||
// should always have component name
|
||||
String componentNameString = extras.getString(MakeshiftBind.EXTRA_COMPONENT_NAME);
|
||||
assert componentNameString != null;
|
||||
ComponentName component = ComponentName.unflattenFromString(componentNameString);
|
||||
assert component != null;
|
||||
|
||||
// the connection id must be checked if present
|
||||
String connectionId = extras.getString(MakeshiftBind.EXTRA_CONNECTION_ID);
|
||||
if (connectionId != null && !connectionId.equals(MakeshiftServiceConnection.this.connectionId)) return; // not for this connection
|
||||
|
||||
if (Objects.equals(intent.getAction(), MakeshiftBind.createBinderResponseIntentAction(component))) {
|
||||
// a binder has arrived
|
||||
assert connectionId != null;
|
||||
IBinder binder = extras.getBinder(MakeshiftBind.EXTRA_BINDER_INSTANCE);
|
||||
if (binder == null) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
onNullBinding(component);
|
||||
}
|
||||
} else {
|
||||
onServiceConnected(component, binder);
|
||||
}
|
||||
|
||||
} else if (Objects.equals(intent.getAction(), MakeshiftBind.createServiceUpIntentAction(component))) {
|
||||
// a service previously sought after is now up, so a bind request should be resent
|
||||
sendBindIntent(component);
|
||||
|
||||
} else if (Objects.equals(intent.getAction(), MakeshiftBind.createServiceDownIntentAction(component))) {
|
||||
// a bound service has died
|
||||
assert connectionId != null;
|
||||
onServiceDisconnected(component);
|
||||
onBindingDied(component);
|
||||
|
||||
} else {
|
||||
Log.wtf(TAG, "got unexpected intent for makeshift bind: " + intent);
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void bindService(Context context, ComponentName componentName, MakeshiftServiceConnection serviceConnection) {
|
||||
if (serviceConnection.context != null && serviceConnection.context != context)
|
||||
throw new IllegalArgumentException("a service connection cannot be used across multiple different contexts!");
|
||||
|
||||
serviceConnection.context = context;
|
||||
serviceConnection.bindFor(componentName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package io.benwiegand.projection.geargrinder.message;
|
||||
|
||||
import static io.benwiegand.projection.geargrinder.util.ByteUtil.readUInt16;
|
||||
import static io.benwiegand.projection.geargrinder.util.ByteUtil.readInt32;
|
||||
import static io.benwiegand.projection.geargrinder.util.ByteUtil.writeInt32;
|
||||
import static io.benwiegand.projection.geargrinder.util.ByteUtil.writeUInt16;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
public class AAFrame {
|
||||
public static final int CHANNEL_ID_OFFSET = 0;
|
||||
public static final int FLAGS_OFFSET = 1;
|
||||
public static final int PAYLOAD_LENGTH_OFFSET = 2;
|
||||
public static final int TOTAL_MESSAGE_LENGTH_OFFSET = 4; // extended header only
|
||||
|
||||
public static final int HEADER_LENGTH = 4;
|
||||
public static final int EXTENDED_HEADER_LENGTH = 8;
|
||||
|
||||
public static final int COMMAND_ID_LENGTH = 2;
|
||||
|
||||
public static final int MAX_LENGTH = 0x4000; // 16 KiB
|
||||
public static final int PAYLOAD_MAX_LENGTH = MAX_LENGTH - HEADER_LENGTH;
|
||||
public static final int EXTENDED_PAYLOAD_MAX_LENGTH = MAX_LENGTH - EXTENDED_HEADER_LENGTH;
|
||||
|
||||
public static final int FLAG_SEQUENCE_FIRST = 1;
|
||||
public static final int FLAG_SEQUENCE_LAST = 1 << 1;
|
||||
public static final int FLAG_CONTROL = 1 << 2;
|
||||
public static final int FLAG_ENCRYPTED = 1 << 3;
|
||||
|
||||
public static final int SEQUENCE_FLAGS_MASK = FLAG_SEQUENCE_FIRST | FLAG_SEQUENCE_LAST;
|
||||
|
||||
private final byte[] buffer;
|
||||
|
||||
public AAFrame(byte[] buffer, int flags) {
|
||||
assert buffer.length >= HEADER_LENGTH;
|
||||
this.buffer = buffer;
|
||||
setFlags(flags);
|
||||
}
|
||||
|
||||
public byte[] getBuffer() {
|
||||
return buffer;
|
||||
}
|
||||
|
||||
public boolean isFirstInSequence() {
|
||||
return (getFlags() & SEQUENCE_FLAGS_MASK) == FLAG_SEQUENCE_FIRST;
|
||||
}
|
||||
|
||||
public int getLength() {
|
||||
return getPayloadOffset() + getPayloadLength();
|
||||
}
|
||||
|
||||
public int getPayloadOffset() {
|
||||
return isFirstInSequence() ? EXTENDED_HEADER_LENGTH : HEADER_LENGTH;
|
||||
}
|
||||
|
||||
public int getChannelId() {
|
||||
return buffer[CHANNEL_ID_OFFSET];
|
||||
}
|
||||
|
||||
public AAFrame setChannelId(int channelId) {
|
||||
assert channelId <= 0xff && channelId >= 0;
|
||||
buffer[CHANNEL_ID_OFFSET] = (byte) channelId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public int getFlags() {
|
||||
return buffer[FLAGS_OFFSET];
|
||||
}
|
||||
|
||||
public AAFrame setFlags(int flags) {
|
||||
assert flags <= 0xff && flags >= 0;
|
||||
buffer[FLAGS_OFFSET] = (byte) flags;
|
||||
return this;
|
||||
}
|
||||
|
||||
public int getPayloadLength() {
|
||||
return readUInt16(buffer, PAYLOAD_LENGTH_OFFSET);
|
||||
}
|
||||
|
||||
public AAFrame setPayloadLength(int payloadLength) {
|
||||
assert payloadLength <= 0xffff && payloadLength >= 0;
|
||||
assert buffer.length - getPayloadOffset() >= payloadLength;
|
||||
writeUInt16(payloadLength, buffer, PAYLOAD_LENGTH_OFFSET);
|
||||
return this;
|
||||
}
|
||||
|
||||
public int getTotalMessageLength() {
|
||||
if (!isFirstInSequence()) return getPayloadLength();
|
||||
return readInt32(getBuffer(), TOTAL_MESSAGE_LENGTH_OFFSET);
|
||||
}
|
||||
|
||||
public AAFrame setTotalMessageLength(int totalLength) {
|
||||
assert isFirstInSequence();
|
||||
writeInt32(totalLength, buffer, TOTAL_MESSAGE_LENGTH_OFFSET);
|
||||
return this;
|
||||
}
|
||||
|
||||
public AAFrame copyPayload(byte[] src, int offset, int length) {
|
||||
setPayloadLength(length);
|
||||
System.arraycopy(src, offset, buffer, getPayloadOffset(), length);
|
||||
return this;
|
||||
}
|
||||
|
||||
public AAFrame copyPayload(ByteBuffer src) {
|
||||
setPayloadLength(src.remaining());
|
||||
src.get(buffer, getPayloadOffset(), src.remaining());
|
||||
return this;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
package io.benwiegand.projection.geargrinder.message;
|
||||
|
||||
import static io.benwiegand.projection.geargrinder.message.AAFrame.COMMAND_ID_LENGTH;
|
||||
import static io.benwiegand.projection.geargrinder.message.AAFrame.EXTENDED_PAYLOAD_MAX_LENGTH;
|
||||
import static io.benwiegand.projection.geargrinder.message.AAFrame.FLAG_CONTROL;
|
||||
import static io.benwiegand.projection.geargrinder.message.AAFrame.FLAG_ENCRYPTED;
|
||||
import static io.benwiegand.projection.geargrinder.message.AAFrame.FLAG_SEQUENCE_FIRST;
|
||||
import static io.benwiegand.projection.geargrinder.message.AAFrame.FLAG_SEQUENCE_LAST;
|
||||
import static io.benwiegand.projection.geargrinder.message.AAFrame.HEADER_LENGTH;
|
||||
import static io.benwiegand.projection.geargrinder.message.AAFrame.PAYLOAD_MAX_LENGTH;
|
||||
import static io.benwiegand.projection.geargrinder.util.ByteUtil.readUInt16;
|
||||
import static io.benwiegand.projection.geargrinder.util.ByteUtil.writeUInt16;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.LinkedBlockingDeque;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import io.benwiegand.projection.geargrinder.callback.MessageListener;
|
||||
import io.benwiegand.projection.geargrinder.crypto.TLSService;
|
||||
import io.benwiegand.projection.geargrinder.transfer.AATransferInterface;
|
||||
import io.benwiegand.projection.geargrinder.util.ByteUtil;
|
||||
|
||||
public class MessageBroker {
|
||||
private static final String TAG = MessageBroker.class.getSimpleName();
|
||||
|
||||
private static final int THREAD_POOL_SIZE = 3;
|
||||
private static final long THREAD_POOL_KEEP_ALIVE = 100; // ms
|
||||
|
||||
/**
|
||||
* how many buffers to allocate for writing sequences.
|
||||
* each buffer uses {@link AAFrame#MAX_LENGTH} bytes.
|
||||
*/
|
||||
// private static final int WRITE_BUFFERS = 4;
|
||||
private static final int WRITE_BUFFERS = 8; // TODO
|
||||
|
||||
// logs payloads for debugging
|
||||
private static final boolean LOG_MESSAGE_DEBUG = false;
|
||||
|
||||
private final Object writeLock = new Object();
|
||||
|
||||
private final ThreadPoolExecutor threadPool = new ThreadPoolExecutor(THREAD_POOL_SIZE, THREAD_POOL_SIZE, THREAD_POOL_KEEP_ALIVE, TimeUnit.MILLISECONDS, new LinkedBlockingDeque<>());
|
||||
private final Map<Integer, MessageListener> messageHandlers = new HashMap<>();
|
||||
|
||||
private final byte[] readBuffer = new byte[AAFrame.MAX_LENGTH];
|
||||
private final byte[][] writeBuffers = new byte[WRITE_BUFFERS][AAFrame.MAX_LENGTH];
|
||||
|
||||
private final AATransferInterface transferInterface;
|
||||
private final TLSService tlsService;
|
||||
|
||||
public record MessageSendParameters(
|
||||
int channelId,
|
||||
boolean encrypted,
|
||||
boolean control
|
||||
) {
|
||||
private int getFlags() {
|
||||
return (encrypted() ? FLAG_ENCRYPTED : 0)
|
||||
| (control() ? FLAG_CONTROL : 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public MessageBroker(AATransferInterface transferInterface, TLSService tlsService) {
|
||||
this.transferInterface = transferInterface;
|
||||
this.tlsService = tlsService;
|
||||
}
|
||||
|
||||
public void destroy() {
|
||||
Log.i(TAG, "destroying message broker");
|
||||
closeConnection();
|
||||
messageHandlers.clear();
|
||||
threadPool.shutdown();
|
||||
}
|
||||
|
||||
|
||||
public void closeConnection() {
|
||||
if (!transferInterface.alive()) return;
|
||||
|
||||
try {
|
||||
transferInterface.close();
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "IOException while closing connection", e);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isAlive() {
|
||||
return transferInterface.alive();
|
||||
}
|
||||
|
||||
public int getMaxPayloadSize(boolean encrypted) {
|
||||
if (encrypted)
|
||||
return tlsService.getMaxPlaintextSize(EXTENDED_PAYLOAD_MAX_LENGTH) + tlsService.getMaxPlaintextSize(PAYLOAD_MAX_LENGTH) * (writeBuffers.length - 1);
|
||||
return EXTENDED_PAYLOAD_MAX_LENGTH + PAYLOAD_MAX_LENGTH * (writeBuffers.length - 1);
|
||||
}
|
||||
|
||||
private int calculateSequenceLength(int extendedPayloadMaxLength, int payloadMaxLength, int length) {
|
||||
if (length <= payloadMaxLength) return 1;
|
||||
int fullFrames = (length - extendedPayloadMaxLength) / payloadMaxLength + 1;
|
||||
return (length - extendedPayloadMaxLength) % payloadMaxLength != 0 ? fullFrames + 1 : fullFrames;
|
||||
}
|
||||
|
||||
/**
|
||||
* sends an AAFrame.
|
||||
* @param frame the frame to send
|
||||
*/
|
||||
private void sendFrame(AAFrame frame) {
|
||||
synchronized (writeLock) {
|
||||
try {
|
||||
transferInterface.sendFrame(frame.getBuffer(), 0, frame.getLength());
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "IOException while sending message", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* sends a full message with a payload
|
||||
* @param params the parameters to send the message with
|
||||
* @param payload a byte array containing the payload
|
||||
* @param offset the offset of the payload
|
||||
* @param length the length of the payload
|
||||
*/
|
||||
public void sendMessage(MessageSendParameters params, byte[] payload, int offset, int length) {
|
||||
synchronized (writeLock) {
|
||||
try {
|
||||
int extendedPayloadMaxLength = params.encrypted() ? tlsService.getMaxPlaintextSize(EXTENDED_PAYLOAD_MAX_LENGTH) : EXTENDED_PAYLOAD_MAX_LENGTH;
|
||||
int payloadMaxLength = params.encrypted() ? tlsService.getMaxPlaintextSize(PAYLOAD_MAX_LENGTH) : PAYLOAD_MAX_LENGTH;
|
||||
int sequenceLength = calculateSequenceLength(extendedPayloadMaxLength, payloadMaxLength, length);
|
||||
|
||||
if (sequenceLength > writeBuffers.length)
|
||||
throw new IndexOutOfBoundsException("not enough write buffers to send message of size " + length);
|
||||
|
||||
// single frame
|
||||
if (sequenceLength == 1) {
|
||||
int flags = params.getFlags() | FLAG_SEQUENCE_FIRST | FLAG_SEQUENCE_LAST;
|
||||
AAFrame frame = new AAFrame(writeBuffers[0], flags)
|
||||
.setChannelId(params.channelId());
|
||||
|
||||
if (params.encrypted()) {
|
||||
tlsService.encrypt(payload, offset, length, frame::copyPayload);
|
||||
} else {
|
||||
frame.copyPayload(payload, offset, length);
|
||||
}
|
||||
|
||||
sendFrame(frame);
|
||||
return;
|
||||
}
|
||||
|
||||
// split into sequence
|
||||
AAFrame[] frames = new AAFrame[sequenceLength];
|
||||
boolean firstInSequence, lastInSequence;
|
||||
int flags;
|
||||
int curOffset = offset;
|
||||
int curLength = Math.min(length, extendedPayloadMaxLength);
|
||||
for (int i = 0; i < sequenceLength; i++) {
|
||||
firstInSequence = curOffset == offset;
|
||||
lastInSequence = offset + length == curOffset + curLength;
|
||||
flags = params.getFlags()
|
||||
| (firstInSequence ? FLAG_SEQUENCE_FIRST : 0)
|
||||
| (lastInSequence ? FLAG_SEQUENCE_LAST : 0);
|
||||
|
||||
frames[i] = new AAFrame(writeBuffers[i], flags)
|
||||
.setChannelId(params.channelId());
|
||||
|
||||
if (params.encrypted()) {
|
||||
tlsService.encrypt(payload, curOffset, curLength, frames[i]::copyPayload);
|
||||
} else {
|
||||
frames[i].copyPayload(payload, curOffset, curLength);
|
||||
}
|
||||
|
||||
curOffset += curLength;
|
||||
curLength = Math.min(length - (curOffset - offset), payloadMaxLength);
|
||||
}
|
||||
|
||||
// first in sequence must have total length
|
||||
// but the total length is unknown if encrypted
|
||||
int totalLength = 0;
|
||||
for (AAFrame frame : frames)
|
||||
totalLength += frame.getPayloadLength();
|
||||
frames[0].setTotalMessageLength(totalLength);
|
||||
|
||||
for (AAFrame frame : frames)
|
||||
sendFrame(frame);
|
||||
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "IOException while sending message", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* like {@link MessageBroker#sendMessage(MessageSendParameters, byte[], int, int)} but uses the whole payload byte array
|
||||
*/
|
||||
public void sendMessage(MessageSendParameters params, byte[] payload) {
|
||||
sendMessage(params, payload, 0, payload.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* sends a full message with a payload constructed from the provided command and command data.
|
||||
* avoid this version of sendMessage() for frequently sent encrypted messages if possible to avoid the arraycopy used to construct the payload.
|
||||
* @param params the parameters to send message with
|
||||
* @param cmd the command (unsigned, 16 bits max)
|
||||
* @param cmdData the data following the command
|
||||
*/
|
||||
public void sendMessage(MessageSendParameters params, int cmd, byte[] cmdData) {
|
||||
assert cmd >= 0 && cmd <= 0xffff;
|
||||
synchronized (writeLock) {
|
||||
byte[] payload = new byte[COMMAND_ID_LENGTH + cmdData.length];
|
||||
writeUInt16(cmd, payload, 0);
|
||||
System.arraycopy(cmdData, 0, payload, COMMAND_ID_LENGTH, cmdData.length);
|
||||
sendMessage(params, payload);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* like {@link MessageBroker#sendMessage(MessageSendParameters, int cmd, byte[] cmdData)} but with no command data.
|
||||
*/
|
||||
public void sendMessage(MessageSendParameters params, int cmd) {
|
||||
assert cmd >= 0 && cmd <= 0xffff;
|
||||
synchronized (writeLock) {
|
||||
byte[] payload = new byte[COMMAND_ID_LENGTH];
|
||||
writeUInt16(cmd, payload, 0);
|
||||
sendMessage(params, payload);
|
||||
}
|
||||
}
|
||||
|
||||
public void registerForChannel(int channelId, MessageListener handler) {
|
||||
if (messageHandlers.containsKey(channelId)) throw new IllegalArgumentException("handler already registered for channel " + channelId);
|
||||
messageHandlers.put(channelId, handler);
|
||||
}
|
||||
|
||||
private void handleMessage(int length) throws IOException {
|
||||
|
||||
// TODO: handle message sequences. not a big deal as they are unlikely to occur since usually only the phone sends large amounts of data
|
||||
|
||||
if (length < HEADER_LENGTH) throw new RuntimeException("message too short");
|
||||
|
||||
// header
|
||||
int channelId = readBuffer[0];
|
||||
int flags = readBuffer[1];
|
||||
int payloadLength = readUInt16(readBuffer, 2);
|
||||
|
||||
if (payloadLength + HEADER_LENGTH < length) {
|
||||
Log.w(TAG, "message larger than payload");
|
||||
} else if (payloadLength + HEADER_LENGTH > length) {
|
||||
throw new AssertionError("message too small for payload size");
|
||||
}
|
||||
|
||||
// decrypt payload
|
||||
byte[] payload;
|
||||
if ((flags & FLAG_ENCRYPTED) != 0) {
|
||||
if (tlsService.needsHandshake()) {
|
||||
Log.wtf(TAG, "encrypted message before handshake?!");
|
||||
if (LOG_MESSAGE_DEBUG) Log.d(TAG, "RX raw: " + ByteUtil.hexDump(readBuffer, 0, length));
|
||||
throw new AssertionError("received encrypted message before ssl/tls handshake");
|
||||
}
|
||||
|
||||
payload = tlsService.decrypt(readBuffer, HEADER_LENGTH, payloadLength, out -> {
|
||||
byte[] decryptedPayload = new byte[out.remaining()];
|
||||
out.get(decryptedPayload);
|
||||
return decryptedPayload;
|
||||
});
|
||||
} else {
|
||||
// not encrypted
|
||||
payload = new byte[payloadLength];
|
||||
System.arraycopy(readBuffer, HEADER_LENGTH, payload, 0, payloadLength);
|
||||
}
|
||||
|
||||
if (LOG_MESSAGE_DEBUG) Log.d(TAG, "RX payload: " + ByteUtil.hexDump(payload));
|
||||
|
||||
// callback
|
||||
MessageListener handler = messageHandlers.get(channelId);
|
||||
if (handler == null) {
|
||||
Log.w(TAG, "no handler for channel " + channelId);
|
||||
return;
|
||||
}
|
||||
|
||||
threadPool.execute(() -> {
|
||||
try {
|
||||
handler.onMessage(channelId, flags, payload, 0, payload.length);
|
||||
} catch (Throwable t) {
|
||||
Log.wtf(TAG, "exception in message handler", t);
|
||||
closeConnection(); // this isn't supposed to happen
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void loop() {
|
||||
Log.i(TAG, "connection loop start");
|
||||
try {
|
||||
int len;
|
||||
while (transferInterface.alive()) {
|
||||
len = transferInterface.readFrame(readBuffer);
|
||||
if (len == 0) continue;
|
||||
|
||||
handleMessage(len);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.v(TAG, "connection died", e);
|
||||
} finally {
|
||||
Log.d(TAG, "connection loop exit");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package io.benwiegand.projection.geargrinder.projection;
|
||||
|
||||
import android.graphics.Rect;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import io.benwiegand.projection.geargrinder.callback.InputEventListener;
|
||||
import io.benwiegand.projection.geargrinder.coordinate.CoordinateTranslator;
|
||||
import io.benwiegand.projection.geargrinder.proto.data.readable.input.event.TouchEvent;
|
||||
|
||||
public class InputEventMuxer implements InputEventListener {
|
||||
|
||||
public static class Destination {
|
||||
private final CoordinateTranslator<TouchEvent.PointerLocation> translator;
|
||||
private final InputEventListener inputEventListener;
|
||||
private final Rect bounds = new Rect();
|
||||
private boolean enabled = true;
|
||||
|
||||
private Destination(InputEventListener listener) {
|
||||
translator = CoordinateTranslator.create(
|
||||
x -> x - bounds.left,
|
||||
y -> y - bounds.top,
|
||||
TouchEvent.PointerLocation.class
|
||||
);
|
||||
inputEventListener = listener;
|
||||
}
|
||||
|
||||
public Rect getBounds() {
|
||||
return bounds;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
}
|
||||
|
||||
private final List<Destination> destinations = new ArrayList<>();
|
||||
|
||||
private final InputEventListener rootListener;
|
||||
|
||||
|
||||
public InputEventMuxer(InputEventListener rootListener) {
|
||||
this.rootListener = rootListener;
|
||||
}
|
||||
|
||||
|
||||
public Destination addDestination(InputEventListener listener) {
|
||||
Destination destination = new Destination(listener);
|
||||
destinations.add(destination);
|
||||
return destination;
|
||||
}
|
||||
|
||||
public void removeDestination(Destination destination) {
|
||||
destinations.remove(destination);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTouchEvent(TouchEvent event, CoordinateTranslator<TouchEvent.PointerLocation> translator) {
|
||||
// TODO: multi-touch
|
||||
int x = translator.translateX(event.pointerLocations()[0]);
|
||||
int y = translator.translateY(event.pointerLocations()[0]);
|
||||
|
||||
// TODO: pointer ownership
|
||||
for (Destination destination : destinations) {
|
||||
if (!destination.bounds.contains(x, y)) continue;
|
||||
if (!destination.enabled) continue;
|
||||
destination.inputEventListener.onTouchEvent(event, translator.chain(destination.translator));
|
||||
return;
|
||||
}
|
||||
|
||||
rootListener.onTouchEvent(event, translator);
|
||||
}
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
package io.benwiegand.projection.geargrinder.projection;
|
||||
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.hardware.display.DisplayManager;
|
||||
import android.hardware.display.VirtualDisplay;
|
||||
import android.os.IBinder;
|
||||
import android.util.Log;
|
||||
import android.view.Surface;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Optional;
|
||||
|
||||
import io.benwiegand.projection.geargrinder.AccessibilityInputService;
|
||||
import io.benwiegand.projection.geargrinder.ProjectionActivity;
|
||||
import io.benwiegand.projection.geargrinder.callback.InputEventListener;
|
||||
import io.benwiegand.projection.geargrinder.channel.InputChannel;
|
||||
import io.benwiegand.projection.geargrinder.coordinate.CoordinateTranslator;
|
||||
import io.benwiegand.projection.geargrinder.makeshiftbind.MakeshiftServiceConnection;
|
||||
import io.benwiegand.projection.geargrinder.proto.data.readable.av.preset.VideoPreset;
|
||||
import io.benwiegand.projection.geargrinder.proto.data.readable.input.event.TouchEvent;
|
||||
|
||||
public class ProjectionService implements InputEventListener {
|
||||
private static final String TAG = ProjectionService.class.getSimpleName();
|
||||
|
||||
private final VirtualDisplay virtualDisplay;
|
||||
|
||||
private VideoPreset videoPreset = VideoPreset.getDefault();
|
||||
|
||||
private AccessibilityInputService.ServiceBinder accessibilityInputServiceBinder = null;
|
||||
private ProjectionActivity.ActivityBinder projectionActivityBinder = null;
|
||||
|
||||
private final Context context;
|
||||
|
||||
private final CoordinateTranslator<TouchEvent.PointerLocation> projectionTouchCoordinateTranslator = CoordinateTranslator.create(
|
||||
x -> x + videoPreset.marginHorizontal() / 2,
|
||||
y -> y + videoPreset.marginVertical() / 2,
|
||||
TouchEvent.PointerLocation.class
|
||||
);
|
||||
|
||||
public ProjectionService(Context context) {
|
||||
this.context = context;
|
||||
DisplayManager dm = context.getSystemService(DisplayManager.class);
|
||||
virtualDisplay = dm.createVirtualDisplay(
|
||||
"Geargrinder projection",
|
||||
videoPreset.width(),
|
||||
videoPreset.height(),
|
||||
videoPreset.density(),
|
||||
null, DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY | DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION
|
||||
);
|
||||
|
||||
// TODO
|
||||
try {
|
||||
new ProcessBuilder("su", "-c", "am start-activity --display " + virtualDisplay.getDisplay().getDisplayId() + " io.benwiegand.projection.geargrinder/.ProjectionActivity").start();
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "failed to launch projection activity", e);
|
||||
}
|
||||
|
||||
MakeshiftServiceConnection.bindService(context, new ComponentName(context, AccessibilityInputService.class), serviceConnection);
|
||||
MakeshiftServiceConnection.bindService(context, new ComponentName(context, ProjectionActivity.class), serviceConnection);
|
||||
}
|
||||
|
||||
public void destroy() {
|
||||
serviceConnection.destroy();
|
||||
virtualDisplay.release();
|
||||
}
|
||||
|
||||
public void setOutput(Surface surface, VideoPreset videoPreset) {
|
||||
Log.i(TAG, "attaching new output");
|
||||
if (!this.videoPreset.equals(videoPreset)) {
|
||||
Log.d(TAG, "resizing to " + videoPreset.width() + " x " + videoPreset.height() + ", dpi = " + videoPreset.density());
|
||||
virtualDisplay.resize(videoPreset.width(), videoPreset.height(), videoPreset.density());
|
||||
this.videoPreset = videoPreset;
|
||||
}
|
||||
|
||||
virtualDisplay.setSurface(surface);
|
||||
|
||||
getProjectionBinder()
|
||||
.ifPresent(binder -> binder.setMargins(videoPreset.marginHorizontal(), videoPreset.marginVertical()));
|
||||
}
|
||||
|
||||
public void setInput(InputChannel inputChannel) {
|
||||
inputChannel.setInputEventListener(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTouchEvent(TouchEvent event, CoordinateTranslator<TouchEvent.PointerLocation> translator) {
|
||||
getProjectionBinder()
|
||||
.map(ProjectionActivity.ActivityBinder::getInputEventListener)
|
||||
.ifPresent(l -> l.onTouchEvent(event, translator.chain(projectionTouchCoordinateTranslator)));
|
||||
}
|
||||
|
||||
private void onProjectionActivityConnected(ProjectionActivity.ActivityBinder binder) {
|
||||
binder.setMargins(videoPreset.marginHorizontal(), videoPreset.marginVertical());
|
||||
}
|
||||
|
||||
|
||||
private Optional<AccessibilityInputService.ServiceBinder> getAccessibilityBinder() {
|
||||
return Optional.ofNullable(accessibilityInputServiceBinder);
|
||||
}
|
||||
|
||||
private Optional<ProjectionActivity.ActivityBinder> getProjectionBinder() {
|
||||
return Optional.ofNullable(projectionActivityBinder);
|
||||
}
|
||||
|
||||
private final MakeshiftServiceConnection serviceConnection = new MakeshiftServiceConnection() {
|
||||
@Override
|
||||
public void onServiceConnected(ComponentName name, IBinder service) {
|
||||
Log.i(TAG, "connected: " + name.getShortClassName());
|
||||
switch (service) {
|
||||
case AccessibilityInputService.ServiceBinder binder -> accessibilityInputServiceBinder = binder;
|
||||
case ProjectionActivity.ActivityBinder binder -> {
|
||||
projectionActivityBinder = binder;
|
||||
onProjectionActivityConnected(binder);
|
||||
}
|
||||
default -> Log.wtf(TAG, "unhandled binder type", new RuntimeException());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServiceDisconnected(ComponentName name) {
|
||||
Log.i(TAG, "disconnected: " + name.getShortClassName());
|
||||
if (new ComponentName(context, AccessibilityInputService.class).equals(name)) {
|
||||
accessibilityInputServiceBinder = null;
|
||||
} else if (new ComponentName(context, ProjectionActivity.class).equals(name)) {
|
||||
projectionActivityBinder = null;
|
||||
} else {
|
||||
Log.wtf(TAG, "unhandled component: " + name, new RuntimeException());
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
package io.benwiegand.projection.geargrinder.projection;
|
||||
|
||||
import static io.benwiegand.projection.geargrinder.util.UiUtil.getViewBoundsInDisplay;
|
||||
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.content.pm.ActivityInfo;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.hardware.display.DisplayManager;
|
||||
import android.hardware.display.VirtualDisplay;
|
||||
import android.util.Log;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.SurfaceHolder;
|
||||
import android.view.SurfaceView;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import io.benwiegand.projection.geargrinder.R;
|
||||
import io.benwiegand.projection.geargrinder.callback.InputEventListener;
|
||||
import io.benwiegand.projection.geargrinder.coordinate.CoordinateTranslator;
|
||||
import io.benwiegand.projection.geargrinder.proto.data.readable.input.event.TouchEvent;
|
||||
import io.benwiegand.projection.geargrinder.shell.RootShell;
|
||||
import io.benwiegand.projection.geargrinder.util.RootUtil;
|
||||
|
||||
public class VirtualActivity implements SurfaceHolder.Callback, InputEventListener {
|
||||
private static final String TAG = VirtualActivity.class.getSimpleName();
|
||||
|
||||
private static final long SPLASH_SHOW_DURATION = 3000;
|
||||
private static final long SPLASH_ANIMATION_DURATION = 300;
|
||||
|
||||
private final RootShell rootShell;
|
||||
private final VirtualDisplay virtualDisplay;
|
||||
private final InputEventMuxer inputEventMuxer;
|
||||
private final InputEventMuxer.Destination inputDestination;
|
||||
private final int density;
|
||||
private int width;
|
||||
private int height;
|
||||
|
||||
private final View rootView;
|
||||
private final SurfaceView surfaceView;
|
||||
|
||||
public VirtualActivity(RootShell rootShell, InputEventMuxer inputEventMuxer, ComponentName componentName, ViewGroup parent) throws IOException, PackageManager.NameNotFoundException {
|
||||
this.rootShell = rootShell;
|
||||
this.inputEventMuxer = inputEventMuxer;
|
||||
density = parent.getResources().getDisplayMetrics().densityDpi;
|
||||
Context context = parent.getContext();
|
||||
DisplayManager dm = context.getSystemService(DisplayManager.class);
|
||||
PackageManager pm = context.getPackageManager();
|
||||
ActivityInfo activityInfo = pm.getActivityInfo(componentName, 0);
|
||||
LayoutInflater inflater = LayoutInflater.from(context);
|
||||
|
||||
// display
|
||||
virtualDisplay = dm.createVirtualDisplay(
|
||||
"Geargrinder virtual activity",
|
||||
800, 480, density, // TODO
|
||||
null, DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY | DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION
|
||||
);
|
||||
|
||||
// TODO
|
||||
try {
|
||||
// launch
|
||||
rootShell.writeLine("am start-activity --display " + virtualDisplay.getDisplay().getDisplayId() + " " + componentName.flattenToShortString());
|
||||
} catch (Throwable t) {
|
||||
virtualDisplay.release();
|
||||
throw t;
|
||||
}
|
||||
|
||||
// view
|
||||
rootView = inflater.inflate(R.layout.layout_virtual_activity, parent, false);
|
||||
surfaceView = rootView.findViewById(R.id.virtual_activity_surface);
|
||||
surfaceView.getHolder().addCallback(this);
|
||||
|
||||
TextView titleView = rootView.findViewById(R.id.virtual_activity_title);
|
||||
titleView.setText(pm.getApplicationLabel(activityInfo.applicationInfo));
|
||||
|
||||
ImageView iconView = rootView.findViewById(R.id.virtual_activity_icon);
|
||||
iconView.setImageDrawable(pm.getApplicationIcon(activityInfo.applicationInfo));
|
||||
|
||||
// touch
|
||||
inputDestination = inputEventMuxer.addDestination(this);
|
||||
surfaceView.getViewTreeObserver().addOnGlobalLayoutListener(() ->
|
||||
onLayoutUpdated(surfaceView.getWidth(), surfaceView.getHeight()));
|
||||
}
|
||||
|
||||
public void destroy() {
|
||||
inputEventMuxer.removeDestination(inputDestination);
|
||||
virtualDisplay.release();
|
||||
}
|
||||
|
||||
private void showSplash(boolean animateIn) {
|
||||
View splash = rootView.findViewById(R.id.virtual_activity_splash);
|
||||
// TODO: disable input
|
||||
splash.animate()
|
||||
.setStartDelay(0)
|
||||
.setDuration(animateIn ? SPLASH_ANIMATION_DURATION : 0)
|
||||
.alpha(0.99f)
|
||||
.withEndAction(() -> splash.animate()
|
||||
.setStartDelay(SPLASH_SHOW_DURATION)
|
||||
.setDuration(SPLASH_ANIMATION_DURATION)
|
||||
.alpha(0f));
|
||||
}
|
||||
|
||||
public void showSplash() {
|
||||
showSplash(true);
|
||||
}
|
||||
|
||||
public View getRootView() {
|
||||
return rootView;
|
||||
}
|
||||
|
||||
private void onLayoutUpdated(int width, int height) {
|
||||
getViewBoundsInDisplay(surfaceView, inputDestination.getBounds());
|
||||
if (this.width != width || this.height != height) {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
virtualDisplay.resize(width, height, density);
|
||||
showSplash(false);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void surfaceChanged(@NonNull SurfaceHolder holder, int format, int width, int height) {
|
||||
if (virtualDisplay.getSurface() != holder.getSurface())
|
||||
virtualDisplay.setSurface(holder.getSurface());
|
||||
|
||||
onLayoutUpdated(width, height);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void surfaceCreated(@NonNull SurfaceHolder holder) {
|
||||
Log.d(TAG, "surface created");
|
||||
inputDestination.setEnabled(true);
|
||||
showSplash(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void surfaceDestroyed(@NonNull SurfaceHolder holder) {
|
||||
Log.d(TAG, "surface destroyed");
|
||||
virtualDisplay.setSurface(null);
|
||||
inputDestination.setEnabled(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTouchEvent(TouchEvent event, CoordinateTranslator<TouchEvent.PointerLocation> translator) {
|
||||
try {
|
||||
RootUtil.simulateTouchEventRoot(rootShell, virtualDisplay.getDisplay().getDisplayId(), event, translator);
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "failed to simulate touch", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package io.benwiegand.projection.geargrinder.proto;
|
||||
|
||||
import static io.benwiegand.projection.geargrinder.util.ByteUtil.readInt32;
|
||||
import static io.benwiegand.projection.geargrinder.util.ByteUtil.readInt64;
|
||||
import static io.benwiegand.projection.geargrinder.util.ByteUtil.unsignByte;
|
||||
|
||||
import android.util.Base64;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* flexible protobuf parser
|
||||
*/
|
||||
public class ProtoParser {
|
||||
private static final String TAG = ProtoParser.class.getSimpleName();
|
||||
|
||||
// TODO: merge parts of ProtoParser and ProtoSerializer
|
||||
private static final int FIELD_TYPE_VAR_INT = 0;
|
||||
private static final int FIELD_TYPE_64 = 1;
|
||||
private static final int FIELD_TYPE_VAR_DATA = 2;
|
||||
private static final int FIELD_TYPE_GROUP_START = 3;
|
||||
private static final int FIELD_TYPE_GROUP_END = 4;
|
||||
private static final int FIELD_TYPE_32 = 5;
|
||||
|
||||
public interface ProtoField {
|
||||
int fieldId();
|
||||
int offset();
|
||||
int length();
|
||||
}
|
||||
|
||||
public record ProtoVarInt(int fieldId, int offset, int length) implements ProtoField {
|
||||
public long decode(byte[] buffer) {
|
||||
int i = offset();
|
||||
long value = 0;
|
||||
int lshift = 0;
|
||||
do {
|
||||
value += (long) (buffer[i] & 0x7f) << lshift;
|
||||
lshift += 7;
|
||||
} while ((buffer[i++] & 0x80) != 0);
|
||||
|
||||
assert i - offset() == length();
|
||||
return value;
|
||||
}
|
||||
}
|
||||
public record ProtoVarData(int fieldId, int offset, int length) implements ProtoField {}
|
||||
public record Proto64(int fieldId, int offset) implements ProtoField {
|
||||
public int length() {
|
||||
return 8;
|
||||
}
|
||||
}
|
||||
public record Proto32(int fieldId, int offset) implements ProtoField {
|
||||
public int length() {
|
||||
return 4;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static ProtoField getSingle(List<ProtoField> fieldList) {
|
||||
if (fieldList == null) return null;
|
||||
if (fieldList.size() != 1) throw new AssertionError("expected one field in list");
|
||||
return fieldList.get(0);
|
||||
}
|
||||
|
||||
public static <T extends ProtoField> T getSingle(List<ProtoField> fieldList, Class<T> clazz) {
|
||||
ProtoField field = getSingle(fieldList);
|
||||
if (field == null) return null;
|
||||
if (!clazz.equals(field.getClass()))
|
||||
throw new AssertionError("expected field of type " + clazz.getSimpleName() + " but got " + field.getClass().getSimpleName());
|
||||
return (T) field;
|
||||
}
|
||||
|
||||
public static long getSingleUnsignedInteger(byte[] buffer, List<ProtoField> fieldList, long defaultValue) {
|
||||
return switch (getSingle(fieldList)) {
|
||||
case null -> defaultValue;
|
||||
case ProtoVarInt vi -> vi.decode(buffer);
|
||||
case Proto32 p32 -> readInt32(buffer, p32.offset());
|
||||
case Proto64 p64 -> readInt64(buffer, p64.offset());
|
||||
default -> throw new AssertionError("invalid field type for integer");
|
||||
};
|
||||
}
|
||||
|
||||
public static int getSingleUnsignedInteger32(byte[] buffer, List<ProtoField> fieldList, int defaultValue) {
|
||||
return switch (getSingle(fieldList)) {
|
||||
case null -> defaultValue;
|
||||
case ProtoVarInt vi -> (int) vi.decode(buffer);
|
||||
case Proto32 p32 -> readInt32(buffer, p32.offset());
|
||||
default -> throw new AssertionError("invalid field type for 32 bit integer");
|
||||
};
|
||||
}
|
||||
|
||||
public static boolean getSingleBoolean(byte[] buffer, List<ProtoField> fieldList, boolean defaultValue) {
|
||||
return getSingleUnsignedInteger(buffer, fieldList, defaultValue ? 1 : 0) != 0;
|
||||
}
|
||||
|
||||
public static String getSingleString(byte[] buffer, List<ProtoField> fieldList, String defaultValue) {
|
||||
return switch (getSingle(fieldList)) {
|
||||
case null -> defaultValue;
|
||||
case ProtoVarData vd -> new String(buffer, vd.offset(), vd.length(), StandardCharsets.UTF_8);
|
||||
default -> throw new AssertionError("expected var data field type for string");
|
||||
};
|
||||
}
|
||||
|
||||
public static String getSingleString(byte[] buffer, List<ProtoField> fieldList) {
|
||||
return getSingleString(buffer, fieldList, null);
|
||||
}
|
||||
|
||||
private static int[] getUnsignedInteger8Array(byte[] buffer, ProtoVarData vd) {
|
||||
if (vd == null) return new int[0];
|
||||
|
||||
int[] result = new int[vd.length()];
|
||||
for (int i = 0; i < result.length; i++) {
|
||||
result[i] = unsignByte(buffer[i + vd.offset()]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static int[] getUnsignedInteger32Array(byte[] buffer, List<ProtoField> fieldList) {
|
||||
if (fieldList == null) return new int[0];
|
||||
|
||||
// seems to be converted to a byte array sometimes when all values are <= 255
|
||||
if (fieldList.size() == 1 && fieldList.get(0) instanceof ProtoVarData vd)
|
||||
return getUnsignedInteger8Array(buffer, vd);
|
||||
|
||||
int[] result = new int[fieldList.size()];
|
||||
int i = 0;
|
||||
for (ProtoParser.ProtoField field : fieldList) {
|
||||
result[i++] = switch (field) {
|
||||
case ProtoVarInt vi -> (int) vi.decode(buffer);
|
||||
case Proto32 p32 -> readInt32(buffer, p32.offset());
|
||||
default -> throw new AssertionError("invalid field type for 32 bit integer");
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
public static Map<Integer, List<ProtoField>> parse(byte[] buffer, int offset, int length) {
|
||||
Map<Integer, List<ProtoField>> result = new HashMap<>();
|
||||
int i = offset;
|
||||
while (i - offset < length) {
|
||||
ProtoField field = parseField(buffer, i);
|
||||
|
||||
List<ProtoField> fieldList = result.get(field.fieldId());
|
||||
if (fieldList == null) fieldList = new ArrayList<>(1);
|
||||
fieldList.add(field);
|
||||
result.put(field.fieldId(), fieldList);
|
||||
|
||||
i = field.offset() + field.length();
|
||||
if (i - offset == length) break;
|
||||
if (i - offset > length) {
|
||||
// passed length might be incorrect
|
||||
Log.wtf(TAG, "exceeded length while parsing field with id: " + field.fieldId());
|
||||
throw new IndexOutOfBoundsException("a field parsed beyond the specified length");
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static ProtoVarInt parseVarInt(int fieldId, byte[] buffer, int offset) {
|
||||
// uses left-most bit of each byte to indicate end
|
||||
int i = offset;
|
||||
while ((buffer[i++] & 0x80) != 0) { /* weee */ }
|
||||
return new ProtoVarInt(fieldId, offset, i - offset);
|
||||
}
|
||||
|
||||
private static ProtoField parseField(byte[] buffer, int offset) {
|
||||
int i = offset;
|
||||
int fieldId = buffer[i] >> 3;
|
||||
int fieldType = buffer[i] & 0x07;
|
||||
i++;
|
||||
|
||||
return switch (fieldType) {
|
||||
case FIELD_TYPE_64 -> new Proto64(fieldId, i);
|
||||
case FIELD_TYPE_32 -> new Proto32(fieldId, i);
|
||||
case FIELD_TYPE_VAR_INT -> parseVarInt(fieldId, buffer, i);
|
||||
case FIELD_TYPE_VAR_DATA -> {
|
||||
ProtoVarInt dataLength = parseVarInt(0, buffer, i);
|
||||
i += dataLength.length();
|
||||
long lengthDecoded = dataLength.decode(buffer);
|
||||
if (lengthDecoded > Integer.MAX_VALUE || lengthDecoded < 0) {
|
||||
Log.wtf(TAG, "unrealistically massive var data: len = " + lengthDecoded);
|
||||
throw new AssertionError("data size too large for field with id " + fieldId);
|
||||
}
|
||||
yield new ProtoVarData(fieldId, i, (int) lengthDecoded);
|
||||
}
|
||||
case FIELD_TYPE_GROUP_START -> {
|
||||
Log.w(TAG, "skipping group");
|
||||
// groups aren't supported
|
||||
while (buffer[i] >> 3 != fieldId && (buffer[i++] & 0x07) != FIELD_TYPE_GROUP_END) { /* weee */ }
|
||||
final int iFinal = i;
|
||||
yield new ProtoField() {
|
||||
@Override
|
||||
public int fieldId() {
|
||||
return fieldId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int offset() {
|
||||
return offset + 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int length() {
|
||||
return iFinal - offset();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public String toString() {
|
||||
return "unparsed group (length " + length() + ")";
|
||||
}
|
||||
};
|
||||
}
|
||||
case FIELD_TYPE_GROUP_END -> {
|
||||
Log.wtf(TAG, "group end without group start!!!");
|
||||
throw new AssertionError("group end without group start for id " + fieldId);
|
||||
}
|
||||
|
||||
default -> {
|
||||
// this should be impossible
|
||||
Log.wtf(TAG, "unexpected field type: " + fieldType);
|
||||
throw new AssertionError("unexpected field type " + fieldType + " for field with id " + fieldId);
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
private static void debugDumpField(byte[] buffer, ProtoField field, int indentBy) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < indentBy; i++) sb.append(" ");
|
||||
String indent = sb.toString();
|
||||
|
||||
if (field instanceof ProtoParser.ProtoVarInt vi) {
|
||||
Log.i(TAG, indent + "- field " + field.fieldId() + " num : " + vi.decode(buffer));
|
||||
} else if (field instanceof ProtoParser.Proto32 p32) {
|
||||
Log.i(TAG, indent + "- field " + field.fieldId() + " i32 : " + readInt32(buffer, p32.offset()));
|
||||
} else if (field instanceof ProtoParser.Proto64 p64) {
|
||||
Log.i(TAG, indent + "- field " + field.fieldId() + " i64 : " + readInt64(buffer, p64.offset()));
|
||||
} else if (field instanceof ProtoParser.ProtoVarData vd) {
|
||||
// Log.i(TAG, "field " + id + " dat : " + new String(buffer, vd.offset(), vd.length()));
|
||||
Log.i(TAG, indent + "- field " + field.fieldId() + " dat : " + Base64.encodeToString(buffer, vd.offset(), vd.length(), Base64.NO_WRAP));
|
||||
}
|
||||
}
|
||||
|
||||
private static void debugDumpRecursive(byte[] buffer, Map<Integer, List<ProtoField>> fields, int indent) {
|
||||
|
||||
for (Map.Entry<Integer, List<ProtoParser.ProtoField>> entry : fields.entrySet()) {
|
||||
for (ProtoParser.ProtoField field : entry.getValue()) {
|
||||
debugDumpField(buffer, field, indent);
|
||||
|
||||
if (field instanceof ProtoVarData vd) {
|
||||
try {
|
||||
Map<Integer, List<ProtoField>> subFields = parse(buffer, vd.offset(), vd.length());
|
||||
debugDumpRecursive(buffer, subFields, indent + 1);
|
||||
} catch (Throwable ignored) {
|
||||
// probably not protobuf data
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void debugDump(byte[] buffer, int offset, int length) {
|
||||
Map<Integer, List<ProtoField>> fields = parse(buffer, offset, length);
|
||||
|
||||
for (Map.Entry<Integer, List<ProtoParser.ProtoField>> entry : fields.entrySet()) {
|
||||
for (ProtoParser.ProtoField field : entry.getValue()) {
|
||||
debugDumpField(buffer, field, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void debugDumpRecursive(byte[] buffer, int offset, int length) {
|
||||
Map<Integer, List<ProtoField>> fields = parse(buffer, offset, length);
|
||||
debugDumpRecursive(buffer, fields, 0);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package io.benwiegand.projection.geargrinder.proto;
|
||||
|
||||
import static io.benwiegand.projection.geargrinder.util.ByteUtil.unsignByte;
|
||||
import static io.benwiegand.projection.geargrinder.util.ByteUtil.writeInt32;
|
||||
import static io.benwiegand.projection.geargrinder.util.ByteUtil.writeInt64;
|
||||
|
||||
/**
|
||||
* flexible protobuf serializer
|
||||
*/
|
||||
public class ProtoSerializer {
|
||||
|
||||
private static final int FIELD_TYPE_VAR_INT = 0;
|
||||
private static final int FIELD_TYPE_64 = 1;
|
||||
private static final int FIELD_TYPE_VAR_DATA = 2;
|
||||
private static final int FIELD_TYPE_GROUP_START = 3;
|
||||
private static final int FIELD_TYPE_GROUP_END = 4;
|
||||
private static final int FIELD_TYPE_32 = 5;
|
||||
|
||||
public interface ProtoField {
|
||||
int fieldId();
|
||||
|
||||
int encode(byte[] buffer, int offset);
|
||||
|
||||
int encodedLength();
|
||||
|
||||
int type();
|
||||
}
|
||||
|
||||
public record ProtoVarInt(int fieldId, long value) implements ProtoSerializer.ProtoField {
|
||||
@Override
|
||||
public int encode(byte[] buffer, int offset) {
|
||||
int i = offset;
|
||||
long v = value();
|
||||
do {
|
||||
buffer[i++] = (byte) ((v & 0x7f) | 0x80);
|
||||
v >>= 7;
|
||||
} while (v != 0);
|
||||
buffer[i-1] = (byte) (unsignByte(buffer[i-1]) & 0x7f);
|
||||
|
||||
return i - offset;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int encodedLength() {
|
||||
int i = 1;
|
||||
long v = value();
|
||||
while ((v >>= 7) != 0) i++;
|
||||
return i;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int type() {
|
||||
return FIELD_TYPE_VAR_INT;
|
||||
}
|
||||
}
|
||||
public record ProtoVarData(int fieldId, byte[] data) implements ProtoSerializer.ProtoField {
|
||||
@Override
|
||||
public int encode(byte[] buffer, int offset) {
|
||||
int i = offset;
|
||||
i += new ProtoVarInt(0, data.length).encode(buffer, i);
|
||||
|
||||
System.arraycopy(data, 0, buffer, i, data.length);
|
||||
i += data.length;
|
||||
return i - offset;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int encodedLength() {
|
||||
return data.length + new ProtoVarInt(0, data.length).encodedLength();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int type() {
|
||||
return FIELD_TYPE_VAR_DATA;
|
||||
}
|
||||
}
|
||||
public record Proto64(int fieldId, long value) implements ProtoSerializer.ProtoField {
|
||||
@Override
|
||||
public int encode(byte[] buffer, int offset) {
|
||||
writeInt64(value(), buffer, offset);
|
||||
return encodedLength();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int encodedLength() {
|
||||
return 8;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int type() {
|
||||
return FIELD_TYPE_64;
|
||||
}
|
||||
}
|
||||
public record Proto32(int fieldId, int value) implements ProtoSerializer.ProtoField {
|
||||
@Override
|
||||
public int encode(byte[] buffer, int offset) {
|
||||
writeInt32(value(), buffer, offset);
|
||||
return encodedLength();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int encodedLength() {
|
||||
return 4;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int type() {
|
||||
return FIELD_TYPE_32;
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[] serialize(ProtoField... fields) {
|
||||
int totalLength = 0;
|
||||
for (ProtoField field : fields) {
|
||||
assert field.fieldId() > 0;
|
||||
assert field.fieldId() < 0xf8;
|
||||
|
||||
totalLength += 1 + field.encodedLength();
|
||||
}
|
||||
|
||||
byte[] buffer = new byte[totalLength];
|
||||
int i = 0;
|
||||
for (ProtoField field : fields) {
|
||||
int fieldType = field.type();
|
||||
buffer[i++] = (byte) ((field.fieldId() << 3) + fieldType);
|
||||
i += field.encode(buffer, i);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package io.benwiegand.projection.geargrinder.proto.data.readable;
|
||||
|
||||
import android.util.Base64;
|
||||
import android.util.Log;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import io.benwiegand.projection.geargrinder.proto.ProtoParser;
|
||||
import io.benwiegand.projection.geargrinder.proto.data.readable.av.AVChannelMeta;
|
||||
import io.benwiegand.projection.geargrinder.proto.data.readable.input.InputChannelMeta;
|
||||
|
||||
public interface ChannelMeta {
|
||||
int channelId();
|
||||
|
||||
static ChannelMeta parse(byte[] buffer, int offset, int length) {
|
||||
final String TAG = ChannelMeta.class.getSimpleName() + "::parse";
|
||||
|
||||
try {
|
||||
Map<Integer, List<ProtoParser.ProtoField>> fields = ProtoParser.parse(buffer, offset, length);
|
||||
|
||||
if (fields.size() < 2) {
|
||||
throw new AssertionError("expected at least 2 fields");
|
||||
} else if (fields.size() > 2) {
|
||||
Log.wtf(TAG, "unexpected extra data in channel meta. dump: " + Base64.encodeToString(buffer, offset, length, 0));
|
||||
}
|
||||
|
||||
int channelId = ProtoParser.getSingleUnsignedInteger32(buffer, fields.get(1), -1);
|
||||
if (channelId == -1) throw new AssertionError("no channel id in channel meta");
|
||||
|
||||
ProtoParser.ProtoVarData metaField;
|
||||
|
||||
// AV channel meta
|
||||
metaField = ProtoParser.getSingle(fields.get(3), ProtoParser.ProtoVarData.class);
|
||||
if (metaField != null)
|
||||
return AVChannelMeta.parse(channelId, buffer, metaField.offset(), metaField.length());
|
||||
|
||||
// input channel meta
|
||||
metaField = ProtoParser.getSingle(fields.get(4), ProtoParser.ProtoVarData.class);
|
||||
if (metaField != null)
|
||||
return InputChannelMeta.parse(channelId, buffer, metaField.offset(), metaField.length());
|
||||
|
||||
// known fields but not yet parsed/implemented
|
||||
for (int fieldNumber : new int[] {2, 5, 6, 8, 12}) {
|
||||
// field 2: sensor channel
|
||||
// field 5: av/mic input channel
|
||||
// field 6: bluetooth channel
|
||||
// field 8: navigation channel
|
||||
// field 12: vendor extension channel
|
||||
|
||||
metaField = ProtoParser.getSingle(fields.get(fieldNumber), ProtoParser.ProtoVarData.class);
|
||||
if (metaField != null) {
|
||||
Log.d(TAG, "channel " + channelId + " meta (at field " + fieldNumber + ") not parsed: " + Base64.encodeToString(buffer, metaField.offset(), metaField.length(), 0));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// not yet known but would like to know
|
||||
Log.v(TAG, "unknown metadata field for channel meta. dump: " + Base64.encodeToString(buffer, offset, length, 0));
|
||||
return null;
|
||||
|
||||
} catch (Throwable t) {
|
||||
Log.wtf(TAG, "failed to parse ChannelMeta: " + Base64.encodeToString(buffer, offset, length, 0), t);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package io.benwiegand.projection.geargrinder.proto.data.readable;
|
||||
|
||||
import android.util.Base64;
|
||||
import android.util.Log;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import io.benwiegand.projection.geargrinder.proto.ProtoParser;
|
||||
|
||||
public record ChannelOpenResponse(
|
||||
Status status
|
||||
) {
|
||||
private static final String TAG = ChannelOpenResponse.class.getSimpleName();
|
||||
|
||||
public enum Status {
|
||||
UNKNOWN,
|
||||
OK,
|
||||
ERROR;
|
||||
|
||||
private static Status parse(int value) {
|
||||
return switch (value) {
|
||||
case 0 -> OK;
|
||||
case 1 -> ERROR;
|
||||
default -> UNKNOWN;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public static ChannelOpenResponse parse(byte[] buffer, int offset, int length) {
|
||||
try {
|
||||
Map<Integer, List<ProtoParser.ProtoField>> fields = ProtoParser.parse(buffer, offset, length);
|
||||
|
||||
return new ChannelOpenResponse(
|
||||
Status.parse(ProtoParser.getSingleUnsignedInteger32(buffer, fields.get(1), -1))
|
||||
);
|
||||
|
||||
} catch (Throwable t) {
|
||||
Log.wtf(TAG, "failed to parse ChannelOpenResponse: " + Base64.encodeToString(buffer, offset, length, 0), t);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package io.benwiegand.projection.geargrinder.proto.data.readable;
|
||||
|
||||
import android.util.Base64;
|
||||
import android.util.Log;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import io.benwiegand.projection.geargrinder.proto.ProtoParser;
|
||||
|
||||
public record ServiceDiscoveryResponse(
|
||||
ChannelMeta[] channelMetadata, // 1 (repeated)
|
||||
String headunitName, // 2
|
||||
String carModel, // 3
|
||||
String carYear, // 4
|
||||
String carSerial, // 5
|
||||
boolean leftHandDrive, // 6
|
||||
String headunitManufacturer, // 7
|
||||
String headunitSoftwareBuild, // 8
|
||||
String headunitSoftwareVersion // 9
|
||||
) {
|
||||
private static final String TAG = ServiceDiscoveryResponse.class.getSimpleName();
|
||||
|
||||
private static final Set<String> GENERIC_CAR_NAMES = Set.of("Universal", "Generic", "Unknown");
|
||||
|
||||
public String friendlyName() {
|
||||
String carName = carModel();
|
||||
if (carName == null || GENERIC_CAR_NAMES.contains(carName)) {
|
||||
if (headunitName() != null) carName = headunitName();
|
||||
}
|
||||
if (carName == null) carName = carSerial();
|
||||
if (carName == null) carName = "Unknown";
|
||||
|
||||
if (carYear() != null) carName = carYear() + " " + carName;
|
||||
|
||||
return carName;
|
||||
}
|
||||
|
||||
|
||||
public static ServiceDiscoveryResponse parse(byte[] buffer, int offset, int length) {
|
||||
try {
|
||||
Map<Integer, List<ProtoParser.ProtoField>> fields = ProtoParser.parse(buffer, offset, length);
|
||||
|
||||
// for debugging
|
||||
ProtoParser.debugDumpRecursive(buffer, offset, length);
|
||||
|
||||
List<ProtoParser.ProtoField> channelMetaFields = fields.get(1);
|
||||
ChannelMeta[] channelMetas = new ChannelMeta[channelMetaFields == null ? 0 : channelMetaFields.size()];
|
||||
if (channelMetaFields != null) {
|
||||
int i = 0;
|
||||
for (ProtoParser.ProtoField channelMetaField : channelMetaFields) {
|
||||
if (channelMetaField instanceof ProtoParser.ProtoVarData vd) {
|
||||
channelMetas[i++] = ChannelMeta.parse(buffer, vd.offset(), vd.length());
|
||||
} else {
|
||||
throw new AssertionError("expected var data for channel metadata");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return new ServiceDiscoveryResponse(
|
||||
channelMetas,
|
||||
ProtoParser.getSingleString(buffer, fields.get(2), null),
|
||||
ProtoParser.getSingleString(buffer, fields.get(3), null),
|
||||
ProtoParser.getSingleString(buffer, fields.get(4), null),
|
||||
ProtoParser.getSingleString(buffer, fields.get(5), null),
|
||||
ProtoParser.getSingleBoolean(buffer, fields.get(6), true), // leftHandDrive
|
||||
ProtoParser.getSingleString(buffer, fields.get(7), null),
|
||||
ProtoParser.getSingleString(buffer, fields.get(8), null),
|
||||
ProtoParser.getSingleString(buffer, fields.get(9), null)
|
||||
);
|
||||
} catch (Throwable t) {
|
||||
Log.wtf(TAG, "failed to parse ServiceDiscoveryReponse: " + Base64.encodeToString(buffer, offset, length, 0), t);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package io.benwiegand.projection.geargrinder.proto.data.readable.av;
|
||||
|
||||
import android.util.Base64;
|
||||
import android.util.Log;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import io.benwiegand.projection.geargrinder.proto.ProtoParser;
|
||||
import io.benwiegand.projection.geargrinder.proto.data.readable.ChannelMeta;
|
||||
|
||||
public interface AVChannelMeta extends ChannelMeta {
|
||||
|
||||
static AVChannelMeta parse(int channelId, byte[] buffer, int offset, int length) {
|
||||
final String TAG = AVChannelMeta.class.getSimpleName() + "::parse";
|
||||
|
||||
try {
|
||||
Map<Integer, List<ProtoParser.ProtoField>> fields = ProtoParser.parse(buffer, offset, length);
|
||||
|
||||
int type = ProtoParser.getSingleUnsignedInteger32(buffer, fields.get(1), -1);
|
||||
return switch (type) {
|
||||
case 1 -> AudioChannelMeta.parse(channelId, buffer, offset, length, fields);
|
||||
// idk what 2 is
|
||||
case 3 -> VideoChannelMeta.parse(channelId, buffer, offset, length, fields);
|
||||
case 0 -> { // none
|
||||
Log.wtf(TAG, "AV channel type none");
|
||||
yield null;
|
||||
}
|
||||
case -1 -> throw new AssertionError("AV channel type not defined");
|
||||
default -> throw new AssertionError("unknown AV channel type: " + type);
|
||||
};
|
||||
} catch (Throwable t) {
|
||||
Log.wtf(TAG, "failed to parse AVChannelMeta: " + Base64.encodeToString(buffer, offset, length, 0), t);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package io.benwiegand.projection.geargrinder.proto.data.readable.av;
|
||||
|
||||
import android.util.Base64;
|
||||
import android.util.Log;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import io.benwiegand.projection.geargrinder.proto.ProtoParser;
|
||||
|
||||
public record AVSetupResponse(
|
||||
Status status,
|
||||
int maxOutstandingAck,
|
||||
int[] acceptedPresets
|
||||
) {
|
||||
private static final String TAG = AVSetupResponse.class.getSimpleName();
|
||||
|
||||
public enum Status {
|
||||
UNKNOWN,
|
||||
NO_ERROR,
|
||||
ERROR,
|
||||
OK;
|
||||
|
||||
private static Status parse(int value) {
|
||||
return switch (value) {
|
||||
case 0 -> NO_ERROR;
|
||||
case 1 -> ERROR;
|
||||
case 2 -> OK;
|
||||
default -> UNKNOWN;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public static AVSetupResponse parse(byte[] buffer, int offset, int length) {
|
||||
try {
|
||||
Map<Integer, List<ProtoParser.ProtoField>> fields = ProtoParser.parse(buffer, offset, length);
|
||||
|
||||
return new AVSetupResponse(
|
||||
Status.parse(ProtoParser.getSingleUnsignedInteger32(buffer, fields.get(1), -1)),
|
||||
ProtoParser.getSingleUnsignedInteger32(buffer, fields.get(2), 1),
|
||||
ProtoParser.getUnsignedInteger32Array(buffer, fields.get(3))
|
||||
);
|
||||
|
||||
} catch (Throwable t) {
|
||||
Log.wtf(TAG, "failed to parse AVSetupResponse: " + Base64.encodeToString(buffer, offset, length, 0), t);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "AVSetupResponse{" +
|
||||
"status=" + status +
|
||||
", maxOutstandingAck=" + maxOutstandingAck +
|
||||
", acceptedPresets=" + Arrays.toString(acceptedPresets) +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package io.benwiegand.projection.geargrinder.proto.data.readable.av;
|
||||
|
||||
import android.util.Base64;
|
||||
import android.util.Log;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import io.benwiegand.projection.geargrinder.proto.ProtoParser;
|
||||
import io.benwiegand.projection.geargrinder.proto.data.readable.av.preset.AudioPreset;
|
||||
|
||||
public record AudioChannelMeta(
|
||||
int channelId,
|
||||
AudioType audioType,
|
||||
AudioPreset[] presets
|
||||
// calls not implemented yet
|
||||
) implements AVChannelMeta {
|
||||
private static final String TAG = AudioChannelMeta.class.getSimpleName();
|
||||
|
||||
public enum AudioType {
|
||||
UNKNOWN,
|
||||
SPEECH,
|
||||
SYSTEM,
|
||||
MEDIA,
|
||||
ALARM;
|
||||
|
||||
private static AudioType parse(int value) {
|
||||
if (value < 0) return UNKNOWN;
|
||||
if (value >= values().length) return UNKNOWN;
|
||||
return values()[value];
|
||||
}
|
||||
}
|
||||
|
||||
public static AudioChannelMeta parse(int channelId, byte[] buffer, int offset, int length, Map<Integer, List<ProtoParser.ProtoField>> fields) {
|
||||
try {
|
||||
|
||||
List<ProtoParser.ProtoField> audioPresetFields = fields.get(3);
|
||||
AudioPreset[] audioPresets = new AudioPreset[audioPresetFields == null ? 0 : audioPresetFields.size()];
|
||||
if (audioPresetFields != null) {
|
||||
int i = 0;
|
||||
for (ProtoParser.ProtoField audioPresetField : audioPresetFields) {
|
||||
if (audioPresetField instanceof ProtoParser.ProtoVarData vd) {
|
||||
audioPresets[i++] = AudioPreset.parse(buffer, vd.offset(), vd.length());
|
||||
} else {
|
||||
throw new AssertionError("expected var data for audio preset, got " + audioPresetField.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new AudioChannelMeta(
|
||||
channelId,
|
||||
AudioType.parse(ProtoParser.getSingleUnsignedInteger32(buffer, fields.get(2), -1)),
|
||||
audioPresets
|
||||
);
|
||||
|
||||
} catch (Throwable t) {
|
||||
Log.wtf(TAG, "failed to parse AudioChannelMeta: " + Base64.encodeToString(buffer, offset, length, 0), t);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "AudioChannelMeta{" +
|
||||
"channelId=" + channelId +
|
||||
", audioType=" + audioType +
|
||||
", presets=" + Arrays.toString(presets) +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package io.benwiegand.projection.geargrinder.proto.data.readable.av;
|
||||
|
||||
import android.util.Base64;
|
||||
import android.util.Log;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import io.benwiegand.projection.geargrinder.proto.ProtoParser;
|
||||
import io.benwiegand.projection.geargrinder.proto.data.readable.av.preset.VideoPreset;
|
||||
|
||||
public record VideoChannelMeta(
|
||||
int channelId,
|
||||
VideoPreset[] presets
|
||||
// calls not implemented yet
|
||||
) implements AVChannelMeta {
|
||||
private static final String TAG = VideoChannelMeta.class.getSimpleName();
|
||||
|
||||
public static VideoChannelMeta parse(int channelId, byte[] buffer, int offset, int length, Map<Integer, List<ProtoParser.ProtoField>> fields) {
|
||||
try {
|
||||
|
||||
List<ProtoParser.ProtoField> videoPresetFields = fields.get(4);
|
||||
VideoPreset[] videoPresets = new VideoPreset[videoPresetFields == null ? 0 : videoPresetFields.size()];
|
||||
if (videoPresetFields != null) {
|
||||
int i = 0;
|
||||
for (ProtoParser.ProtoField videoPresetField : videoPresetFields) {
|
||||
if (videoPresetField instanceof ProtoParser.ProtoVarData vd) {
|
||||
videoPresets[i++] = VideoPreset.parse(buffer, vd.offset(), vd.length());
|
||||
} else {
|
||||
throw new AssertionError("expected var data for audio preset");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new VideoChannelMeta(
|
||||
channelId,
|
||||
videoPresets
|
||||
);
|
||||
|
||||
} catch (Throwable t) {
|
||||
Log.wtf(TAG, "failed to parse VideoChannelMeta: " + Base64.encodeToString(buffer, offset, length, 0), t);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "VideoChannelMeta{" +
|
||||
"channelId=" + channelId +
|
||||
", presets=" + Arrays.toString(presets) +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package io.benwiegand.projection.geargrinder.proto.data.readable.av.preset;
|
||||
|
||||
import android.media.AudioFormat;
|
||||
import android.media.AudioRecord;
|
||||
import android.os.Build;
|
||||
import android.util.Base64;
|
||||
import android.util.Log;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import io.benwiegand.projection.geargrinder.proto.ProtoParser;
|
||||
|
||||
public record AudioPreset(
|
||||
int sampleRate,
|
||||
int bitDepth,
|
||||
int channelCount
|
||||
) {
|
||||
private static final String TAG = AudioPreset.class.getSimpleName();
|
||||
|
||||
public int audioFormatEncoding() {
|
||||
return switch(bitDepth()) {
|
||||
// TODO: 8, 24, and 32 are untested
|
||||
case 8 -> AudioFormat.ENCODING_PCM_8BIT;
|
||||
case 16 -> AudioFormat.ENCODING_PCM_16BIT;
|
||||
case 24 -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? AudioFormat.ENCODING_PCM_24BIT_PACKED : AudioFormat.ENCODING_INVALID;
|
||||
case 32 -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? AudioFormat.ENCODING_PCM_32BIT : AudioFormat.ENCODING_INVALID;
|
||||
default -> {
|
||||
// unlike mpeg, raw pcm audio is not very forgiving for discrepancies
|
||||
Log.e(TAG, "unsupported bit depth: " + bitDepth());
|
||||
yield AudioFormat.ENCODING_INVALID;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public int audioFormatChannelConfig() {
|
||||
return switch (channelCount()) {
|
||||
// surround sound is not supported yet
|
||||
case 1 -> AudioFormat.CHANNEL_IN_MONO;
|
||||
case 2 -> AudioFormat.CHANNEL_IN_STEREO;
|
||||
default -> {
|
||||
Log.e(TAG, "unsupported channel count: " + channelCount());
|
||||
yield AudioFormat.CHANNEL_INVALID;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public AudioFormat createAudioFormat() {
|
||||
return new AudioFormat.Builder()
|
||||
.setEncoding(audioFormatEncoding())
|
||||
.setSampleRate(sampleRate())
|
||||
.setChannelMask(audioFormatChannelConfig())
|
||||
.build();
|
||||
}
|
||||
|
||||
public int getMinBufferSize() {
|
||||
return AudioRecord.getMinBufferSize(
|
||||
sampleRate(),
|
||||
audioFormatChannelConfig(),
|
||||
audioFormatEncoding()
|
||||
);
|
||||
}
|
||||
|
||||
public boolean isSupported() {
|
||||
// does not guarantee that the sample rate is supported
|
||||
if (audioFormatEncoding() == AudioFormat.ENCODING_INVALID) return false;
|
||||
if (audioFormatChannelConfig() == AudioFormat.CHANNEL_INVALID) return false;
|
||||
return sampleRate() > 0;
|
||||
}
|
||||
|
||||
public static AudioPreset parse(byte[] buffer, int offset, int length) {
|
||||
try {
|
||||
Map<Integer, List<ProtoParser.ProtoField>> fields = ProtoParser.parse(buffer, offset, length);
|
||||
|
||||
return new AudioPreset(
|
||||
ProtoParser.getSingleUnsignedInteger32(buffer, fields.get(1), 0),
|
||||
ProtoParser.getSingleUnsignedInteger32(buffer, fields.get(2), 0),
|
||||
ProtoParser.getSingleUnsignedInteger32(buffer, fields.get(3), 0)
|
||||
);
|
||||
|
||||
} catch (Throwable t) {
|
||||
Log.wtf(TAG, "failed to parse AudioPreset: " + Base64.encodeToString(buffer, offset, length, 0), t);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
package io.benwiegand.projection.geargrinder.proto.data.readable.av.preset;
|
||||
|
||||
import android.util.Base64;
|
||||
import android.util.Log;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import io.benwiegand.projection.geargrinder.proto.ProtoParser;
|
||||
|
||||
public record VideoPreset(
|
||||
Resolution resolution,
|
||||
RefreshRate refreshRate,
|
||||
int marginHorizontal,
|
||||
int marginVertical,
|
||||
int density
|
||||
) {
|
||||
private static final String TAG = VideoPreset.class.getSimpleName();
|
||||
public static final int DEFAULT_RESOLUTION_WIDTH = 800;
|
||||
public static final int DEFAULT_RESOLUTION_HEIGHT = 480;
|
||||
public static final int DEFAULT_DENSITY_DPI = 200;
|
||||
public static final int DEFAULT_REFRESH_RATE_HZ = 24;
|
||||
|
||||
public enum Resolution {
|
||||
UNKNOWN,
|
||||
RES_480P,
|
||||
RES_720P,
|
||||
RES_1080P;
|
||||
|
||||
public int width() {
|
||||
return switch (this) {
|
||||
case UNKNOWN -> DEFAULT_RESOLUTION_WIDTH;
|
||||
case RES_480P -> 800;
|
||||
case RES_720P -> 1280;
|
||||
case RES_1080P -> 1920;
|
||||
};
|
||||
}
|
||||
|
||||
public int height() {
|
||||
return switch (this) {
|
||||
case UNKNOWN -> DEFAULT_RESOLUTION_HEIGHT;
|
||||
case RES_480P -> 480;
|
||||
case RES_720P -> 720;
|
||||
case RES_1080P -> 1080;
|
||||
};
|
||||
}
|
||||
|
||||
private static Resolution parse(int value) {
|
||||
if (value < 0) return UNKNOWN;
|
||||
if (value >= values().length) return UNKNOWN;
|
||||
return values()[value];
|
||||
}
|
||||
}
|
||||
|
||||
public enum RefreshRate {
|
||||
UNKNOWN,
|
||||
RATE_30HZ,
|
||||
RATE_60HZ;
|
||||
|
||||
public int hz() {
|
||||
return switch (this) {
|
||||
case UNKNOWN -> DEFAULT_REFRESH_RATE_HZ;
|
||||
case RATE_30HZ -> 30;
|
||||
case RATE_60HZ -> 60;
|
||||
};
|
||||
}
|
||||
|
||||
private static RefreshRate parse(int value) {
|
||||
if (value < 0) return UNKNOWN;
|
||||
if (value >= values().length) return UNKNOWN;
|
||||
return values()[value];
|
||||
}
|
||||
}
|
||||
|
||||
public int width() {
|
||||
return resolution().width();
|
||||
}
|
||||
|
||||
public int height() {
|
||||
return resolution().height();
|
||||
}
|
||||
|
||||
public static VideoPreset getDefault() {
|
||||
return new VideoPreset(
|
||||
Resolution.UNKNOWN,
|
||||
RefreshRate.UNKNOWN,
|
||||
0, 0,
|
||||
DEFAULT_DENSITY_DPI
|
||||
);
|
||||
}
|
||||
|
||||
public static VideoPreset parse(byte[] buffer, int offset, int length) {
|
||||
try {
|
||||
Map<Integer, List<ProtoParser.ProtoField>> fields = ProtoParser.parse(buffer, offset, length);
|
||||
|
||||
return new VideoPreset(
|
||||
Resolution.parse(ProtoParser.getSingleUnsignedInteger32(buffer, fields.get(1), -1)),
|
||||
RefreshRate.parse(ProtoParser.getSingleUnsignedInteger32(buffer, fields.get(2), -1)),
|
||||
ProtoParser.getSingleUnsignedInteger32(buffer, fields.get(3), 0),
|
||||
ProtoParser.getSingleUnsignedInteger32(buffer, fields.get(4), 0),
|
||||
ProtoParser.getSingleUnsignedInteger32(buffer, fields.get(5), DEFAULT_DENSITY_DPI)
|
||||
);
|
||||
|
||||
} catch (Throwable t) {
|
||||
Log.wtf(TAG, "failed to parse VideoPreset: " + Base64.encodeToString(buffer, offset, length, 0), t);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package io.benwiegand.projection.geargrinder.proto.data.readable.input;
|
||||
|
||||
import android.util.Base64;
|
||||
import android.util.Log;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import io.benwiegand.projection.geargrinder.proto.ProtoParser;
|
||||
import io.benwiegand.projection.geargrinder.proto.data.readable.ChannelMeta;
|
||||
|
||||
public record InputChannelMeta(
|
||||
int channelId,
|
||||
int[] keycodes,
|
||||
TouchInputMeta touchScreenMeta,
|
||||
TouchInputMeta touchPadMeta
|
||||
) implements ChannelMeta {
|
||||
private static final String TAG = InputChannelMeta.class.getSimpleName();
|
||||
|
||||
public boolean hasTouchScreen() {
|
||||
return touchScreenMeta() != null;
|
||||
}
|
||||
|
||||
public static InputChannelMeta parse(int channelId, byte[] buffer, int offset, int length) {
|
||||
try {
|
||||
Map<Integer, List<ProtoParser.ProtoField>> fields = ProtoParser.parse(buffer, offset, length);
|
||||
|
||||
int[] keyCodes = ProtoParser.getUnsignedInteger32Array(buffer, fields.get(1));
|
||||
|
||||
ProtoParser.ProtoVarData touchScreenMetaField = ProtoParser.getSingle(fields.get(2), ProtoParser.ProtoVarData.class);
|
||||
TouchInputMeta touchScreenMeta = touchScreenMetaField == null ? null : TouchInputMeta.parse(buffer, touchScreenMetaField.offset(), touchScreenMetaField.length());
|
||||
|
||||
ProtoParser.ProtoVarData touchPadMetaField = ProtoParser.getSingle(fields.get(3), ProtoParser.ProtoVarData.class);
|
||||
TouchInputMeta touchPadMeta = touchPadMetaField == null ? null : TouchInputMeta.parse(buffer, touchPadMetaField.offset(), touchPadMetaField.length());
|
||||
|
||||
return new InputChannelMeta(
|
||||
channelId,
|
||||
keyCodes,
|
||||
touchScreenMeta,
|
||||
touchPadMeta
|
||||
);
|
||||
|
||||
} catch (Throwable t) {
|
||||
Log.wtf(TAG, "failed to parse InputChannelMeta: " + Base64.encodeToString(buffer, offset, length, 0), t);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "InputChannelMeta{" +
|
||||
"channelId=" + channelId +
|
||||
", keycodes=" + Arrays.toString(keycodes) +
|
||||
", touchScreenMeta=" + touchScreenMeta +
|
||||
", touchPadMeta=" + touchPadMeta +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package io.benwiegand.projection.geargrinder.proto.data.readable.input;
|
||||
|
||||
import android.util.Base64;
|
||||
import android.util.Log;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import io.benwiegand.projection.geargrinder.proto.ProtoParser;
|
||||
import io.benwiegand.projection.geargrinder.proto.data.readable.input.event.ButtonEvent;
|
||||
import io.benwiegand.projection.geargrinder.proto.data.readable.input.event.TouchEvent;
|
||||
|
||||
public record InputEventData(
|
||||
long timestamp, // nanoseconds
|
||||
// TODO: field 2 (multi-display?)
|
||||
TouchEvent touchEvent,
|
||||
ButtonEvent[] buttonEvents
|
||||
// TODO: fields 5 and 6 (touch pad?)
|
||||
) {
|
||||
private static final String TAG = InputEventData.class.getSimpleName();
|
||||
|
||||
public static InputEventData parse(byte[] buffer, int offset, int length) {
|
||||
try {
|
||||
Map<Integer, List<ProtoParser.ProtoField>> fields = ProtoParser.parse(buffer, offset, length);
|
||||
|
||||
ProtoParser.ProtoVarData touchEventField = ProtoParser.getSingle(fields.get(3), ProtoParser.ProtoVarData.class);
|
||||
TouchEvent touchEvent = touchEventField == null ? null : TouchEvent.parse(buffer, touchEventField.offset(), touchEventField.length());
|
||||
|
||||
List<ProtoParser.ProtoField> buttonEventFields = fields.get(4);
|
||||
ButtonEvent[] buttonEvents = new ButtonEvent[buttonEventFields == null ? 0 : buttonEventFields.size()];
|
||||
if (buttonEventFields != null) {
|
||||
int i = 0;
|
||||
for (ProtoParser.ProtoField buttonEventField : buttonEventFields) {
|
||||
if (buttonEventField instanceof ProtoParser.ProtoVarData vd) {
|
||||
buttonEvents[i++] = ButtonEvent.parse(buffer, vd.offset(), vd.length());
|
||||
} else {
|
||||
throw new AssertionError("expected var data for button event, got " + buttonEventField.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new InputEventData(
|
||||
ProtoParser.getSingleUnsignedInteger(buffer, fields.get(1), 0),
|
||||
touchEvent,
|
||||
buttonEvents
|
||||
);
|
||||
|
||||
} catch (Throwable t) {
|
||||
Log.wtf(TAG, "failed to parse InputEventData: " + Base64.encodeToString(buffer, offset, length, 0), t);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "InputEventData{" +
|
||||
"timestamp=" + timestamp +
|
||||
", touchEvent=" + touchEvent +
|
||||
", buttonEvents=" + Arrays.toString(buttonEvents) +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package io.benwiegand.projection.geargrinder.proto.data.readable.input;
|
||||
|
||||
import android.util.Base64;
|
||||
import android.util.Log;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import io.benwiegand.projection.geargrinder.proto.ProtoParser;
|
||||
|
||||
public record TouchInputMeta(
|
||||
int width,
|
||||
int height
|
||||
// TODO: field 3 unknown. (multitouch?)
|
||||
) {
|
||||
private static final String TAG = TouchInputMeta.class.getSimpleName();
|
||||
|
||||
public static TouchInputMeta parse(byte[] buffer, int offset, int length) {
|
||||
try {
|
||||
Map<Integer, List<ProtoParser.ProtoField>> fields = ProtoParser.parse(buffer, offset, length);
|
||||
|
||||
int width = ProtoParser.getSingleUnsignedInteger32(buffer, fields.get(1), -1);
|
||||
int height = ProtoParser.getSingleUnsignedInteger32(buffer, fields.get(2), -1);
|
||||
|
||||
return new TouchInputMeta(
|
||||
width,
|
||||
height
|
||||
);
|
||||
|
||||
} catch (Throwable t) {
|
||||
Log.wtf(TAG, "failed to parse TouchInputMeta: " + Base64.encodeToString(buffer, offset, length, 0), t);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package io.benwiegand.projection.geargrinder.proto.data.readable.input.event;
|
||||
|
||||
public record ButtonEvent() {
|
||||
private static final String TAG = ButtonEvent.class.getSimpleName();
|
||||
|
||||
public static ButtonEvent parse(byte[] buffer, int offset, int length) {
|
||||
// TODO
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
package io.benwiegand.projection.geargrinder.proto.data.readable.input.event;
|
||||
|
||||
import android.util.Base64;
|
||||
import android.util.Log;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import io.benwiegand.projection.geargrinder.proto.ProtoParser;
|
||||
|
||||
public record TouchEvent(
|
||||
PointerLocation[] pointerLocations,
|
||||
int actionPointerIndex, // similar to MotionEvent.getActionIndex()
|
||||
Action action
|
||||
) {
|
||||
private static final String TAG = TouchEvent.class.getSimpleName();
|
||||
|
||||
public record PointerLocation(int x, int y, int pointerIndex) {
|
||||
public int xScaled(int srcWidth, int dstWidth) {
|
||||
return (dstWidth * x) / srcWidth;
|
||||
}
|
||||
|
||||
public int yScaled(int srcHeight, int dstHeight) {
|
||||
return (dstHeight * y) / srcHeight;
|
||||
}
|
||||
|
||||
public static PointerLocation parse(byte[] buffer, int offset, int length) {
|
||||
try {
|
||||
Map<Integer, List<ProtoParser.ProtoField>> fields = ProtoParser.parse(buffer, offset, length);
|
||||
|
||||
return new PointerLocation(
|
||||
ProtoParser.getSingleUnsignedInteger32(buffer, fields.get(1), 0),
|
||||
ProtoParser.getSingleUnsignedInteger32(buffer, fields.get(2), 0),
|
||||
ProtoParser.getSingleUnsignedInteger32(buffer, fields.get(3), 0)
|
||||
);
|
||||
|
||||
} catch (Throwable t) {
|
||||
Log.wtf(TAG, "failed to parse PointerLocation: " + Base64.encodeToString(buffer, offset, length, 0), t);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum Action {
|
||||
// appears to match MotionEvent actions
|
||||
|
||||
DOWN,
|
||||
UP,
|
||||
MOVE,
|
||||
CANCEL,
|
||||
OUTSIDE,
|
||||
// multitouch
|
||||
POINTER_DOWN,
|
||||
POINTER_UP;
|
||||
|
||||
private static Action parse(int value) {
|
||||
if (value < 0) return null;
|
||||
if (value >= values().length) return null;
|
||||
return values()[value];
|
||||
}
|
||||
}
|
||||
|
||||
public static TouchEvent parse(byte[] buffer, int offset, int length) {
|
||||
try {
|
||||
Map<Integer, List<ProtoParser.ProtoField>> fields = ProtoParser.parse(buffer, offset, length);
|
||||
|
||||
List<ProtoParser.ProtoField> pointerLocationFields = fields.get(1);
|
||||
PointerLocation[] pointerLocations = new PointerLocation[pointerLocationFields == null ? 0 : pointerLocationFields.size()];
|
||||
if (pointerLocationFields != null) {
|
||||
int i = 0;
|
||||
for (ProtoParser.ProtoField pointerLocationField : pointerLocationFields) {
|
||||
if (pointerLocationField instanceof ProtoParser.ProtoVarData vd) {
|
||||
pointerLocations[i++] = PointerLocation.parse(buffer, vd.offset(), vd.length());
|
||||
} else {
|
||||
throw new AssertionError("expected var data for pointer location, got " + pointerLocationField.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new TouchEvent(
|
||||
pointerLocations,
|
||||
ProtoParser.getSingleUnsignedInteger32(buffer, fields.get(2), 0),
|
||||
Action.parse(ProtoParser.getSingleUnsignedInteger32(buffer, fields.get(3), 0))
|
||||
);
|
||||
|
||||
} catch (Throwable t) {
|
||||
Log.wtf(TAG, "failed to parse TouchEvent: " + Base64.encodeToString(buffer, offset, length, 0), t);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "TouchEvent{" +
|
||||
"pointerLocations=" + Arrays.toString(pointerLocations) +
|
||||
", action=" + action +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package io.benwiegand.projection.geargrinder.proto.data.writable;
|
||||
|
||||
import io.benwiegand.projection.geargrinder.proto.ProtoSerializer;
|
||||
|
||||
public record ChannelOpenRequest(
|
||||
int priority,
|
||||
int channelId
|
||||
) {
|
||||
|
||||
public byte[] serialize() {
|
||||
return ProtoSerializer.serialize(
|
||||
new ProtoSerializer.Proto32(1, priority()),
|
||||
new ProtoSerializer.Proto32(2, channelId())
|
||||
);
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package io.benwiegand.projection.geargrinder.proto.data.writable;
|
||||
|
||||
import android.util.Base64;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import io.benwiegand.projection.geargrinder.proto.ProtoSerializer;
|
||||
|
||||
public record ServiceDiscoveryRequest(
|
||||
byte[] thumbnail32, // 1 32x32 png image
|
||||
byte[] thumbnail64, // 2 64x64 png image
|
||||
byte[] thumbnail128, // 3 128x128 png image
|
||||
String deviceName, // 4
|
||||
String deviceModel // 5
|
||||
) {
|
||||
public static final byte[] DEFAULT_THUMBNAIL_32 = Base64.decode("iVBORw0KGgoAAAANSUhEUgAAACAAAAAgAgMAAAAOFJJnAAAAAXNSR0IB2cksfwAAAARnQU1BAACxjwv8YQUAAAAgY0hSTQAAeiYAAICEAAD6AAAAgOgAAHUwAADqYAAAOpgAABdwnLpRPAAAAAxQTFRFAAAAAAAA////nZ2detZtKgAAAAF0Uk5TAEDm2GYAAABKSURBVBjTY2AgBBRgDA0YXwPCW4CFsQArowHKYMLK0EBhMCzAYKxawbRq1QogQ2vVKgiDYRVYEqSIAc5QgDEW4GNw4WIoQBhoAAACdyWFYS2uEwAAAABJRU5ErkJggg==", 0);
|
||||
public static final byte[] DEFAULT_THUMBNAIL_64 = Base64.decode("iVBORw0KGgoAAAANSUhEUgAAAEAAAABAAgMAAADXB5lNAAAAAXNSR0IB2cksfwAAAARnQU1BAACxjwv8YQUAAAAgY0hSTQAAeiYAAICEAAD6AAAAgOgAAHUwAADqYAAAOpgAABdwnLpRPAAAAAxQTFRFAAAAAAAA////nZ2detZtKgAAAAF0Uk5TAEDm2GYAAAAZdEVYdENvbW1lbnQAQ3JlYXRlZCB3aXRoIEdJTVBXgQ4XAAAAh0lEQVQ4y8XTSQqAMAwF0NCV5Cg9ZY+SZfintFZNYwRRcQgUyqMhA5To8+ASIF+DKX8D2UOSAHwdkp4B7MB3yjsQD7XNAGoAOoQSIL8O6+goHsRaR7uwQX0YQVsKIFgAKAktZsiwkLluh2WFPN17WerruwMSgJ8ADZB+hjYpeahn8GChtz7zCPorhEnnuPkFAAAAAElFTkSuQmCC", 0);
|
||||
public static final byte[] DEFAULT_THUMBNAIL_128 = Base64.decode("iVBORw0KGgoAAAANSUhEUgAAAIAAAACAAgMAAAC+UIlYAAAAAXNSR0IB2cksfwAAAARnQU1BAACxjwv8YQUAAAAgY0hSTQAAeiYAAICEAAD6AAAAgOgAAHUwAADqYAAAOpgAABdwnLpRPAAAAAxQTFRFAAAAAAAA////nZ2detZtKgAAAAF0Uk5TAEDm2GYAAAAZdEVYdENvbW1lbnQAQ3JlYXRlZCB3aXRoIEdJTVBXgQ4XAAAA80lEQVRYw+3XTQ6EIAwF4IYlR+GUrOcUZFbNO+WYOIz8tGAMiibzlvJpAtIGiP65f+Db4wbhyeA7PRXEgROBVYHlDnAYBvwgYAXgLwe+WuN9wM0ELgKaCUgDBgJwCVjxDFAuMpC9OQdwtoINsO5m3BnEsnfjgWmCIAF/GLAGggqMBPK64B3g17REgBRAAqEJlmdIwRKuQNjaX0xIKx94rT8kCeegDqd/qwOoC2wPUDlQzCL9xLvYF8VEuNo4ZRfcA8JBgBMAy8CqgEcD0oGnqsXMAq4P/HRQnnKA60FdOX8gnuEmgO1ErlxyDIbfo7q3wSflA6NL5p69Qoa3AAAAAElFTkSuQmCC", 0);
|
||||
public static final String DEFAULT_DEVICE_NAME = "Geargrinder";
|
||||
public static final String DEFAULT_DEVICE_MODEL = "geargrinder automotive projection app";
|
||||
|
||||
public static ServiceDiscoveryRequest getDefault() {
|
||||
return new ServiceDiscoveryRequest(
|
||||
DEFAULT_THUMBNAIL_32,
|
||||
DEFAULT_THUMBNAIL_64,
|
||||
DEFAULT_THUMBNAIL_128,
|
||||
DEFAULT_DEVICE_NAME,
|
||||
DEFAULT_DEVICE_MODEL
|
||||
);
|
||||
}
|
||||
|
||||
public byte[] serialize() {
|
||||
return ProtoSerializer.serialize(
|
||||
new ProtoSerializer.ProtoVarData(1, thumbnail32()),
|
||||
new ProtoSerializer.ProtoVarData(2, thumbnail64()),
|
||||
new ProtoSerializer.ProtoVarData(3, thumbnail128()),
|
||||
new ProtoSerializer.ProtoVarData(4, deviceName().getBytes(StandardCharsets.UTF_8)),
|
||||
new ProtoSerializer.ProtoVarData(5, deviceModel().getBytes(StandardCharsets.UTF_8))
|
||||
);
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package io.benwiegand.projection.geargrinder.proto.data.writable.av;
|
||||
|
||||
import io.benwiegand.projection.geargrinder.proto.ProtoSerializer;
|
||||
|
||||
public record AVSetupRequest(
|
||||
int type
|
||||
) {
|
||||
|
||||
// TODO: unsure if this is correct
|
||||
public static int AV_SETUP_REQUEST_TYPE_AUDIO = 1;
|
||||
public static int AV_SETUP_REQUEST_TYPE_VIDEO = 3;
|
||||
|
||||
public static AVSetupRequest createAudio() {
|
||||
return new AVSetupRequest(AV_SETUP_REQUEST_TYPE_AUDIO);
|
||||
}
|
||||
|
||||
public static AVSetupRequest createVideo() {
|
||||
return new AVSetupRequest(AV_SETUP_REQUEST_TYPE_VIDEO);
|
||||
}
|
||||
|
||||
public byte[] serialize() {
|
||||
return ProtoSerializer.serialize(
|
||||
new ProtoSerializer.Proto32(1, type())
|
||||
);
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package io.benwiegand.projection.geargrinder.proto.data.writable.av;
|
||||
|
||||
import io.benwiegand.projection.geargrinder.proto.ProtoSerializer;
|
||||
|
||||
public record AVStartIndication(
|
||||
int session,
|
||||
int preset
|
||||
) {
|
||||
|
||||
public byte[] serialize() {
|
||||
return ProtoSerializer.serialize(
|
||||
new ProtoSerializer.Proto32(1, session()),
|
||||
new ProtoSerializer.Proto32(2, preset())
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package io.benwiegand.projection.geargrinder.protocol;
|
||||
|
||||
public class AAConstants {
|
||||
|
||||
// channels
|
||||
// as far as I can tell, this is the only hard-coded channel id
|
||||
// other channel ids are defined in the ServiceDiscoveryResponse
|
||||
public static final int CHANNEL_CONTROL = 0x00;
|
||||
|
||||
// control messages
|
||||
public static final int CMD_VERSION_REQUEST = 0x0001;
|
||||
public static final int CMD_VERSION_RESPONSE = 0x0002;
|
||||
public static final int CMD_SSL_HANDSHAKE = 0x0003;
|
||||
public static final int CMD_AUTH_COMPLETE = 0x0004;
|
||||
public static final int CMD_SERVICE_DISCOVERY_REQUEST = 0x0005;
|
||||
public static final int CMD_SERVICE_DISCOVERY_RESPONSE = 0x0006;
|
||||
public static final int CMD_PING_REQUEST = 0x000b;
|
||||
public static final int CMD_PING_RESPONSE = 0x000c;
|
||||
|
||||
|
||||
// all channels
|
||||
public static final int CMD_CHANNEL_OPEN_REQUEST = 0x0007;
|
||||
public static final int CMD_CHANNEL_OPEN_RESPONSE = 0x0008;
|
||||
|
||||
|
||||
// audio/video channel messages
|
||||
public static final int AV_CMD_MEDIA_WITH_TIMESTAMP = 0x0000;
|
||||
public static final int AV_CMD_MEDIA = 0x0001;
|
||||
public static final int AV_CMD_SETUP_REQUEST = 0x8000;
|
||||
public static final int AV_CMD_START = 0x8001;
|
||||
public static final int AV_CMD_STOP = 0x8002;
|
||||
public static final int AV_CMD_SETUP_RESPONSE = 0x8003;
|
||||
public static final int AV_CMD_MEDIA_ACK = 0x8004;
|
||||
public static final int AV_CMD_VIDEO_FOCUS_REQUEST = 0x8007;
|
||||
public static final int AV_CMD_VIDEO_FOCUSED = 0x8008;
|
||||
|
||||
// input channel messages
|
||||
public static final int INPUT_CMD_EVENT = 0x8001;
|
||||
public static final int INPUT_CMD_BINDING_REQUEST = 0x8002;
|
||||
public static final int INPUT_CMD_BINDING_RESPONSE = 0x8003;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package io.benwiegand.projection.geargrinder.shell;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
// the most basic root shell you can get
|
||||
// TODO: better shell
|
||||
public class RootShell {
|
||||
private static final String TAG = RootShell.class.getSimpleName();
|
||||
|
||||
private final Process process;
|
||||
|
||||
public RootShell() throws IOException {
|
||||
process = new ProcessBuilder("su").start();
|
||||
}
|
||||
|
||||
public void writeLine(String line) throws IOException {
|
||||
Log.d(TAG, "writing line: " + line);
|
||||
OutputStream os = process.getOutputStream();
|
||||
os.write((line + "\n").getBytes(StandardCharsets.UTF_8));
|
||||
os.flush();
|
||||
}
|
||||
|
||||
public void destroy() {
|
||||
process.destroyForcibly();
|
||||
}
|
||||
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package io.benwiegand.projection.geargrinder.transfer;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public interface AATransferInterface {
|
||||
|
||||
/**
|
||||
* checks the status of the underlying connection
|
||||
* @return true if alive, false if not
|
||||
*/
|
||||
boolean alive();
|
||||
|
||||
/**
|
||||
* sends an entire AA frame over the underlying byte stream
|
||||
* @param buffer input data buffer
|
||||
* @param offset offset of input data
|
||||
* @param length length of input data
|
||||
* @throws IOException if the connection died
|
||||
*/
|
||||
void sendFrame(byte[] buffer, int offset, int length) throws IOException;
|
||||
|
||||
/**
|
||||
* reads an entire AA frame from the underlying byte stream, blocking if needed
|
||||
* @param buffer output
|
||||
* @return the number of bytes in the message
|
||||
* @throws IOException if the connection died
|
||||
*/
|
||||
int readFrame(byte[] buffer) throws IOException;
|
||||
|
||||
/**
|
||||
* closes the connection
|
||||
*/
|
||||
void close() throws IOException;
|
||||
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package io.benwiegand.projection.geargrinder.transfer;
|
||||
|
||||
import android.os.ParcelFileDescriptor;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
|
||||
// uses USB bulk transfers to separate AA messages
|
||||
// other implementations may need to leverage the 16-bit payload size in each message
|
||||
public class UsbTransferInterface implements AATransferInterface {
|
||||
private final ParcelFileDescriptor pfd;
|
||||
private final InputStream is;
|
||||
private final OutputStream os;
|
||||
private final int maxTransferSize;
|
||||
|
||||
private boolean alive = true;
|
||||
|
||||
public UsbTransferInterface(ParcelFileDescriptor pfd, InputStream is, OutputStream os, int maxTransferSize) {
|
||||
this.pfd = pfd;
|
||||
this.is = is;
|
||||
this.os = os;
|
||||
this.maxTransferSize = maxTransferSize;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean alive() {
|
||||
return alive;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sendFrame(byte[] buffer, int offset, int length) throws IOException {
|
||||
assert length <= maxTransferSize;
|
||||
try {
|
||||
os.write(buffer, offset, length);
|
||||
} catch (IOException e) {
|
||||
alive = false;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int readFrame(byte[] buffer) throws IOException {
|
||||
try {
|
||||
int len = is.read(buffer); // one usb bulk transfer per message
|
||||
if (len < 0) throw new IOException("stream closed (" + len + ")");
|
||||
return len;
|
||||
} catch (IOException e) {
|
||||
alive = false;
|
||||
throw e;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public void close() throws IOException {
|
||||
try {
|
||||
pfd.close();
|
||||
} finally {
|
||||
alive = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package io.benwiegand.projection.geargrinder.util;
|
||||
|
||||
public class ByteUtil {
|
||||
|
||||
private static final char[] HEX_DIGITS = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'};
|
||||
private static final char[] HEX_DIGITS_UPPER = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'};
|
||||
|
||||
public static String hexDump(byte[] buffer, int offset, int length, String separator, boolean upper) {
|
||||
char[] digits = upper ? HEX_DIGITS_UPPER : HEX_DIGITS;
|
||||
|
||||
StringBuilder sb = new StringBuilder((length - offset) * (2 + separator.length()));
|
||||
for (int i = offset; i < length; i++) {
|
||||
byte b = buffer[i];
|
||||
sb.append(digits[(0xF0 & b) >>> 4])
|
||||
.append(digits[0x0F & b]);
|
||||
|
||||
if (i != (buffer.length - offset) - 1) sb.append(separator);
|
||||
}
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
public static String hexDump(byte[] buffer, int offset, int length) {
|
||||
return hexDump(buffer, offset, length, " ", false);
|
||||
}
|
||||
|
||||
public static String hexDump(byte[] buffer) {
|
||||
return hexDump(buffer, 0, buffer.length);
|
||||
}
|
||||
|
||||
public static int unsignByte(byte b) {
|
||||
return Byte.toUnsignedInt(b);
|
||||
}
|
||||
|
||||
public static int writeUInt16(int value, byte[] buffer, int offset) {
|
||||
assert value >= 0;
|
||||