initial commit

This commit is contained in:
Benjamin Wiegand
2026-02-10 14:07:55 -08:00
commit 96c35d65b6
101 changed files with 8094 additions and 0 deletions
+15
View File
@@ -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
+674
View File
@@ -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>.
+71
View File
@@ -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/)
+3
View File
@@ -0,0 +1,3 @@
/build
/release
/debug
+43
View File
@@ -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)
}
+21
View File
@@ -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
@@ -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());
}
}
+85
View File
@@ -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>
@@ -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];
}
}
}
@@ -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);
}
}
}
@@ -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);
}
@@ -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;
}
}
@@ -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("========================================================");
}
}
}
@@ -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";
}
}
@@ -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);
}
@@ -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");
}
}
}
@@ -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);
}
}
@@ -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());
}
}
};
}
@@ -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;
}
}
@@ -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;
}
}
}
@@ -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;
}
}
}
@@ -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;
}
}
}
@@ -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;
}
}
}
@@ -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) +
'}';
}
}
@@ -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) +
'}';
}
}
@@ -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) +
'}';
}
}
@@ -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;
}
}
}
@@ -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;
}
}
}
@@ -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 +
'}';
}
}
@@ -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) +
'}';
}
}
@@ -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;
}
}
}
@@ -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;
}
}
@@ -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 +
'}';
}
}
@@ -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())
);
}
}
@@ -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))
);
}
}
@@ -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())
);
}
}
@@ -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();
}
}
@@ -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;
}
@@ -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;