Gearslip: phone-side Android Auto client

This commit is contained in:
seb committed 2026-09-20 02:09:47 -07:00
commit 24cfbee0c4
98 files changed
+12370

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
*.iml
.gradle/
.idea/
.DS_Store
.kotlin
/build
local.properties
# Reference checkouts only - never referenced by the build; safe to delete entirely.
/cloned-repos
# The head unit certificate. Never committed or shared beyond who Seb sends it to directly.
/cert
# Local investigation script; not meant for the public repo.
/docs/cert-sweep.sh
archive/
CLAUDE.md
+663
View File
@@ -0,0 +1,663 @@
Copyright (C) 2026 seb
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 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 Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are 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.
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.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
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 Affero 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. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
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 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 work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero 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 Affero 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 Affero 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 Affero 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 Affero 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 Affero General Public License for more details.
You should have received a copy of the GNU Affero 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 your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
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 AGPL, see
<https://www.gnu.org/licenses/>.
+16
View File
@@ -0,0 +1,16 @@
# License
Gearslip is licensed under the **GNU Affero General Public License v3.0** (see `LICENSE`).
In short: you're free to use, run, and modify it. If you modify it — or run a modified
version as a service others use over a network — the AGPL requires you to make your source
changes available on the same terms. That obligation follows the code even when you don't
redistribute a binary, which is what makes the AGPL fit for something that can run as a
background service.
## Commercial licensing
If the AGPL's terms don't work for you — for example, you want to use Gearslip privately
without being required to publish your changes — a separate commercial license is available
directly from the author. Terms (including any restrictions on modification) are set on a
case-by-case basis. Contact me though Matrix (on my profile) to discuss one.
+56
View File
@@ -0,0 +1,56 @@
<img src="docs/icon/gearslip-icon.png" alt="Gearslip icon" width="96" height="96">
# Gearslip
Bring any app to your car screen.
## What this is
Gearslip is a phone-side Android Auto client. It speaks the Android Auto protocol directly
to a car head unit over USB and acts as a **Car App Library host**: apps that ship an
Android Auto car UI (navigation and media apps) hand Gearslip a template, and Gearslip
renders it natively on the car screen, full quality, no phone-screen mirroring. A separate
screen-sharing mode is included for apps that don't ship a car UI at all.
Built by reverse-engineering the protocol for interoperability - getting a non-Google client
talking to a car head unit that only expects Google's own app.
## Status
Real progress, with one hard limit that's worth knowing before you build on this:
- **Templated app hosting works.** All 16 template types the Car App Library defines are
rendered, including a keyboard Gearslip draws itself for search and sign-in. Verified
against several real navigation and media apps.
- **Media playback works.** Car-enabled media apps are browsable and controllable from the
car UI, with lyrics and playback state, over the same host connection.
- **The connection to a real head unit needs a certificate Google controls, and Gearslip
doesn't ship one.** A head unit only accepts a phone identity that chains to Google's
Automotive Link certificate authority - something only Google issues. Without one, Gearslip
still runs and renders everything correctly against a debug harness, but a real car will
reject the connection outright. See [SPIKE_FINDINGS.md](SPIKE_FINDINGS.md) for what was
tested, including one publicly-known leaked certificate that works on some older head
units and is rejected by newer firmware and by Google's own reference tooling.
- **No adb, root, or Shizuku is required to run it.** Every feature is gated on an ordinary
Android permission or an on-device consent dialog - nothing needs privileged access, on
the phone or in the car.
## Building
```
./gradlew :gearslip:assembleDebug
```
The APK lands at `build/Gearslip-debug.apk`.
## Further reading
- [SPIKE_FINDINGS.md](SPIKE_FINDINGS.md) - the certificate investigation in full: what was
tried, what a real head unit does and doesn't accept, and why.
- [CUSTOM_HEADUNIT_SCOPING.md](CUSTOM_HEADUNIT_SCOPING.md) - the original scoping of what a
custom head unit client would need.
## License
AGPL-3.0 - see [LICENSE](LICENSE). A separate commercial license is available for private
use outside the AGPL's terms; see [NOTICE.md](NOTICE.md).
+7
View File
@@ -0,0 +1,7 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" width="432" height="432" viewBox="0 0 108 108">
<defs>
<linearGradient id="trail" x1="73.4" y1="46.0" x2="95.8" y2="44.5" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#8C1F1F" stop-opacity="0"/>
<stop offset="100%" stop-color="#8C1F1F" stop-opacity="0.7"/>
</linearGradient>
</defs>
<rect width="108" height="108" fill="#0c0c0d"/>
<circle cx="54" cy="54" r="52" fill="#131415"/>
<path d="M 73.23,47.15 L 94.77,51.90 L 96.89,37.05 L 73.57,44.78 Z" fill="url(#trail)"/>
<path d="M 54.00,27.00 L 59.99,19.91 Q 61.02,18.69 62.50,19.30 L 72.52,23.45 Q 74.00,24.07 73.87,25.66 L 73.23,33.31 Q 73.09,34.91 73.70,36.39 L 80.39,52.52 Q 81.00,54.00 82.22,55.03 L 88.09,59.99 Q 89.31,61.02 88.70,62.50 L 84.55,72.52 Q 83.93,74.00 82.34,73.87 L 73.09,73.09 Q 73.09,73.09 73.09,73.09 L 73.09,73.09 Q 73.09,73.09 73.09,73.09 L 73.87,82.34 Q 74.00,83.93 72.52,84.55 L 62.50,88.70 Q 61.02,89.31 59.99,88.09 L 54.00,81.00 Q 54.00,81.00 54.00,81.00 L 54.00,81.00 Q 54.00,81.00 54.00,81.00 L 48.01,88.09 Q 46.98,89.31 45.50,88.70 L 35.48,84.55 Q 34.00,83.93 34.13,82.34 L 34.91,73.09 Q 34.91,73.09 34.91,73.09 L 34.91,73.09 Q 34.91,73.09 34.91,73.09 L 25.66,73.87 Q 24.07,74.00 23.45,72.52 L 19.30,62.50 Q 18.69,61.02 19.91,59.99 L 27.00,54.00 Q 27.00,54.00 27.00,54.00 L 27.00,54.00 Q 27.00,54.00 27.00,54.00 L 19.91,48.01 Q 18.69,46.98 19.30,45.50 L 23.45,35.48 Q 24.07,34.00 25.66,34.13 L 34.91,34.91 Q 34.91,34.91 34.91,34.91 L 34.91,34.91 Q 34.91,34.91 34.91,34.91 L 34.13,25.66 Q 34.00,24.07 35.48,23.45 L 45.50,19.30 Q 46.98,18.69 48.01,19.91 L 54.00,27.00 Q 54.00,27.00 54.00,27.00 L 54.00,27.00 Q 54.00,27.00 54.00,27.00 Z" fill="#8B9096"/>
<path d="M 92.78,34.42 L 99.94,37.20 Q 101.43,37.78 101.34,39.37 L 100.67,50.20 Q 100.58,51.80 99.03,52.19 L 91.58,54.08 Q 90.03,54.47 90.13,52.87 L 91.19,35.44 Q 91.29,33.84 92.78,34.42 Z" fill="#8C1F1F"/>
<circle cx="54" cy="54" r="12" fill="#0c0c0d"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" width="432" height="432" viewBox="0 0 108 108">
<defs>
<linearGradient id="trail" x1="73.4" y1="46.0" x2="95.8" y2="44.5" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#8C1F1F" stop-opacity="0"/>
<stop offset="100%" stop-color="#8C1F1F" stop-opacity="0.7"/>
</linearGradient>
</defs>
<rect width="108" height="108" fill="#0c0c0d"/>
<circle cx="54" cy="54" r="52" fill="#131415"/>
<path d="M 73.23,47.15 L 94.77,51.90 L 96.89,37.05 L 73.57,44.78 Z" fill="url(#trail)"/>
<path d="M 54.00,27.00 L 61.02,18.69 L 74.00,24.07 L 73.09,34.91 L 81.00,54.00 L 89.31,61.02 L 83.93,74.00 L 73.09,73.09 L 73.09,73.09 L 74.00,83.93 L 61.02,89.31 L 54.00,81.00 L 54.00,81.00 L 46.98,89.31 L 34.00,83.93 L 34.91,73.09 L 34.91,73.09 L 24.07,74.00 L 18.69,61.02 L 27.00,54.00 L 27.00,54.00 L 18.69,46.98 L 24.07,34.00 L 34.91,34.91 L 34.91,34.91 L 34.00,24.07 L 46.98,18.69 L 54.00,27.00 Z" fill="#8B9096"/>
<path d="M 91.29,33.84 L 101.43,37.78 L 100.58,51.80 L 90.03,54.47 Z" fill="#8C1F1F"/>
<circle cx="54" cy="54" r="12" fill="#0c0c0d"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+176
View File
@@ -0,0 +1,176 @@
import math
def pt(cx, cy, r, deg):
a = math.radians(deg - 90)
return (cx + r*math.cos(a), cy + r*math.sin(a))
def gear_points(cx, cy, teeth, outer_r, inner_r, tooth_frac=0.5):
slice_w = 360.0 / teeth
gap = slice_w * (1 - tooth_frac)
pts = []
for i in range(teeth):
start = i * slice_w
a1 = start + gap/2
a2 = a1 + slice_w*tooth_frac
pts.append(pt(cx, cy, inner_r, start))
pts.append(pt(cx, cy, outer_r, a1))
pts.append(pt(cx, cy, outer_r, a2))
pts.append(pt(cx, cy, inner_r, start + slice_w))
return pts
def path_from_pts(pts):
d = f"M {pts[0][0]:.2f},{pts[0][1]:.2f} "
for p in pts[1:]:
d += f"L {p[0]:.2f},{p[1]:.2f} "
return d + "Z"
def rotate(pts, ocx, ocy, deg):
a = math.radians(deg)
out = []
for x, y in pts:
x0, y0 = x-ocx, y-ocy
out.append((x0*math.cos(a)-y0*math.sin(a)+ocx, x0*math.sin(a)+y0*math.cos(a)+ocy))
return out
def scale_pts(pts, ocx, ocy, s):
return [((x-ocx)*s+ocx, (y-ocy)*s+ocy) for x, y in pts]
cx, cy = 54, 54
teeth = 8
outer_r = 36
inner_r = 27
all_pts = gear_points(cx, cy, teeth, outer_r, inner_r)
broken_idx = 1
body_pts = [p for i, p in enumerate(all_pts) if not (4*broken_idx <= i < 4*broken_idx+4)]
tooth_pts = all_pts[4*broken_idx:4*broken_idx+4]
tooth_cx = sum(p[0] for p in tooth_pts)/4
tooth_cy = sum(p[1] for p in tooth_pts)/4
dx, dy = 14, 2
final_pts = rotate([(x+dx, y+dy) for x, y in tooth_pts], tooth_cx+dx, tooth_cy+dy, 26)
final_cx, final_cy = tooth_cx+dx, tooth_cy+dy
trail_half_w_near = 1.2
trail_half_w_far = 7.5
perp = (-dy, dx)
plen = math.hypot(*perp)
perp = (perp[0]/plen, perp[1]/plen)
gap_angle = math.atan2(tooth_cy - cy, tooth_cx - cx)
extend_r = inner_r - 6
gap_x = cx + math.cos(gap_angle) * extend_r
gap_y = cy + math.sin(gap_angle) * extend_r
near1 = (gap_x + perp[0]*trail_half_w_near, gap_y + perp[1]*trail_half_w_near)
near2 = (gap_x - perp[0]*trail_half_w_near, gap_y - perp[1]*trail_half_w_near)
far1 = (final_cx + perp[0]*trail_half_w_far, final_cy + perp[1]*trail_half_w_far)
far2 = (final_cx - perp[0]*trail_half_w_far, final_cy - perp[1]*trail_half_w_far)
trail_pts = [near1, far1, far2, near2]
hub_r = 12
# --- scale everything down to fit the adaptive-icon safe zone (66dp diameter circle) ---
SCALE = 0.66
def S(pts):
return scale_pts(pts, cx, cy, SCALE)
body_s = S(body_pts)
final_s = S(final_pts)
trail_s = S(trail_pts)
hub_r_s = hub_r * SCALE
gap_x_s, gap_y_s = scale_pts([(gap_x, gap_y)], cx, cy, SCALE)[0]
final_cx_s, final_cy_s = scale_pts([(final_cx, final_cy)], cx, cy, SCALE)[0]
body_path = path_from_pts(body_s)
final_path = path_from_pts(final_s)
trail_path = path_from_pts(trail_s)
GREY = "#8B9096"
RED = "#8C1F1F"
BG = "#0c0c0d"
RING = "#131415"
# ===== 1. Background: full-bleed, unscaled =====
bg_xml = f'''<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:pathData="M0,0h108v108h-108z"
android:fillColor="{BG}"/>
<path
android:pathData="M54,2 A52,52 0 1,1 53.99,2 Z"
android:fillColor="{RING}"/>
</vector>
'''
# ===== 2. Foreground: gear + broken piece + trail, scaled into the safe zone =====
# Trail uses a gradient (narrow near the gear, fading out) via an <aapt:attr> gradient,
# same visual as the mockup.
fg_xml = f'''<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="{trail_path}">
<aapt:attr name="android:fillColor">
<gradient
android:type="linear"
android:startX="{gap_x_s:.2f}"
android:startY="{gap_y_s:.2f}"
android:endX="{final_cx_s:.2f}"
android:endY="{final_cy_s:.2f}">
<item android:offset="0" android:color="#00{RED[1:]}"/>
<item android:offset="1" android:color="{RED}"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="{body_path}"
android:fillColor="{GREY}"/>
<path
android:pathData="{final_path}"
android:fillColor="{RED}"/>
<path
android:pathData="M{cx},{cy-hub_r_s:.2f} A{hub_r_s:.2f},{hub_r_s:.2f} 0 1,1 {cx-0.01:.2f},{cy-hub_r_s:.2f} Z"
android:fillColor="{BG}"/>
</vector>
'''
# ===== 3. Monochrome: same silhouette, single alpha-bearing color for Material You
# theming. The system substitutes its own tint - the fillColor value here is
# irrelevant, only the shape/alpha matters. Trail kept at a lower alpha so the
# fade effect survives tinting too.
mono_xml = f'''<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:pathData="{trail_path}"
android:fillColor="#FF000000"
android:fillAlpha="0.35"/>
<path
android:pathData="{body_path}"
android:fillColor="#FF000000"/>
<path
android:pathData="{final_path}"
android:fillColor="#FF000000"/>
<path
android:pathData="M{cx},{cy-hub_r_s:.2f} A{hub_r_s:.2f},{hub_r_s:.2f} 0 1,1 {cx-0.01:.2f},{cy-hub_r_s:.2f} Z"
android:fillColor="#00000000"/>
</vector>
'''
out = '/tmp/claude-1000/-home-seb-AABrowser/dadb0938-9338-4349-968f-4a89bc8de777/scratchpad/icon'
open(f'{out}/ic_launcher_background.xml', 'w').write(bg_xml)
open(f'{out}/ic_launcher_foreground.xml', 'w').write(fg_xml)
open(f'{out}/ic_launcher_monochrome.xml', 'w').write(mono_xml)
print("written")
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

+10
View File
@@ -0,0 +1,10 @@
# Build output is redirected to <root>/build/gearslip/ (see build.gradle.kts), already
# covered by the root .gitignore's /build - this is just a safety net in case that ever
# changes back.
build/
# User-supplied certificate material - never checked in (see CertProvider.kt).
phone.p12
phone.pass
*.crt
*.key
+84
View File
@@ -0,0 +1,84 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
/**
* Gearslip: a phone-side Android Auto client built against the reverse-engineered AA wire
* protocol, so a non-Google app can authenticate to and project onto a real car head unit
* with no gearhead in the path. No third-party dependencies: the X.509 certificate is
* generated by hand (see `SelfSignedCert`) so there is no BouncyCastle version to track and
* no second crypto provider to collide with Conscrypt.
*/
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "app.seb3thehacker.gearslip"
compileSdk = 37
defaultConfig {
applicationId = "app.seb3thehacker.gearslip"
minSdk = 35
targetSdk = 37
versionCode = 1
versionName = "0.1"
}
// Keeps build output out of the module folder: <root>/build/gearslip/... instead of
// <root>/gearslip/build/..., so everything this project produces lands under one
// root-level build/ directory.
layout.buildDirectory.set(rootProject.layout.buildDirectory.dir("gearslip"))
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
testOptions {
// GearslipLog calls android.util.Log, which is a stub on the local JVM.
unitTests.isReturnDefaultValues = true
}
}
// Renames the produced APK and moves it to the root build/ folder (<root>/build/Gearslip-debug.apk)
// after the fact rather than fighting AGP's variant-output API across versions - simpler and
// doesn't break when that API moves again.
tasks.register("renameApk") {
doLast {
val rootBuildDir = rootProject.layout.buildDirectory.get().asFile
layout.buildDirectory.dir("outputs/apk").get().asFile.walkTopDown()
.filter { it.extension == "apk" }
.toList()
.forEach { apk ->
val renamed = File(rootBuildDir, "Gearslip-${apk.parentFile.name}.apk")
apk.copyTo(renamed, overwrite = true)
apk.delete()
}
}
}
tasks.matching { it.name.startsWith("assemble") }.configureEach { finalizedBy("renameApk") }
dependencies {
// Only for the XML window theme (Theme.Material3.DynamicColors); every screen is Compose.
implementation(libs.google.material)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons.core)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
// Used from the host side, not the app side: Gearslip renders other apps' templates rather
// than publishing its own, so this is here for the AIDL interfaces and the model classes
// their Bundleables deserialize into.
implementation(libs.androidx.car.app)
implementation(libs.androidx.car.app.projected)
testImplementation(libs.junit)
}
tasks.withType<KotlinJvmCompile>().configureEach {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
}
}
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<!-- Debug builds only: renders the car UI to PNGs so it can be checked without a car. -->
<activity android:name="app.seb3thehacker.gearslip.CarPreviewActivity" android:exported="true" />
</application>
</manifest>
@@ -0,0 +1,106 @@
package app.seb3thehacker.gearslip
import android.graphics.Bitmap
import android.graphics.PixelFormat
import android.hardware.display.DisplayManager
import android.media.ImageReader
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.MotionEvent
import androidx.activity.ComponentActivity
import app.seb3thehacker.gearslip.car.CarEnvironment
import app.seb3thehacker.gearslip.car.CarSettings
import app.seb3thehacker.gearslip.car.CarUi
import java.io.File
import java.io.FileOutputStream
/**
* Debug-only harness: runs the real car UI on a virtual display (the same path a head unit
* gets), taps where told, and saves what it rendered as PNGs in the external files dir.
*
* adb shell am start -n app.seb3thehacker.gearslip/.CarPreviewActivity \
* --ei w 800 --ei h 480 --ei dpi 160 --es taps "400,440;90,440"
*
* Writes preview-0.png (before any tap), then one more per tap.
*/
class CarPreviewActivity : ComponentActivity() {
private val main = Handler(Looper.getMainLooper())
private var projector: ScreenProjector? = null
private var reader: ImageReader? = null
@Volatile private var latest: Bitmap? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
GearslipLog.init(this)
CarSettings.init(this)
CarEnvironment.start(this)
val w = intent.getIntExtra("w", 800)
val h = intent.getIntExtra("h", 480)
val dpi = intent.getIntExtra("dpi", 160)
val taps = intent.getStringExtra("taps").orEmpty().split(";").filter { it.isNotBlank() }
.map { it.split(",").let { p -> p[0].toFloat() to p[1].toFloat() } }
val imageReader = ImageReader.newInstance(w, h, PixelFormat.RGBA_8888, 3)
reader = imageReader
imageReader.setOnImageAvailableListener({ r ->
r.acquireLatestImage()?.use { image ->
val plane = image.planes[0]
val padded = plane.rowStride / plane.pixelStride
val bmp = Bitmap.createBitmap(padded, h, Bitmap.Config.ARGB_8888)
bmp.copyPixelsFromBuffer(plane.buffer)
latest = Bitmap.createBitmap(bmp, 0, 0, w, h)
}
}, main)
val p = ScreenProjector(this).also { projector = it }
p.start(imageReader.surface, w, h, dpi) { CarUi() }
GearslipLog.i("preview: displayId=${p.displayId}")
if (intent.getBooleanExtra("hide", false)) p.setOverlayVisible(false)
intent.getStringExtra("launch")?.let { pkg ->
main.postDelayed({
packageManager.getLaunchIntentForPackage(pkg)?.let { p.launchApp(it) }
?: GearslipLog.e("preview: no launch intent for $pkg")
}, 1500)
}
var step = 0
fun snap() {
val bmp = latest ?: return GearslipLog.e("preview: no frame yet for step $step")
val out = File(getExternalFilesDir(null), "preview-$step.png")
FileOutputStream(out).use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) }
GearslipLog.i("preview: wrote ${out.name}")
step++
}
// Free-running mode: keep snapshotting on a timer so the display can be driven from
// outside (adb `am start --display` / `input -d`) while the harness records it.
val snaps = intent.getIntExtra("snaps", 0)
if (snaps > 0) {
val every = intent.getIntExtra("every", 3000).toLong()
repeat(snaps) { i -> main.postDelayed({ snap() }, 2000L + every * i) }
return
}
main.postDelayed({
snap()
taps.forEachIndexed { i, (x, y) ->
main.postDelayed({
p.dispatchTouch(MotionEvent.ACTION_DOWN, x, y)
main.postDelayed({ p.dispatchTouch(MotionEvent.ACTION_UP, x, y) }, 80)
}, 2500L * (i + 1))
main.postDelayed({ snap() }, 2500L * (i + 1) + 1500)
}
}, 2000)
}
override fun onDestroy() {
super.onDestroy()
projector?.stop()
reader?.close()
CarEnvironment.stop()
}
}
+132
View File
@@ -0,0 +1,132 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The projected WebView loads real pages; without this every load fails silently. -->
<uses-permission android:name="android.permission.INTERNET" />
<!--
Phone mirroring. Android refuses to hand over a MediaProjection unless a foreground
service of this type is already running, so MirrorService exists purely to satisfy that
and to give the driver a way to stop the capture from the notification shade.
-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<!-- Capturing a media app's playback (to send to the car) is gated on this by the platform. -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!--
Declared, not just requested. Templated apps decide whether to talk to a host by asking
whether it holds this permission (HostValidator.isValidHost checks it before falling back
to a signature allow-list containing Android Auto's). It is defined by the car framework
on Android Automotive, which does not exist on a phone, so nothing else defines it here
and Gearslip has to define it to be able to hold it.
-->
<permission
android:name="android.car.permission.TEMPLATE_RENDERER"
android:protectionLevel="normal" />
<uses-permission android:name="android.car.permission.TEMPLATE_RENDERER" />
<uses-feature android:name="android.hardware.usb.accessory" android:required="true" />
<queries>
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
<!-- Templated apps are invisible to us without this, and the catalog comes back empty. -->
<intent>
<action android:name="androidx.car.app.CarAppService" />
</intent>
<!-- Media apps for the car: found by their browser service. -->
<intent>
<action android:name="android.media.browse.MediaBrowserService" />
</intent>
</queries>
<application
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher"
android:label="Gearslip"
android:supportsRtl="true"
android:theme="@style/Theme.Gearslip">
<!--
singleTop: the head unit can re-attach while we are already foregrounded, which
arrives via onNewIntent rather than a fresh instance.
-->
<activity
android:name=".GearslipActivity"
android:exported="true"
android:launchMode="singleTop"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|uiMode|density|locale|layoutDirection|fontScale">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.hardware.usb.action.USB_ACCESSORY_ATTACHED" />
</intent-filter>
<meta-data
android:name="android.hardware.usb.action.USB_ACCESSORY_ATTACHED"
android:resource="@xml/accessory_filter" />
</activity>
<service
android:name=".mirror.MirrorService"
android:exported="false"
android:foregroundServiceType="mediaProjection" />
<service
android:name=".host.LocationKeepAlive"
android:exported="false"
android:foregroundServiceType="location" />
<!-- Holds the capture session that carries a media app's audio to the car. -->
<service
android:name=".audio.AudioCaptureService"
android:exported="false"
android:foregroundServiceType="mediaProjection" />
<!--
Touch on the car screen is replayed onto the phone through this service.
dispatchGesture is the only public way for an app to inject input into another app,
and it is unavailable until the user enables Gearslip under Settings > Accessibility.
-->
<service
android:name=".mirror.TouchRelayService"
android:exported="true"
android:label="Gearslip car touch"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/touch_relay" />
</service>
<!--
Reads the phone's notifications so the car can show and answer them. Inert until the
user grants Notification access to Gearslip in the phone's settings.
-->
<service
android:name=".notify.GearslipNotificationListener"
android:exported="true"
android:label="Gearslip notifications"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
</application>
</manifest>
@@ -0,0 +1,28 @@
package app.seb3thehacker.gearslip
import android.content.Context
/** User-facing preferences. Read fresh on every session, so changes apply on the next connect. */
object AppSettings {
private const val PREFS = "gearslip_settings"
private const val KEY_STARTUP_URL = "startup_url"
private fun prefs(context: Context) =
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
fun getString(context: Context, key: String, default: String): String =
prefs(context).getString(key, default) ?: default
fun putString(context: Context, key: String, value: String) {
prefs(context).edit().putString(key, value).apply()
}
/** Blank means "use the built-in test page". */
fun startupUrl(context: Context): String =
prefs(context).getString(KEY_STARTUP_URL, "").orEmpty()
fun setStartupUrl(context: Context, value: String) {
prefs(context).edit().putString(KEY_STARTUP_URL, value.trim()).apply()
}
}
@@ -0,0 +1,140 @@
package app.seb3thehacker.gearslip
import android.content.Context
import android.net.Uri
import java.io.File
import java.security.KeyStore
import java.security.cert.X509Certificate
/**
* Supplies the certificate the phone presents to the head unit.
*
* FOSS posture: this code ships with NO certificate. Whoever wants to test the trusted-cert
* path supplies their own. Sources, in priority order:
*
* 1. **Imported** in the app (Settings > Certificate): a PKCS#12 file plus its password,
* copied into the app's private storage.
* 2. **Staged over adb** into the app's external files directory, the way the spike has
* always done it. Still honoured, so existing setups keep working untouched:
*
* openssl pkcs12 -export -in headunit.crt -inkey headunit.key \
* -name phone -passout pass:aaspike -out phone.p12
* adb push phone.p12 /sdcard/Android/data/app.seb3thehacker.gearslip/files/phone.p12
*
* Default password is "aaspike" (kept as-is so an already-staged phone.p12 doesn't need
* regenerating); a `phone.pass` file next to it overrides it.
* 3. A freshly generated self-signed cert, which a real head unit rejects (see
* SPIKE_FINDINGS.md).
*/
object CertProvider {
private const val P12_NAME = "phone.p12"
private const val PASS_NAME = "phone.pass"
private const val DEFAULT_PASSWORD = "aaspike"
private const val IMPORT_DIR = "identity"
enum class Kind { IMPORTED, ADB_STAGED, SELF_SIGNED }
class Identity(
val keyStore: KeyStore,
val certificate: X509Certificate,
val password: CharArray,
val source: String,
val kind: Kind = Kind.SELF_SIGNED,
)
/** Loaded fresh each call, so a newly imported or pushed cert takes effect on the next connect. */
fun load(context: Context): Identity = loadFromFiles(context) ?: run {
val generated = SelfSignedCert.generate()
Identity(
generated.keyStore,
generated.certificate,
SelfSignedCert.PASSWORD,
"self-signed (generated) - a real head unit rejects this",
Kind.SELF_SIGNED,
)
}
/**
* The active identity without the cost of generating a self-signed key: null means "nothing
* supplied, a self-signed one would be generated". For the UI, which only wants to describe it.
*/
fun loadSupplied(context: Context): Identity? = loadFromFiles(context)
private fun loadFromFiles(context: Context): Identity? {
val imported = importedFile(context, P12_NAME)
if (imported.isFile) {
runCatching {
return loadP12(imported, importedPassword(context), "imported certificate", Kind.IMPORTED)
}.onFailure { GearslipLog.e("could not load the imported certificate - trying the next source", it) }
}
val dir = context.getExternalFilesDir(null)
val staged = dir?.let { File(it, P12_NAME) }
if (dir != null && staged != null && staged.isFile) {
runCatching {
val passFile = File(dir, PASS_NAME)
val password = if (passFile.isFile) {
passFile.readText().lineSequence().firstOrNull().orEmpty().trim()
} else {
DEFAULT_PASSWORD
}
return loadP12(staged, password, "$P12_NAME staged over adb", Kind.ADB_STAGED)
}.onFailure { GearslipLog.e("could not load $P12_NAME - falling back to self-signed", it) }
}
return null
}
/**
* Validates [uri] as a PKCS#12 identity with [password] and, only if it is good, makes it
* the active certificate. A wrong password or a file with no private key throws and
* leaves whatever was active before untouched.
*/
fun importFrom(context: Context, uri: Uri, password: String): Identity {
val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
?: throw IllegalArgumentException("could not open the selected file")
val staging = File(context.cacheDir, "import-$P12_NAME")
try {
staging.writeBytes(bytes)
val identity = loadP12(staging, password, "imported certificate", Kind.IMPORTED)
val target = importedFile(context, P12_NAME)
target.parentFile?.mkdirs()
staging.copyTo(target, overwrite = true)
importedFile(context, PASS_NAME).writeText(password)
GearslipLog.i("imported a certificate: ${identity.certificate.subjectX500Principal}")
return identity
} finally {
staging.delete()
}
}
fun hasImported(context: Context): Boolean = importedFile(context, P12_NAME).isFile
/** Drops the imported identity; the next source in line (adb-staged, then self-signed) takes over. */
fun removeImported(context: Context) {
importedFile(context, P12_NAME).delete()
importedFile(context, PASS_NAME).delete()
GearslipLog.i("removed the imported certificate")
}
// The imported files live in the app's private internal storage (not the external files
// dir), so other apps cannot read the key, and `adb uninstall` clearing it is expected.
private fun importedFile(context: Context, name: String) = File(File(context.filesDir, IMPORT_DIR), name)
private fun importedPassword(context: Context): String =
importedFile(context, PASS_NAME).takeIf { it.isFile }?.readText().orEmpty()
private fun loadP12(file: File, password: String, label: String, kind: Kind): Identity {
val chars = password.toCharArray()
val keyStore = KeyStore.getInstance("PKCS12")
file.inputStream().use { keyStore.load(it, chars) }
val alias = keyStore.aliases().asSequence().firstOrNull { keyStore.isKeyEntry(it) }
?: throw IllegalStateException("the file contains no private-key entry")
val certificate = keyStore.getCertificate(alias) as X509Certificate
return Identity(keyStore, certificate, chars, "$label (alias=$alias)", kind)
}
}
@@ -0,0 +1,96 @@
package app.seb3thehacker.gearslip
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* Proves the H.264 encoder actually produces a bitstream on this device, before a drive.
*
* Without this, an unsupported colour format, a rejected Baseline profile, or a null
* getInputImage() would all present in the car as "the head unit shows nothing" - the same
* symptom as a protocol fault, and impossible to tell apart from the log. Same reasoning as
* the TLS self-test: make our own failures loud on the bench.
*/
object EncoderSelfTest {
fun run(width: Int = 800, height: Int = 480, frameRate: Int = 30): Boolean {
GearslipLog.i("=== encoder self-test starting (${width}x$height @ ${frameRate}fps) ===")
var codecConfig: ByteArray? = null
var frames = 0
var sawKeyFrame = false
var sawResyncKeyFrame = false
var resyncRequestedAtFrame = -1
val done = CountDownLatch(1)
val source = VideoSource(
width = width,
height = height,
frameRate = frameRate,
onCodecConfig = { codecConfig = it },
onFrame = { _, _, keyFrame ->
frames++
if (keyFrame) sawKeyFrame = true
// At frame 5, ask for a resync keyframe - the same call sendVideoFrame makes
// after a drop - and confirm THIS encoder actually honours it. Trusting an
// API we have never exercised on this device is exactly the mistake that
// cost two drives already (the CONTROL message-type bit, the touch scaling).
if (frames == 5) {
resyncRequestedAtFrame = frames
}
if (resyncRequestedAtFrame in 1 until frames && keyFrame) {
sawResyncKeyFrame = true
}
if (frames >= 12) done.countDown()
},
)
return try {
source.start()
// Give it a couple of frames before requesting resync, matching how it is used
// for real: the request always follows at least one already-encoded frame.
Thread {
while (resyncRequestedAtFrame == -1) Thread.sleep(20)
source.requestSyncFrame()
}.apply { isDaemon = true; start() }
done.await(8, TimeUnit.SECONDS)
source.stop()
val csd = codecConfig
when {
csd == null -> {
GearslipLog.e("encoder self-test FAILED: no CODEC_CONFIG (SPS/PPS) produced")
false
}
frames == 0 -> {
GearslipLog.e("encoder self-test FAILED: no frames produced")
false
}
!sawKeyFrame -> {
GearslipLog.e("encoder self-test FAILED: no keyframe - the car needs one first")
false
}
!sawResyncKeyFrame -> {
GearslipLog.e(
"encoder self-test FAILED: requestSyncFrame() produced no keyframe - " +
"the resync-after-drop fix would silently do nothing on this device",
)
false
}
else -> {
GearslipLog.i(
"SPS/PPS = ${csd.size} bytes, $frames frames, keyframe present, " +
"requestSyncFrame() confirmed working",
)
GearslipLog.hex(" csd", csd, limit = 48)
GearslipLog.i("=== encoder self-test PASSED ===")
true
}
}
} catch (t: Throwable) {
runCatching { source.stop() }
GearslipLog.e("encoder self-test FAILED with an exception", t)
false
}
}
}
@@ -0,0 +1,135 @@
package app.seb3thehacker.gearslip
import java.io.ByteArrayOutputStream
/**
* Android Auto frame layout, transcribed from aasdk.
*
* FrameHeader::getData (src/Messenger/FrameHeader.cpp:47-55),
* FrameSize::getData (src/Messenger/FrameSize.cpp:49-62) and
* MessageOutStream::compoundFrame (src/Messenger/MessageOutStream.cpp:100-120):
*
* byte 0 channel id
* byte 1 frameType | encryption | messageType
* bytes 2-3 uint16 BE payload length of THIS frame
* bytes 4-7 uint32 BE total message length - present ONLY when frameType == FIRST
* payload uint16 BE message id, then the body
*/
object Frames {
const val CHANNEL_CONTROL = 0
const val TYPE_MIDDLE = 0
const val TYPE_FIRST = 1
const val TYPE_LAST = 2
const val TYPE_BULK = 3
const val ENCRYPTED = 1 shl 3
/**
* Message type bit. Channel-open messages are CONTROL even when sent on a service
* channel - aasdk sets MessageType::CONTROL only in sendChannelOpenResponse and
* MessageType::SPECIFIC everywhere else (e.g.
* src/Channel/MediaSink/Video/VideoMediaSinkService.cpp:52 vs :66). Getting this wrong
* makes the head unit drop the USB link outright rather than answer.
*/
const val MESSAGE_SPECIFIC = 0
const val MESSAGE_CONTROL = 1 shl 2
/** aasdk splits at this size (MessageOutStream.hpp:62). Nothing the spike sends is close. */
const val MAX_FRAME_PAYLOAD = 0x4000
class Frame(
val channel: Int,
val frameType: Int,
val encrypted: Boolean,
val messageType: Int,
val payload: ByteArray,
)
/**
* Builds one frame. [totalSize] is the full plaintext message length and is written only
* for FIRST frames, matching aasdk's MessageOutStream::setFrameSize
* (`FrameSize(payloadSize, totalSize)` for FIRST, `FrameSize(payloadSize)` otherwise).
*/
fun build(
channel: Int,
encrypted: Boolean,
payload: ByteArray,
messageType: Int = MESSAGE_SPECIFIC,
frameType: Int = TYPE_BULK,
totalSize: Int? = null,
): ByteArray {
val flags = frameType or messageType or (if (encrypted) ENCRYPTED else 0)
val header = byteArrayOf(
channel.toByte(),
flags.toByte(),
((payload.size shr 8) and 0xFF).toByte(),
(payload.size and 0xFF).toByte(),
)
if (frameType != TYPE_FIRST || totalSize == null) return header + payload
val extended = byteArrayOf(
((totalSize shr 24) and 0xFF).toByte(),
((totalSize shr 16) and 0xFF).toByte(),
((totalSize shr 8) and 0xFF).toByte(),
(totalSize and 0xFF).toByte(),
)
return header + extended + payload
}
/**
* Pulls whole frames out of an accumulating buffer.
*
* A read() on the accessory descriptor returns one bulk transfer, which is usually one
* frame - but nothing guarantees it, so this never assumes alignment.
*/
class Parser {
private var pending = ByteArray(0)
fun append(data: ByteArray, length: Int) {
val grown = ByteArray(pending.size + length)
System.arraycopy(pending, 0, grown, 0, pending.size)
System.arraycopy(data, 0, grown, pending.size, length)
pending = grown
}
fun next(): Frame? {
if (pending.size < 4) return null
val channel = pending[0].toInt() and 0xFF
val flags = pending[1].toInt() and 0xFF
val frameType = flags and TYPE_BULK
// Extended size is written only for FIRST - BULK (3) keeps the short header.
val headerLength = if (frameType == TYPE_FIRST) 8 else 4
if (pending.size < headerLength) return null
val frameSize = ((pending[2].toInt() and 0xFF) shl 8) or (pending[3].toInt() and 0xFF)
val total = headerLength + frameSize
if (pending.size < total) return null
val payload = pending.copyOfRange(headerLength, total)
pending = pending.copyOfRange(total, pending.size)
return Frame(
channel, frameType, (flags and ENCRYPTED) != 0, flags and MESSAGE_CONTROL, payload,
)
}
}
/** Rejoins FIRST/MIDDLE/LAST runs. Payloads arrive already decrypted. */
class Assembler {
private val partial = HashMap<Int, ByteArrayOutputStream>()
fun offer(frame: Frame, payload: ByteArray): ByteArray? = when (frame.frameType) {
TYPE_BULK -> payload
TYPE_FIRST -> {
partial[frame.channel] = ByteArrayOutputStream().apply { write(payload) }
null
}
TYPE_MIDDLE -> {
partial[frame.channel]?.write(payload)
null
}
TYPE_LAST -> partial.remove(frame.channel)?.apply { write(payload) }?.toByteArray()
else -> null
}
}
}
@@ -0,0 +1,298 @@
package app.seb3thehacker.gearslip
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.hardware.usb.UsbAccessory
import android.hardware.usb.UsbManager
import android.media.projection.MediaProjection
import android.media.projection.MediaProjectionManager
import android.os.Bundle
import android.os.ParcelFileDescriptor
import android.view.MotionEvent
import android.view.Surface
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
import android.content.res.Configuration
import app.seb3thehacker.gearslip.audio.AudioCaptureService
import app.seb3thehacker.gearslip.audio.CarAudio
import app.seb3thehacker.gearslip.mirror.MirrorService
import app.seb3thehacker.gearslip.mirror.PhoneMirror
import app.seb3thehacker.gearslip.mirror.TouchRelayService
import app.seb3thehacker.gearslip.car.CarEnvironment
import app.seb3thehacker.gearslip.car.CarSettings
import app.seb3thehacker.gearslip.car.CarUi
import app.seb3thehacker.gearslip.ui.GearslipApp
import app.seb3thehacker.gearslip.ui.GearslipTheme
import java.io.FileInputStream
import java.io.FileOutputStream
/**
* Hosts the phone UI and, for now, the car session itself.
*
* Launched by the system when the head unit switches the phone into accessory mode. The
* session lives in this activity, so [android:configChanges] in the manifest keeps a
* rotation or theme change from recreating it and dropping the connection.
*/
class GearslipActivity : ComponentActivity(), Projection {
private var descriptor: ParcelFileDescriptor? = null
private var runner: GearslipRunner? = null
private var screenProjector: ScreenProjector? = null
private var worker: Thread? = null
/** The encoder's input Surface, kept so the car's picture can be switched to the mirror. */
private var surface: Surface? = null
private var frameWidth = 0
private var frameHeight = 0
private var frameDensity = 0
/**
* Screen capture always begins with a consent dialog, which only an activity can show -
* so the car UI asks, and the request surfaces here on the phone.
*/
private val captureConsent = registerForActivityResult(StartActivityForResult()) { result ->
val data = result.data
if (result.resultCode == RESULT_OK && data != null) {
MirrorService.start(this, result.resultCode, data)
} else {
GearslipLog.w("screen capture consent declined")
}
}
/** Media audio needs the microphone permission (a platform rule for playback capture) first. */
private val audioPermission = registerForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) askAudioCapture() else {
GearslipLog.w("audio: record permission declined")
CarAudio.denied()
}
}
private val audioConsent = registerForActivityResult(StartActivityForResult()) { result ->
val data = result.data
if (result.resultCode == RESULT_OK && data != null) {
AudioCaptureService.start(this, result.resultCode, data)
} else {
GearslipLog.w("audio: capture consent declined")
CarAudio.denied()
}
}
private val detachReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == UsbManager.ACTION_USB_ACCESSORY_DETACHED) {
GearslipLog.w("accessory detached")
closeAccessory()
}
}
}
/** Hosted map apps need Gearslip to hold location so they can keep it in the background. */
private val locationPermission = registerForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
) { }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION) !=
android.content.pm.PackageManager.PERMISSION_GRANTED
) locationPermission.launch(android.Manifest.permission.ACCESS_FINE_LOCATION)
enableEdgeToEdge()
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
GearslipLog.init(this)
GearslipLog.i("Gearslip ready")
CarSettings.init(this)
CarEnvironment.setPhoneTheme(resources.configuration)
setContent {
GearslipTheme {
GearslipApp(onDisconnect = ::closeAccessory)
}
}
PhoneMirror.onStartRequested = { runOnUiThread { requestCapture() } }
PhoneMirror.onTokenReady = { token -> runOnUiThread { showMirror(token) } }
PhoneMirror.onEnded = { runOnUiThread { showCarUi() } }
CarAudio.onConsentRequested = { runOnUiThread { requestAudioCapture() } }
registerReceiver(detachReceiver, IntentFilter(UsbManager.ACTION_USB_ACCESSORY_DETACHED), RECEIVER_NOT_EXPORTED)
handleIntent(intent)
}
// The manifest opts out of recreation on uiMode changes, so a light/dark switch on the phone
// arrives here, and the car UI follows it.
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
CarEnvironment.setPhoneTheme(newConfig)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIntent(intent)
}
override fun onDestroy() {
super.onDestroy()
runCatching { unregisterReceiver(detachReceiver) }
PhoneMirror.onStartRequested = null
PhoneMirror.onTokenReady = null
PhoneMirror.onEnded = null
CarAudio.onConsentRequested = null
CarAudio.release()
PhoneMirror.requestStop()
closeAccessory()
GearslipLog.flush()
}
// --- Media audio --------------------------------------------------------------------
private fun requestAudioCapture() {
if (checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) == android.content.pm.PackageManager.PERMISSION_GRANTED) {
askAudioCapture()
} else {
audioPermission.launch(android.Manifest.permission.RECORD_AUDIO)
}
}
private fun askAudioCapture() {
val manager = getSystemService(MediaProjectionManager::class.java)
runCatching { audioConsent.launch(manager.createScreenCaptureIntent()) }
.onFailure {
GearslipLog.e("could not ask for audio capture", it)
CarAudio.denied()
}
}
// --- Phone mirroring ----------------------------------------------------------------
private fun requestCapture() {
val manager = getSystemService(MediaProjectionManager::class.java)
runCatching { captureConsent.launch(manager.createScreenCaptureIntent()) }
.onFailure { GearslipLog.e("could not ask for screen capture", it) }
}
/** A Surface has a single producer, so our own display must go before the mirror arrives. */
private fun showMirror(token: MediaProjection) {
val target = surface ?: return
screenProjector?.stop()
screenProjector = null
PhoneMirror.begin(token, target, frameWidth, frameHeight, frameDensity)
if (PhoneMirror.active.value) PhoneMirror.launchPending(this) else showCarUi()
}
private fun showCarUi() {
val target = surface ?: return
if (screenProjector != null) return
val projector = ScreenProjector(this).also { screenProjector = it }
projector.start(target, frameWidth, frameHeight, frameDensity) { CarUi() }
}
/**
* Sends a head-unit touch to the phone. Anything landing on the mirror's letterbox padding
* maps to nothing on the phone and is dropped rather than clamped onto a screen edge.
*/
private fun relayTouch(action: Int, x: Float, y: Float) {
val point = PhoneMirror.mapToPhone(this, x, y)
if (point == null) {
// The letterbox bars belong to no window, which makes them a safe way back: a tap
// on the padding leaves the mirror and returns the car to Gearslip's own UI.
if (action == MotionEvent.ACTION_UP) PhoneMirror.requestStop()
return
}
val relay = TouchRelayService.instance ?: return
when (action) {
MotionEvent.ACTION_DOWN -> relay.down(point.x, point.y)
MotionEvent.ACTION_MOVE -> relay.move(point.x, point.y)
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> relay.up(point.x, point.y)
}
}
private fun handleIntent(intent: Intent?) {
if (intent?.action != UsbManager.ACTION_USB_ACCESSORY_ATTACHED) return
val accessory = intent.getParcelableExtra(UsbManager.EXTRA_ACCESSORY, UsbAccessory::class.java)
if (accessory == null) {
GearslipLog.e("attach intent carried no accessory")
SessionStatus.failed("Accessory missing", "The system reported a USB attach but did not include the accessory.")
return
}
GearslipLog.i("accessory attached by the system - permission is implicit on this path")
start(accessory)
}
private fun start(accessory: UsbAccessory) {
if (worker?.isAlive == true) {
GearslipLog.w("a run is already in progress")
return
}
describe(accessory)
val manager = getSystemService(UsbManager::class.java)
val pfd = manager.openAccessory(accessory)
if (pfd == null) {
GearslipLog.e("openAccessory returned null - the session was claimed by someone else")
SessionStatus.failed("Accessory unavailable", "Another app claimed the USB accessory before Gearslip could open it.")
return
}
descriptor = pfd
val runner = GearslipRunner(
input = FileInputStream(pfd.fileDescriptor),
output = FileOutputStream(pfd.fileDescriptor),
identityProvider = { CertProvider.load(this) },
projection = this,
vehicleProfileFor = { info -> VehicleProfiles.find(VehicleProfiles.load(this), info) },
)
this.runner = runner
worker = Thread { runner.run() }.apply { name = "gearslip"; start() }
}
private fun describe(accessory: UsbAccessory) {
GearslipLog.i("accessory: manufacturer=${accessory.manufacturer} model=${accessory.model}")
GearslipLog.i(" version=${accessory.version} description=${accessory.description}")
}
private fun closeAccessory() {
runner?.stop()
runCatching { descriptor?.close() }
descriptor = null
runner = null
}
// --- Projection ---------------------------------------------------------------------
override fun onSurfaceReady(surface: Surface, width: Int, height: Int, densityDpi: Int) {
runOnUiThread {
this.surface = surface
frameWidth = width
frameHeight = height
frameDensity = densityDpi
CarEnvironment.setDisplay(width, height, densityDpi)
CarEnvironment.start(this)
showCarUi()
}
}
override fun onTouch(action: Int, x: Float, y: Float) {
runOnUiThread {
if (PhoneMirror.active.value) relayTouch(action, x, y)
else screenProjector?.dispatchTouch(action, x, y)
}
}
override fun onProjectionStopped() {
runOnUiThread {
PhoneMirror.requestStop()
screenProjector?.stop()
screenProjector = null
surface = null
CarEnvironment.stop()
}
}
}
@@ -0,0 +1,120 @@
package app.seb3thehacker.gearslip
import android.content.Context
import android.util.Log
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
/**
* Logs to logcat and to a file at once.
*
* The file copy is the safety net: entering USB accessory mode tears down adb-over-USB, so
* if wireless adb drops in the car the file is the only surviving record of the run. It
* lives under getExternalFilesDir, which `adb pull` can reach without root.
*/
object GearslipLog {
const val TAG = "Gearslip"
private val stamp = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
private val io = Executors.newSingleThreadExecutor()
private const val MAX_LIVE_LINES = 3000
private var file: File? = null
// What the live log screen shows. Bounded, and separate from the file, which always keeps
// everything. The flow carries only a version number so a burst of lines costs the UI one
// snapshot per frame rather than one list copy per line.
private val live = ArrayDeque<String>()
private val liveVersion = MutableStateFlow(0)
val version: StateFlow<Int> = liveVersion
fun init(context: Context) {
val dir = context.getExternalFilesDir(null) ?: return
val name = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
file = File(dir, "gearslip-$name.log")
i("log file: ${file?.absolutePath}")
}
fun snapshot(): List<String> = synchronized(live) { live.toList() }
/** Clears the live view only. The log file on disk is left alone. */
fun clearLive() {
synchronized(live) { live.clear() }
liveVersion.update { it + 1 }
}
/** The whole current session's file, for sharing. Falls back to the live buffer. */
fun fullText(): String {
flush()
val f = file
return if (f != null && f.isFile) f.readText() else snapshot().joinToString("\n")
}
fun i(message: String) = write("I", message)
fun w(message: String) = write("W", message)
fun e(message: String, t: Throwable? = null) {
write("E", if (t == null) message else "$message: ${t.javaClass.simpleName}: ${t.message}")
if (t != null) Log.e(TAG, message, t)
}
/** Unmissable in a scrolling logcat - this is what the whole spike exists to print. */
fun verdict(headline: String, detail: String) {
val bar = "=".repeat(60)
i(bar)
i("VERDICT: $headline")
i(detail)
i(bar)
// The verdict is the one line the run exists to produce, and the app may be killed
// the moment the accessory detaches. Do not leave it sitting in the write queue.
flush()
}
/** Blocks until everything queued has actually reached the file. */
fun flush(timeoutMs: Long = 2_000) {
val latch = CountDownLatch(1)
runCatching { io.execute { latch.countDown() } }.onFailure { return }
runCatching { latch.await(timeoutMs, TimeUnit.MILLISECONDS) }
}
fun hex(label: String, data: ByteArray, limit: Int = 256) {
val shown = minOf(data.size, limit)
val sb = StringBuilder()
for (idx in 0 until shown) {
sb.append(String.format("%02X", data[idx]))
if (idx % 16 == 15) sb.append('\n') else sb.append(' ')
}
val suffix = if (data.size > limit) "\n... (${data.size - limit} more bytes)" else ""
i("$label (${data.size} bytes)\n$sb$suffix")
}
private fun write(level: String, message: String) {
when (level) {
"W" -> Log.w(TAG, message)
"E" -> Log.e(TAG, message)
else -> Log.i(TAG, message)
}
val line = "${stamp.format(Date())} $level $message"
synchronized(live) {
live.addLast(line)
while (live.size > MAX_LIVE_LINES) live.removeFirst()
}
liveVersion.update { it + 1 }
val target = file ?: return
io.execute {
runCatching { target.appendText(line + "\n") }
}
}
}
@@ -0,0 +1,775 @@
package app.seb3thehacker.gearslip
import android.os.Build
import app.seb3thehacker.gearslip.audio.AudioLink
import app.seb3thehacker.gearslip.car.CarEnvironment
import java.io.InputStream
import java.io.OutputStream
/**
* Drives the phone side of the control-channel handshake far enough to answer one question:
* does this head unit accept a self-signed certificate from the phone?
*
* The head unit initiates (aasdk's ControlServiceChannel::sendVersionRequest), so until
* step 5 this is purely reactive:
*
* 1 HU -> phone VersionRequest id 1 plain
* 2 phone -> HU VersionResponse id 2 plain
* 3 both EncapsulatedSSL id 3 plain (repeats until TLS completes)
* 4 HU -> phone AuthComplete id 4 plain <- the answer
* 5 phone -> HU ServiceDiscoveryRequest id 5 ENCRYPTED
* 6 HU -> phone ServiceDiscoveryResponse id 6 ENCRYPTED <- the confirmation
*/
class GearslipRunner(
private val input: InputStream,
private val output: OutputStream,
private val identityProvider: () -> CertProvider.Identity,
private val projection: Projection? = null,
private val vehicleProfileFor: (ServiceDiscovery.HeadUnitInfo) -> VehicleProfile? = { null },
) {
private enum class State { WAIT_VERSION, TLS_HANDSHAKE, WAIT_AUTH, WAIT_SDR, DONE }
private val parser = Frames.Parser()
private val assembler = Frames.Assembler()
private lateinit var tls: PhoneTls
@Volatile private var state = State.WAIT_VERSION
@Volatile private var lastProgress = System.currentTimeMillis()
@Volatile private var running = true
@Volatile private var videoChannelId: Int = -1
private var videoConfigs: List<ServiceDiscovery.VideoConfig> = emptyList()
private var selectedConfigIndex: Int? = null
private var vehicleProfile: VehicleProfile? = null
@Volatile private var videoStarted = false
private var videoSource: VideoSource? = null
@Volatile private var inFlight = 0
@Volatile private var maxUnacked = 4
@Volatile private var framesSent = 0
@Volatile private var dropped = 0
@Volatile private var inputChannelId: Int = -1
@Volatile private var audioLink: AudioLink? = null
private var touchWidth = 0
private var touchHeight = 0
private var touchesSeen = 0
@Volatile private var acksSeen = 0
@Volatile private var lastAckAt = 0L
@Volatile private var splitMessages = 0
@Volatile private var resyncRequested = false
@Volatile private var resyncAttempts = 0
@Volatile private var lastKeyframeSentAt = 0L
fun run() {
GearslipLog.i("--- spike run starting ---")
SessionStatus.connecting("Connected to the head unit")
GearslipLog.i("device=${Build.MANUFACTURER} ${Build.MODEL} android=${Build.VERSION.RELEASE} (sdk ${Build.VERSION.SDK_INT})")
val identity = identityProvider()
val cert = identity.certificate
GearslipLog.i("phone certificate source: ${identity.source}")
GearslipLog.i(" subject = ${cert.subjectX500Principal}")
GearslipLog.i(" issuer = ${cert.issuerX500Principal}")
GearslipLog.i(" serial = ${cert.serialNumber} valid ${cert.notBefore}..${cert.notAfter}")
tls = PhoneTls(identity.keyStore, identity.password)
startWatchdog()
val chunk = ByteArray(16 * 1024) // AOAP: one read returns one bulk transfer
try {
while (running) {
val read = input.read(chunk)
if (read < 0) {
GearslipLog.w("head unit closed the connection (EOF) while in state $state")
reportDisconnect()
return
}
if (read == 0) continue
parser.append(chunk, read)
while (true) {
val frame = parser.next() ?: break
handleFrame(frame)
}
}
} catch (t: Throwable) {
GearslipLog.e("transport failed in state $state", t)
reportDisconnect()
} finally {
running = false
GearslipLog.flush()
}
}
fun stop() {
running = false
videoSource?.stop()
videoSource = null
audioLink?.close()
audioLink = null
projection?.onProjectionStopped()
SessionStatus.disconnected()
}
private fun handleFrame(frame: Frames.Frame) {
val payload = if (frame.encrypted) {
if (!tls.handshakeComplete) {
GearslipLog.w("encrypted frame arrived before the handshake finished - ignoring")
return
}
tls.decrypt(frame.payload)
} else {
frame.payload
}
val message = assembler.offer(frame, payload) ?: return
if (message.size < 2) {
GearslipLog.w("runt message on channel ${frame.channel}")
return
}
val messageId = ((message[0].toInt() and 0xFF) shl 8) or (message[1].toInt() and 0xFF)
val body = message.copyOfRange(2, message.size)
lastProgress = System.currentTimeMillis()
if (frame.channel != Frames.CHANNEL_CONTROL) {
if (frame.channel == videoChannelId) {
onVideoMessage(messageId, body)
} else if (frame.channel == inputChannelId) {
onInputMessage(messageId, body)
} else if (frame.channel == audioLink?.channelId) {
audioLink?.onMessage(messageId, body)
} else {
GearslipLog.i("ignoring message id $messageId on channel ${frame.channel}")
}
return
}
when (messageId) {
MSG_VERSION_REQUEST -> onVersionRequest(body)
MSG_ENCAPSULATED_SSL -> onHandshake(body)
MSG_AUTH_COMPLETE -> onAuthComplete(body)
MSG_SERVICE_DISCOVERY_RESPONSE -> onServiceDiscoveryResponse(body)
MSG_PING_REQUEST -> onPingRequest(body)
AudioLink.MSG_FOCUS_RESPONSE -> audioLink?.onFocus(Protobuf.readInt32Field(body, 1) ?: 0)
else -> GearslipLog.i("unhandled control message id=$messageId (${body.size} bytes) - continuing")
}
}
private fun onVersionRequest(body: ByteArray) {
if (body.size < 4) {
GearslipLog.w("VersionRequest too short (${body.size} bytes)")
return
}
val major = ((body[0].toInt() and 0xFF) shl 8) or (body[1].toInt() and 0xFF)
val minor = ((body[2].toInt() and 0xFF) shl 8) or (body[3].toInt() and 0xFF)
GearslipLog.i("<- VersionRequest: head unit speaks $major.$minor")
// Echo the head unit's own version with STATUS_SUCCESS (0). Echoing rather than
// asserting 1.6 is the permissive choice: we only need to reach service discovery,
// not to honour whatever that version implies.
val response = byteArrayOf(
((major shr 8) and 0xFF).toByte(), (major and 0xFF).toByte(),
((minor shr 8) and 0xFF).toByte(), (minor and 0xFF).toByte(),
0, 0, // STATUS_SUCCESS
)
send(MSG_VERSION_RESPONSE, response, encrypted = false)
GearslipLog.i("-> VersionResponse: $major.$minor status=0 (STATUS_SUCCESS)")
state = State.TLS_HANDSHAKE
SessionStatus.connecting("Securing the link")
GearslipLog.i("awaiting ClientHello - the head unit is the TLS client, we are the server")
}
private fun onHandshake(body: ByteArray) {
GearslipLog.i("<- EncapsulatedSSL (${body.size} bytes)")
val replies = try {
tls.pumpHandshake(body)
} catch (t: Throwable) {
GearslipLog.e("TLS handshake threw", t)
GearslipLog.verdict(
"NOT VIABLE (TLS rejected)",
"The head unit aborted the TLS handshake. A bad_certificate / unknown_ca / " +
"handshake_failure alert here means it validates the phone's certificate.",
)
SessionStatus.failed(
"Head unit rejected the certificate",
"The head unit aborted the secure handshake. It validates the phone's certificate.",
)
state = State.DONE
return
}
replies.forEach {
send(MSG_ENCAPSULATED_SSL, it, encrypted = false)
GearslipLog.i("-> EncapsulatedSSL (${it.size} bytes)")
}
if (tls.handshakeComplete && state == State.TLS_HANDSHAKE) {
state = State.WAIT_AUTH
GearslipLog.i("awaiting AuthComplete - this carries the certificate verdict")
}
}
private fun onAuthComplete(body: ByteArray) {
val status = Protobuf.readInt32Field(body, 1)
GearslipLog.i("<- AuthComplete: status=$status")
when (status) {
0 -> {
GearslipLog.i("STATUS_SUCCESS - certificate accepted; confirming with service discovery")
state = State.WAIT_SDR
SessionStatus.connecting("Certificate accepted - discovering services")
sendServiceDiscoveryRequest()
}
-2 -> {
GearslipLog.verdict(
"NOT VIABLE (STATUS_CERTIFICATE_ERROR)",
"The head unit explicitly rejected the phone's self-signed certificate. " +
"This is a definitive no - it validates the phone against a trust chain " +
"we cannot issue from.",
)
SessionStatus.failed(
"Head unit rejected the certificate",
"The head unit refused the phone's certificate (certificate error).",
)
state = State.DONE
}
-3 -> {
GearslipLog.verdict(
"NOT VIABLE (STATUS_AUTHENTICATION_FAILURE)",
"Authentication rejected. Broader than a pure certificate error, but the " +
"practical answer is the same.",
)
SessionStatus.failed(
"Head unit rejected the certificate",
"Authentication failed. The head unit does not trust the certificate in use.",
)
state = State.DONE
}
else -> {
GearslipLog.verdict(
"INCONCLUSIVE (AuthComplete status=$status)",
"Unexpected status. Check MessageStatus.proto in aasdk for the meaning " +
"before drawing any conclusion.",
)
SessionStatus.failed("Unexpected response", "The head unit answered authentication with status $status.")
state = State.DONE
}
}
}
private fun sendServiceDiscoveryRequest() {
val body = Protobuf.stringField(4, "Gearslip") + // label_text
Protobuf.stringField(5, Build.MODEL) // device_name
send(MSG_SERVICE_DISCOVERY_REQUEST, body, encrypted = true)
GearslipLog.i("-> ServiceDiscoveryRequest (encrypted)")
}
private fun onServiceDiscoveryResponse(body: ByteArray) {
GearslipLog.i("<- ServiceDiscoveryResponse (${body.size} bytes, decrypted successfully)")
GearslipLog.i("head unit describes itself as:\n" + Protobuf.describe(body))
GearslipLog.verdict(
"VIABLE",
"The head unit accepted the presented phone certificate, completed TLS, and is " +
"advertising its channels over the encrypted session. A phone-side client is " +
"possible against THIS unit - portability to newer units is still unproven.",
)
state = State.DONE
val info = ServiceDiscovery.findHeadUnitInfo(body)
GearslipLog.i("head unit: $info")
vehicleProfile = runCatching { vehicleProfileFor(info) }.getOrNull()
val profile = vehicleProfile
if (profile != null) {
GearslipLog.i("vehicle profile matched: \"${profile.name}\" resolution=${profile.resolution} insets=${profile.insets}")
} else {
GearslipLog.i("no vehicle profile matched - default display settings (add one to vehicles.json)")
}
CarEnvironment.setVehicle(profile?.name ?: "Unknown vehicle", profile?.insets ?: Insets.NONE)
SessionStatus.connecting("Starting video")
startVideoChannel(body)
}
// --- Phase A: video channel bring-up (no encoder yet) -------------------------------
//
// Inverting aasdk's head-unit video sink (src/Channel/MediaSink/Video/
// VideoMediaSinkService.cpp): every message that sink *receives* is one we must *send*.
//
// -> CHANNEL_OPEN_REQUEST <- CHANNEL_OPEN_RESPONSE
// -> SETUP <- CONFIG (status, max_unacked, configuration_indices)
// -> VIDEO_FOCUS_REQUEST <- VIDEO_FOCUS_NOTIFICATION
//
// Reaching VIDEO_FOCUS_NOTIFICATION means the head unit is waiting for frames, which is
// the Phase A milestone. Phase B then sends CODEC_CONFIG + DATA.
private fun startVideoChannel(serviceDiscoveryResponse: ByteArray) {
val video = ServiceDiscovery.findVideoService(serviceDiscoveryResponse)
if (video == null) {
GearslipLog.w("no video service in the discovery response - cannot start Phase A")
return
}
videoChannelId = video.serviceId
videoConfigs = video.configs
GearslipLog.i("video service: channel=${video.serviceId} codec=${video.codecName}")
video.configs.forEachIndexed { index, config ->
GearslipLog.i(" config[$index] = $config")
}
if (video.codecType != CODEC_H264_BP) {
GearslipLog.w("head unit wants ${video.codecName}, not H264_BP - Phase B must match this")
}
openChannel(video.serviceId)
startAudioChannel(serviceDiscoveryResponse)
val input = ServiceDiscovery.findInputService(serviceDiscoveryResponse)
if (input == null) {
GearslipLog.w("no input service advertised - projection will be output-only")
} else {
inputChannelId = input.serviceId
touchWidth = input.touchWidth
touchHeight = input.touchHeight
GearslipLog.i(
"input service: channel=${input.serviceId} touchscreen=" +
"${input.touchWidth}x${input.touchHeight}",
)
openChannel(input.serviceId)
}
}
/** Media audio rides its own sink; guidance, system and telephony sinks are left closed. */
private fun startAudioChannel(serviceDiscoveryResponse: ByteArray) {
val sinks = ServiceDiscovery.findAudioServices(serviceDiscoveryResponse)
sinks.forEach { sink ->
GearslipLog.i(
"audio sink: channel=${sink.serviceId} stream=${sink.streamName} codec=${sink.codecName} " +
sink.configs.joinToString(prefix = "[", postfix = "]") { "${it.sampleRate}Hz/${it.bits}bit/x${it.channels}" },
)
}
val media = sinks.firstOrNull { it.streamType == STREAM_MEDIA } ?: run {
GearslipLog.w("no media audio sink advertised - media apps will play on the phone only")
return
}
audioLink = AudioLink(
media,
sendOnChannel = { id, body -> send(id, body, encrypted = true, channel = media.serviceId) },
sendControl = { id, body -> send(id, body, encrypted = true) },
)
openChannel(media.serviceId)
}
private fun openChannel(serviceId: Int) {
val request = Protobuf.varintField(1, 0L) + // priority (sint32 zigzag: 0 -> 0)
Protobuf.varintField(2, serviceId.toLong()) // service_id
send(
MSG_CHANNEL_OPEN_REQUEST, request, encrypted = true,
channel = serviceId, messageType = Frames.MESSAGE_CONTROL,
)
GearslipLog.i("-> ChannelOpenRequest(service_id=$serviceId) on channel $serviceId")
}
/**
* Touch events arrive **already in projected video coordinates**, not in the head unit's
* native touchscreen space.
*
* Confirmed on the 2018 Uconnect: its panel is advertised as 1258x708, but with 800x480
* projected the raw x never exceeded 791 and raw y never exceeded ~351 even when tapping
* the far edges. Scaling by touchscreen/video (as an earlier version did) compressed the
* right-hand third of the screen away entirely - taps on a right-edge button landed on
* the middle one. The head unit maps its own panel onto our video before reporting.
*
* So: inject as-is, clamped to the video bounds. The advertised touchscreen size is kept
* only for diagnostics.
*/
private fun onInputMessage(messageId: Int, body: ByteArray) {
when (messageId) {
MSG_CHANNEL_OPEN_RESPONSE ->
GearslipLog.i("<- ChannelOpenResponse (input): status=${Protobuf.readInt32Field(body, 1)}")
MSG_INPUT_REPORT -> {
val report = Wire.fields(body)
val touch = Wire.bytes(report, 3) ?: Wire.bytes(report, 7)
if (touch == null) {
GearslipLog.i("<- InputReport with no touch payload (key or rotary event)")
return
}
val touchFields = Wire.fields(touch)
val action = Wire.varint(touchFields, 3)?.toInt() ?: 0
val pointers = Wire.allBytes(touchFields, 1)
if (pointers.isEmpty()) return
if (pointers.size > 1 && touchesSeen < 3) {
GearslipLog.i("(multi-touch: ${pointers.size} pointers; using the first)")
}
val first = Wire.fields(pointers[0])
val rawX = Wire.varint(first, 1)?.toInt() ?: return
val rawY = Wire.varint(first, 2)?.toInt() ?: return
val size = videoConfigs.getOrNull(selectedConfigIndex ?: 0)?.pixelSize()
val x = clamp(rawX, size?.first)
val y = clamp(rawY, size?.second)
touchesSeen++
if (touchesSeen <= 10 || action == ACTION_DOWN || action == ACTION_UP) {
val note = if (x != rawX.toFloat() || y != rawY.toFloat()) " (clamped)" else ""
GearslipLog.i("<- Touch action=$action at ($rawX,$rawY)$note")
}
projection?.onTouch(action, x, y)
}
else -> GearslipLog.i("<- unhandled input message id=$messageId (${body.size} bytes)")
}
}
private fun onVideoMessage(messageId: Int, body: ByteArray) {
when (messageId) {
MSG_CHANNEL_OPEN_RESPONSE -> {
val status = Protobuf.readInt32Field(body, 1)
GearslipLog.i("<- ChannelOpenResponse: status=$status")
if (status != 0) {
GearslipLog.verdict(
"PHASE A FAILED (channel open rejected)",
"The head unit refused to open the video channel (status=$status).",
)
SessionStatus.failed("Video channel refused", "The head unit would not open the video channel (status $status).")
return
}
// Setup { required MediaCodecType type = 1 }
send(MSG_MEDIA_SETUP, Protobuf.varintField(1, CODEC_H264_BP.toLong()),
encrypted = true, channel = videoChannelId)
GearslipLog.i("-> Setup(codec=VIDEO_H264_BP)")
}
MSG_MEDIA_CONFIG -> {
val fields = Wire.fields(body)
val status = Wire.varint(fields, 1)?.toInt()
val maxUnackedFromConfig = Wire.varint(fields, 2)?.toInt()
val indices = fields.filter { it.number == 3 && it.wireType == 0 }.map { it.varint }
GearslipLog.i(
"<- Config: status=$status (1=WAIT 2=READY) max_unacked=$maxUnackedFromConfig " +
"configuration_indices=$indices",
)
if (maxUnackedFromConfig != null) maxUnacked = maxOf(1, maxUnackedFromConfig)
// Prefer the smallest offered resolution for the first attempt: fewer pixels,
// fewer ways for a first encode to go wrong.
val offered = indices.map { it.toInt() }
val preferred = vehicleProfile?.resolution?.let { want ->
offered.firstOrNull { videoConfigs.getOrNull(it)?.resolutionName == want }
}
selectedConfigIndex = preferred ?: offered.minByOrNull { idx ->
videoConfigs.getOrNull(idx)?.pixelCount() ?: Int.MAX_VALUE
} ?: offered.firstOrNull()
GearslipLog.i("selected config index $selectedConfigIndex from offered $offered")
// VideoFocusRequestNotification { mode = 2; reason = 3 }
val focus = Protobuf.varintField(2, VIDEO_FOCUS_PROJECTED.toLong()) +
Protobuf.varintField(3, 1L)
send(MSG_VIDEO_FOCUS_REQUEST, focus, encrypted = true, channel = videoChannelId)
GearslipLog.i("-> VideoFocusRequest(mode=PROJECTED)")
}
MSG_VIDEO_FOCUS_NOTIFICATION -> {
val fields = Wire.fields(body)
GearslipLog.i("<- VideoFocusNotification: mode=${Wire.varint(fields, 1)}")
// The head unit repeats this while it waits for frames; only act once.
if (!videoStarted) {
videoStarted = true
GearslipLog.i("PHASE A COMPLETE - video focus granted; starting video source")
startVideoSource()
}
}
MSG_MEDIA_ACK -> {
val fields = Wire.fields(body)
val acked = Wire.varint(fields, 2)?.toInt() ?: 1
inFlight = maxOf(0, inFlight - maxOf(1, acked))
acksSeen++
lastAckAt = System.currentTimeMillis()
if (framesSent <= 3 || framesSent % 60 == 0) {
GearslipLog.i("<- MediaAck(ack=$acked) inFlight=$inFlight after $framesSent frames")
}
}
else -> GearslipLog.i("<- unhandled video message id=$messageId (${body.size} bytes)")
}
}
// --- Phase B: encode and stream H.264 ----------------------------------------------
//
// -> Start { session_id, configuration_index }
// -> CodecConfig (raw SPS/PPS, NO timestamp - aasdk routes it to onMediaIndication)
// -> Data (8-byte big-endian timestamp, then the H.264 access unit)
// <- Ack { session_id, ack } - at most max_unacked frames may be outstanding
private fun startVideoSource() {
lastKeyframeSentAt = System.currentTimeMillis() // frame #1 is always a keyframe
val index = selectedConfigIndex ?: 0
val config = videoConfigs.getOrNull(index)
if (config == null) {
GearslipLog.e("no video config at index $index - cannot start the encoder")
return
}
val (width, height) = config.pixelSize() ?: run {
GearslipLog.e("unsupported resolution ${config.resolutionName}")
return
}
val start = Protobuf.varintField(1, SESSION_ID.toLong()) +
Protobuf.varintField(2, index.toLong())
send(MSG_MEDIA_START, start, encrypted = true, channel = videoChannelId)
GearslipLog.i("-> Start(session_id=$SESSION_ID, configuration_index=$index -> $config)")
val useProjection = projection != null
videoSource = VideoSource(
width = width,
height = height,
frameRate = if (config.frameRate == 1) 60 else 30,
mode = if (useProjection) VideoSource.Mode.SURFACE else VideoSource.Mode.TEST_CARD,
onCodecConfig = { csd ->
send(MSG_MEDIA_CODEC_CONFIG, csd, encrypted = true, channel = videoChannelId)
GearslipLog.i("-> CodecConfig (${csd.size} bytes SPS/PPS)")
GearslipLog.hex(" csd", csd, limit = 64)
},
onFrame = { data, presentationTimeUs, keyFrame ->
sendVideoFrame(data, presentationTimeUs, keyFrame)
},
).also { source ->
runCatching {
source.start()
val surface = source.inputSurface
if (projection != null && surface != null) {
projection.onSurfaceReady(surface, width, height, config.density)
SessionStatus.projecting()
}
}.onFailure { e -> GearslipLog.e("encoder start failed", e) }
}
}
private fun sendVideoFrame(data: ByteArray, presentationTimeUs: Long, keyFrame: Boolean) {
// Flow control: the head unit told us max_unacked in its Config reply.
if (inFlight >= maxUnacked) {
// A dropped or never-sent ack would otherwise wedge the stream forever, which
// looks identical to a frozen picture. Assume the window cleared and carry on.
val since = System.currentTimeMillis() - lastAckAt
if (since > ACK_STALL_MS) {
GearslipLog.w("no ack for ${since}ms with $inFlight in flight - resetting the window")
inFlight = 0
} else {
dropped++
if (dropped % 30 == 1) {
GearslipLog.w("dropping frame - $inFlight in flight (max $maxUnacked)")
}
// A dropped frame breaks the decoder's reference chain on the head unit -
// every frame after it is a P-frame assuming a picture that never arrived.
// Without a resync the picture free-runs on corrupted state indefinitely,
// which is the smeared/ghosted look.
//
// Ask on EVERY drop, not just the first in a streak: the resync keyframe we
// asked for is itself just another frame, and can be dropped by this exact
// same check. A one-shot request that gets swallowed by the burst that
// provoked it leaves the stream corrupted for good, with no further request
// ever made - confirmed on the road (docs/spike/phase-c-stuck-resync-2026-09-17.log,
// dropped=27 in one burst, video dead at 0fps for 100+s afterward). Repeated
// setParameters(REQUEST_SYNC_FRAME) calls before one lands are harmless - it
// just keeps the "next frame is a keyframe" flag set.
resyncRequested = true
resyncAttempts++
videoSource?.requestSyncFrame()
return
}
}
val timestamp = ByteArray(8)
for (i in 0 until 8) {
timestamp[i] = ((presentationTimeUs shr ((7 - i) * 8)) and 0xFF).toByte()
}
if (keyFrame) {
if (resyncRequested) {
GearslipLog.i("resync keyframe delivered after $resyncAttempts attempt(s)")
}
resyncRequested = false
resyncAttempts = 0
lastKeyframeSentAt = System.currentTimeMillis()
}
try {
send(MSG_MEDIA_DATA, timestamp + data, encrypted = true, channel = videoChannelId)
} catch (e: java.io.IOException) {
if (running) GearslipLog.w("video send failed, stopping the encoder: ${e.message}")
running = false
videoSource?.stop()
return
}
inFlight++
framesSent++
if (framesSent <= 3 || keyFrame && framesSent % 30 == 0) {
GearslipLog.i(
"-> Data frame #$framesSent (${data.size} bytes, keyFrame=$keyFrame, " +
"ptsUs=$presentationTimeUs)",
)
}
}
private fun clamp(value: Int, limit: Int?): Float = when {
limit == null -> value.toFloat()
value < 0 -> 0f
value >= limit -> (limit - 1).toFloat()
else -> value.toFloat()
}
private fun onPingRequest(body: ByteArray) {
val timestamp = Protobuf.readInt32Field(body, 1)?.toLong() ?: System.nanoTime()
send(MSG_PING_RESPONSE, Protobuf.varintField(1, timestamp), encrypted = tls.handshakeComplete)
GearslipLog.i("<- PingRequest / -> PingResponse (keeping the session alive)")
}
private fun send(
messageId: Int,
body: ByteArray,
encrypted: Boolean,
channel: Int = Frames.CHANNEL_CONTROL,
messageType: Int = Frames.MESSAGE_SPECIFIC,
) {
val payload = byteArrayOf(
((messageId shr 8) and 0xFF).toByte(), (messageId and 0xFF).toByte(),
) + body
synchronized(output) {
if (payload.size <= Frames.MAX_FRAME_PAYLOAD) {
val framePayload = if (encrypted) tls.encrypt(payload) else payload
output.write(Frames.build(channel, encrypted, framePayload, messageType))
} else {
// Split exactly as aasdk does: chunk the *plaintext*, encrypt each chunk
// separately, and carry the total plaintext length in the FIRST frame.
// Video keyframes for anything busier than a test card exceed 16 KB routinely.
var offset = 0
while (offset < payload.size) {
val size = minOf(Frames.MAX_FRAME_PAYLOAD, payload.size - offset)
val chunk = payload.copyOfRange(offset, offset + size)
val frameType = when {
offset == 0 -> Frames.TYPE_FIRST
offset + size >= payload.size -> Frames.TYPE_LAST
else -> Frames.TYPE_MIDDLE
}
val framePayload = if (encrypted) tls.encrypt(chunk) else chunk
output.write(
Frames.build(
channel, encrypted, framePayload, messageType, frameType,
if (frameType == Frames.TYPE_FIRST) payload.size else null,
),
)
offset += size
}
splitMessages++
}
output.flush()
}
}
private fun reportDisconnect() {
when (state) {
State.DONE -> SessionStatus.disconnected()
State.WAIT_VERSION -> SessionStatus.failed(
"Head unit never started talking",
"The connection opened but the head unit sent nothing. Check the phone is set to use Gearslip for this USB accessory.",
)
State.TLS_HANDSHAKE, State.WAIT_AUTH -> SessionStatus.failed(
"Connection dropped during setup",
"The head unit disconnected while securing the link. This usually means it rejected the certificate.",
)
State.WAIT_SDR -> SessionStatus.failed(
"Connection dropped after authentication",
"The certificate was accepted but the head unit disconnected before finishing setup.",
)
}
when (state) {
State.WAIT_VERSION -> GearslipLog.verdict(
"INCONCLUSIVE (no VersionRequest)",
"The head unit never opened the control channel. This is a connection problem, " +
"not a certificate verdict - check that the accessory was actually claimed.",
)
State.TLS_HANDSHAKE -> GearslipLog.verdict(
"LIKELY NOT VIABLE (dropped during TLS)",
"The unit disconnected mid-handshake without sending an alert we could read. " +
"Rejection is the likeliest reading, but re-run once before trusting it.",
)
State.WAIT_AUTH -> GearslipLog.verdict(
"INCONCLUSIVE (dropped after TLS, before AuthComplete)",
"TLS completed but the unit disconnected before stating a verdict. Re-run.",
)
State.WAIT_SDR -> GearslipLog.verdict(
"INCONCLUSIVE (accepted, then dropped before service discovery)",
"AuthComplete said success, so the certificate was accepted - but our " +
"ServiceDiscoveryRequest drew no reply. Suspect our own encryption or " +
"framing rather than the head unit.",
)
State.DONE -> GearslipLog.i("connection closed after the run completed")
}
}
private fun startWatchdog() {
Thread {
var lastFrames = 0
while (running) {
Thread.sleep(5_000)
if (!running) break
if (state != State.DONE) {
val idle = System.currentTimeMillis() - lastProgress
if (idle > 10_000) GearslipLog.w("stalled in state $state for ${idle / 1000}s")
}
// Stream health. Without this a drive cannot tell "streaming fine" from
// "stalled after three frames" - the per-frame logging is deliberately sparse.
if (videoStarted) {
val sent = framesSent
val fps = (sent - lastFrames) / 5.0
lastFrames = sent
GearslipLog.i(
"video: ${"%.1f".format(fps)} fps over 5s | sent=$sent dropped=$dropped " +
"acked=$acksSeen inFlight=$inFlight split=$splitMessages",
)
// Self-healing safety net: force a fresh keyframe periodically regardless
// of whether we think one is owed. Protects against any future case where
// our own drop/resync bookkeeping is wrong or a corruption slips through
// some other path - a real decoder desync should never outlive this.
val idleSinceKeyframe = System.currentTimeMillis() - lastKeyframeSentAt
if (idleSinceKeyframe > PERIODIC_KEYFRAME_MS) {
videoSource?.requestSyncFrame()
}
}
}
}.apply { isDaemon = true }.start()
}
private companion object {
// protobuf/aap_protobuf/service/control/ControlMessageType.proto:7-17
const val MSG_VERSION_REQUEST = 1
const val MSG_VERSION_RESPONSE = 2
const val MSG_ENCAPSULATED_SSL = 3
const val MSG_AUTH_COMPLETE = 4
const val MSG_SERVICE_DISCOVERY_REQUEST = 5
const val MSG_SERVICE_DISCOVERY_RESPONSE = 6
const val MSG_PING_REQUEST = 11
const val MSG_PING_RESPONSE = 12
const val MSG_CHANNEL_OPEN_REQUEST = 7
const val MSG_CHANNEL_OPEN_RESPONSE = 8
// aap_protobuf/service/media/.../MediaMessageId
const val MSG_MEDIA_DATA = 0
const val MSG_MEDIA_CODEC_CONFIG = 1
const val MSG_MEDIA_SETUP = 32768
const val MSG_MEDIA_START = 32769
const val MSG_MEDIA_STOP = 32770
const val MSG_MEDIA_CONFIG = 32771
const val MSG_MEDIA_ACK = 32772
const val MSG_VIDEO_FOCUS_REQUEST = 32775
const val MSG_VIDEO_FOCUS_NOTIFICATION = 32776
const val STREAM_MEDIA = 3
const val CODEC_H264_BP = 3
const val VIDEO_FOCUS_PROJECTED = 1
const val SESSION_ID = 1
const val MSG_INPUT_REPORT = 32769
const val ACTION_DOWN = 0
const val ACTION_UP = 1
const val ACK_STALL_MS = 2_000L
const val PERIODIC_KEYFRAME_MS = 5_000L
}
}
@@ -0,0 +1,219 @@
package app.seb3thehacker.gearslip
import java.net.Socket
import java.nio.ByteBuffer
import java.security.KeyStore
import java.security.SecureRandom
import java.security.cert.X509Certificate
import javax.net.ssl.KeyManagerFactory
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLEngine
import javax.net.ssl.SSLEngineResult
import javax.net.ssl.SSLEngineResult.HandshakeStatus
import javax.net.ssl.SSLPeerUnverifiedException
import javax.net.ssl.X509ExtendedTrustManager
/**
* The phone end of the Android Auto TLS session.
*
* Two things here are the opposite of what aasdk does, because aasdk implements the head
* unit and the head unit is the TLS *client*: it calls TLS_client_method() and
* SSL_set_connect_state (src/Transport/SSLWrapper.cpp:97-140, src/Messenger/Cryptor.cpp:87),
* and SSL_set_accept_state appears nowhere in that codebase. So the phone is the TLS
* **server** and its certificate goes out as the server certificate - sent unconditionally
* on every handshake, which is precisely what we are testing.
*
* TLS here is not layered over the socket: handshake bytes travel inside
* MESSAGE_ENCAPSULATED_SSL control messages (id 3). That rules out SSLSocket and makes
* SSLEngine - a buffer-to-buffer engine, the JSSE counterpart of aasdk's memory BIO pair -
* the right tool.
*/
class PhoneTls(keyStore: KeyStore, password: CharArray) {
private val engine: SSLEngine
private var netIn = ByteArray(0)
private var appBuffer = ByteBuffer.allocate(32 * 1024)
private var netBuffer = ByteBuffer.allocate(32 * 1024)
var handshakeComplete: Boolean = false
private set
init {
val keyManagers = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
.apply { init(keyStore, password) }
.keyManagers
val context = SSLContext.getInstance("TLSv1.2").apply {
init(keyManagers, arrayOf(LoggingTrustManager()), SecureRandom())
}
engine = context.createSSLEngine().apply {
useClientMode = false
// want, not need: we ask the head unit for its certificate so the run also tells
// us what real units present, but we never make the session depend on getting one.
wantClientAuth = true
enabledProtocols = arrayOf("TLSv1.2") // aasdk pins TLS 1.2
}
engine.beginHandshake()
}
/**
* Feeds received handshake bytes in and returns whatever must be sent back, each element
* becoming one MESSAGE_ENCAPSULATED_SSL message.
*/
fun pumpHandshake(incoming: ByteArray?): List<ByteArray> {
if (incoming != null) netIn += incoming
val outgoing = mutableListOf<ByteArray>()
var source = ByteBuffer.wrap(netIn)
loop@ while (true) {
when (engine.handshakeStatus) {
HandshakeStatus.NEED_TASK -> {
var task = engine.delegatedTask
while (task != null) {
task.run()
task = engine.delegatedTask
}
}
HandshakeStatus.NEED_WRAP -> {
netBuffer.clear()
val result = engine.wrap(EMPTY, netBuffer)
if (result.status == SSLEngineResult.Status.BUFFER_OVERFLOW) {
netBuffer = ByteBuffer.allocate(netBuffer.capacity() * 2)
continue@loop
}
netBuffer.flip()
if (netBuffer.hasRemaining()) {
outgoing += ByteArray(netBuffer.remaining()).also { netBuffer.get(it) }
}
if (result.status == SSLEngineResult.Status.CLOSED) break@loop
}
HandshakeStatus.NEED_UNWRAP -> {
appBuffer.clear()
val result = engine.unwrap(source, appBuffer)
when (result.status) {
SSLEngineResult.Status.BUFFER_UNDERFLOW -> break@loop
SSLEngineResult.Status.BUFFER_OVERFLOW -> {
appBuffer = ByteBuffer.allocate(appBuffer.capacity() * 2)
continue@loop
}
SSLEngineResult.Status.CLOSED -> break@loop
else -> Unit
}
}
HandshakeStatus.FINISHED, HandshakeStatus.NOT_HANDSHAKING -> break@loop
else -> break@loop
}
}
netIn = ByteArray(source.remaining()).also { source.get(it) }
if (!handshakeComplete && engine.handshakeStatus == HandshakeStatus.NOT_HANDSHAKING &&
engine.session.cipherSuite != "SSL_NULL_WITH_NULL_NULL"
) {
handshakeComplete = true
describeSession()
}
return outgoing
}
/** Wraps a control-channel payload for an ENCRYPTED frame. */
fun encrypt(plain: ByteArray): ByteArray {
val source = ByteBuffer.wrap(plain)
val out = java.io.ByteArrayOutputStream()
while (source.hasRemaining()) {
netBuffer.clear()
val result = engine.wrap(source, netBuffer)
if (result.status == SSLEngineResult.Status.BUFFER_OVERFLOW) {
netBuffer = ByteBuffer.allocate(netBuffer.capacity() * 2)
continue
}
netBuffer.flip()
val chunk = ByteArray(netBuffer.remaining())
netBuffer.get(chunk)
out.write(chunk)
}
return out.toByteArray()
}
/**
* Unwraps one ENCRYPTED frame's payload. aasdk decrypts per frame
* (MessageOutStream::compoundFrame encrypts per frame), so each frame's payload is
* self-contained ciphertext.
*/
fun decrypt(cipher: ByteArray): ByteArray {
val source = ByteBuffer.wrap(cipher)
val out = java.io.ByteArrayOutputStream()
while (source.hasRemaining()) {
appBuffer.clear()
val result = engine.unwrap(source, appBuffer)
when (result.status) {
SSLEngineResult.Status.BUFFER_OVERFLOW -> {
appBuffer = ByteBuffer.allocate(appBuffer.capacity() * 2)
continue
}
SSLEngineResult.Status.BUFFER_UNDERFLOW -> {
GearslipLog.w("decrypt: record split across frames (${source.remaining()} bytes left over)")
break
}
SSLEngineResult.Status.CLOSED -> break
else -> Unit
}
appBuffer.flip()
val chunk = ByteArray(appBuffer.remaining())
appBuffer.get(chunk)
out.write(chunk)
if (result.bytesConsumed() == 0 && result.bytesProduced() == 0) break
}
return out.toByteArray()
}
private fun describeSession() {
val session = engine.session
GearslipLog.i("TLS handshake COMPLETE - protocol=${session.protocol} cipher=${session.cipherSuite}")
try {
val peers = session.peerCertificates
GearslipLog.i("head unit presented ${peers.size} certificate(s):")
peers.forEachIndexed { index, certificate ->
if (certificate is X509Certificate) {
GearslipLog.i(" [$index] subject=${certificate.subjectX500Principal}")
GearslipLog.i(" issuer =${certificate.issuerX500Principal}")
GearslipLog.i(" serial =${certificate.serialNumber} valid=${certificate.notBefore}..${certificate.notAfter}")
}
}
} catch (_: SSLPeerUnverifiedException) {
GearslipLog.i("head unit presented NO certificate (we asked but did not require one)")
}
}
/** Accepts everything and reports what it saw. We want the data, not a verdict. */
private class LoggingTrustManager : X509ExtendedTrustManager() {
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {
GearslipLog.i("peer certificate offered (authType=$authType, chain=${chain?.size ?: 0}) - accepting")
}
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?, socket: Socket?) =
checkClientTrusted(chain, authType)
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?, engine: SSLEngine?) =
checkClientTrusted(chain, authType)
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) =
checkClientTrusted(chain, authType)
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?, socket: Socket?) =
checkClientTrusted(chain, authType)
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?, engine: SSLEngine?) =
checkClientTrusted(chain, authType)
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
}
companion object {
private val EMPTY: ByteBuffer = ByteBuffer.allocate(0)
}
}
@@ -0,0 +1,65 @@
package app.seb3thehacker.gearslip
import android.content.Context
import java.io.File
/** Decides what the car screen shows. */
object ProjectedContent {
/**
* Precedence: the URL saved in Settings, then a `url.txt` in the external files dir (the
* pre-Settings way, still honoured so existing setups keep working), then the built-in
* test page.
*/
fun resolve(context: Context): String {
val saved = AppSettings.startupUrl(context)
if (saved.isNotEmpty()) {
val url = normalize(saved)
GearslipLog.i("projecting startup URL from settings: $url")
return url
}
val legacy = context.getExternalFilesDir(null)?.let { File(it, "url.txt") }
if (legacy != null && legacy.isFile) {
val url = legacy.readText().trim()
if (url.isNotEmpty()) {
GearslipLog.i("projecting url.txt override: $url")
return url
}
}
return TEST_PAGE
}
/** A bare "example.com" is a URL the user meant; raw HTML is passed through untouched. */
private fun normalize(value: String): String =
if (value.startsWith("<") || "://" in value) value else "https://$value"
/** Big targets, a live clock, and tap feedback - all verifiable at a glance in a car. */
val TEST_PAGE = """
<html><head><meta name="viewport" content="width=device-width,initial-scale=1">
<style>
html{height:100%}
body{margin:0;font-family:sans-serif;background:#101418;color:#fff;
display:flex;flex-direction:column;height:100%}
h1{margin:12px;font-size:28px}
#t{margin:0 12px 8px;font-size:20px;color:#8ab4f8}
.row{display:flex;flex:1;gap:8px;padding:8px}
.b{flex:1;display:flex;align-items:center;justify-content:center;
font-size:26px;border-radius:12px;background:#243043}
.hit{background:#2e7d32}
</style></head><body>
<h1>Gearslip &mdash; projected from the phone</h1>
<div id="t">starting&hellip;</div>
<div class="row">
<div class="b" id="a">TAP A</div><div class="b" id="b">TAP B</div>
<div class="b" id="c">TAP C</div>
</div>
<script>
setInterval(function(){document.getElementById('t').textContent=new Date().toLocaleTimeString();},1000);
['a','b','c'].forEach(function(id){
var e=document.getElementById(id);
e.addEventListener('touchstart',function(){e.classList.add('hit');e.textContent='HIT '+id.toUpperCase();});
e.addEventListener('touchend',function(){setTimeout(function(){e.classList.remove('hit');e.textContent='TAP '+id.toUpperCase();},400);});
});
</script></body></html>
""".trimIndent()
}
@@ -0,0 +1,25 @@
package app.seb3thehacker.gearslip
import android.view.Surface
/**
* Lets [GearslipRunner] drive on-screen content without owning any Android UI itself.
*
* The protocol side decides *when* a surface is needed and *where* touches landed; the
* activity decides *what* to render and how to inject events, because both need a UI thread
* and a Context.
*/
interface Projection {
/** Called once the encoder's input Surface exists and content should start rendering. */
fun onSurfaceReady(surface: Surface, width: Int, height: Int, densityDpi: Int)
/**
* A touch from the head unit, already scaled into projected-video coordinates.
* [action] is an Android MotionEvent action - Android Auto's PointerAction enum uses
* identical values (DOWN=0, UP=1, MOVED=2, POINTER_DOWN=5, POINTER_UP=6).
*/
fun onTouch(action: Int, x: Float, y: Float)
fun onProjectionStopped()
}
@@ -0,0 +1,178 @@
package app.seb3thehacker.gearslip
import java.io.ByteArrayOutputStream
/**
* Just enough protobuf to run the spike, hand-rolled so there is no protoc step.
*
* Only three messages are ever written (AuthResponse is only read):
* ServiceDiscoveryRequest { optional string label_text = 4; optional string device_name = 5; }
* PingResponse { required int64 timestamp = 1; }
* AuthResponse { required int32 status = 1; }
*/
object Protobuf {
fun varint(value: Long): ByteArray {
val out = ByteArrayOutputStream()
var v = value
while (true) {
val b = (v and 0x7F).toInt()
v = v ushr 7
if (v == 0L) {
out.write(b)
return out.toByteArray()
}
out.write(b or 0x80)
}
}
fun stringField(field: Int, value: String): ByteArray {
val bytes = value.toByteArray(Charsets.UTF_8)
return varint(((field shl 3) or 2).toLong()) + varint(bytes.size.toLong()) + bytes
}
fun varintField(field: Int, value: Long): ByteArray =
varint((field shl 3).toLong()) + varint(value)
/** Reads the single int32 in AuthResponse. Negative values are 10-byte varints. */
fun readInt32Field(data: ByteArray, field: Int): Int? {
var pos = 0
while (pos < data.size) {
val (tag, afterTag) = readVarint(data, pos) ?: return null
pos = afterTag
val number = (tag ushr 3).toInt()
when ((tag and 7L).toInt()) {
0 -> {
val (value, afterValue) = readVarint(data, pos) ?: return null
pos = afterValue
if (number == field) return value.toInt()
}
2 -> {
val (len, afterLen) = readVarint(data, pos) ?: return null
pos = afterLen + len.toInt()
}
5 -> pos += 4
1 -> pos += 8
else -> return null
}
}
return null
}
/**
* Shallow field dump. Used on ServiceDiscoveryResponse: its make/model/year strings
* (fields 2-4) and head-unit info (7-10) coming out readable is the clearest possible
* proof that the session is live and we are decrypting correctly.
*/
fun describe(data: ByteArray, indent: String = " "): String {
val sb = StringBuilder()
var pos = 0
while (pos < data.size) {
val (tag, afterTag) = readVarint(data, pos) ?: break
pos = afterTag
val number = (tag ushr 3).toInt()
when ((tag and 7L).toInt()) {
0 -> {
val (value, after) = readVarint(data, pos) ?: break
pos = after
sb.append("$indent#$number varint = $value\n")
}
1 -> {
if (pos + 8 > data.size) break
sb.append("$indent#$number fixed64\n"); pos += 8
}
2 -> {
val (len, afterLen) = readVarint(data, pos) ?: break
val end = afterLen + len.toInt()
if (end > data.size) break
val body = data.copyOfRange(afterLen, end)
pos = end
val text = body.toString(Charsets.UTF_8)
if (body.isNotEmpty() && text.all { it == '\n' || it == '\t' || it.code in 32..126 }) {
sb.append("$indent#$number string = \"$text\"\n")
} else {
sb.append("$indent#$number message/bytes (${body.size})\n")
sb.append(describe(body, "$indent "))
}
}
5 -> {
if (pos + 4 > data.size) break
sb.append("$indent#$number fixed32\n"); pos += 4
}
else -> break
}
}
return sb.toString()
}
private fun readVarint(data: ByteArray, start: Int): Pair<Long, Int>? {
var result = 0L
var shift = 0
var pos = start
while (pos < data.size && shift <= 63) {
val b = data[pos].toInt() and 0xFF
result = result or ((b and 0x7F).toLong() shl shift)
pos++
if (b and 0x80 == 0) return result to pos
shift += 7
}
return null
}
}
/** Structured field access, for walking ServiceDiscoveryResponse without protoc. */
object Wire {
class Field(val number: Int, val wireType: Int, val varint: Long, val bytes: ByteArray?)
fun fields(data: ByteArray): List<Field> {
val out = mutableListOf<Field>()
var pos = 0
while (pos < data.size) {
val tag = readVarint(data, pos) ?: break
pos = tag.second
val number = (tag.first ushr 3).toInt()
when ((tag.first and 7L).toInt()) {
0 -> {
val v = readVarint(data, pos) ?: break
pos = v.second
out += Field(number, 0, v.first, null)
}
1 -> { if (pos + 8 > data.size) break; pos += 8 }
2 -> {
val len = readVarint(data, pos) ?: break
val end = len.second + len.first.toInt()
if (end > data.size || end < len.second) break
out += Field(number, 2, 0, data.copyOfRange(len.second, end))
pos = end
}
5 -> { if (pos + 4 > data.size) break; pos += 4 }
else -> break
}
}
return out
}
fun varint(fields: List<Field>, number: Int): Long? =
fields.firstOrNull { it.number == number && it.wireType == 0 }?.varint
fun bytes(fields: List<Field>, number: Int): ByteArray? =
fields.firstOrNull { it.number == number && it.wireType == 2 }?.bytes
fun allBytes(fields: List<Field>, number: Int): List<ByteArray> =
fields.filter { it.number == number && it.wireType == 2 }.mapNotNull { it.bytes }
private fun readVarint(data: ByteArray, start: Int): Pair<Long, Int>? {
var result = 0L
var shift = 0
var pos = start
while (pos < data.size && shift <= 63) {
val b = data[pos].toInt() and 0xFF
result = result or ((b and 0x7F).toLong() shl shift)
pos++
if (b and 0x80 == 0) return result to pos
shift += 7
}
return null
}
}
@@ -0,0 +1,183 @@
package app.seb3thehacker.gearslip
import android.app.ActivityOptions
import android.app.Presentation
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.hardware.display.DisplayManager
import android.hardware.display.VirtualDisplay
import android.os.SystemClock
import android.view.MotionEvent
import android.view.Surface
import android.view.View
import android.view.ViewGroup
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.ComposeView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
import androidx.lifecycle.setViewTreeLifecycleOwner
import androidx.lifecycle.setViewTreeViewModelStoreOwner
import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
/**
* Renders the car UI into the encoder's input Surface.
*
* Uses the same mechanism as Android screen mirroring: a VirtualDisplay backed by the
* MediaCodec input Surface, with a Presentation hosting a Compose view on that display.
* Anything drawn there is encoded and projected - no hand-written EGL/GL.
*
* Everything here must run on the main thread: Presentation is a Dialog.
*/
class ScreenProjector(private val context: Context) {
private var virtualDisplay: VirtualDisplay? = null
private var presentation: Presentation? = null
private var owner: ViewTreeOwner? = null
private var root: View? = null
/** Tracks the synthetic gesture so injected MotionEvents have a coherent downTime. */
private var downTime = 0L
fun start(surface: Surface, width: Int, height: Int, densityDpi: Int, content: @Composable () -> Unit) {
stop()
val displayManager = context.getSystemService(DisplayManager::class.java)
// OWN_CONTENT_ONLY, not PUBLIC: we render our own Presentation rather than mirroring
// the phone screen. PUBLIC marks the display as screen sharing, which the platform
// refuses without ADD_MIRROR_DISPLAY/CAPTURE_VIDEO_OUTPUT or a MediaProjection token -
// and going the MediaProjection route would put a consent dialog in front of the
// driver on every connect.
val display = try {
displayManager.createVirtualDisplay(
"gearslip-projection", width, height, densityDpi, surface,
DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION or
DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY,
)
} catch (t: Throwable) {
GearslipLog.e("createVirtualDisplay failed", t)
null
}
if (display == null) {
GearslipLog.e("createVirtualDisplay returned null - cannot project")
return
}
virtualDisplay = display
val show = Presentation(context, display.display)
val window = checkNotNull(show.window) { "presentation has no window" }
window.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
// A ComposeView outside an Activity has no lifecycle, saved-state or view-model owner
// to find up its view tree, and refuses to compose without them. Hand it ours.
val treeOwner = ViewTreeOwner()
val decor = window.decorView
decor.setViewTreeLifecycleOwner(treeOwner)
decor.setViewTreeSavedStateRegistryOwner(treeOwner)
decor.setViewTreeViewModelStoreOwner(treeOwner)
decor.setBackgroundColor(Color.BLACK)
show.setContentView(ComposeView(show.context).apply { setContent(content) })
show.show()
presentation = show
owner = treeOwner
root = decor
GearslipLog.i("projecting ${width}x$height @ ${densityDpi}dpi")
}
/** The projected display's id, for placing other apps' activities on it. */
val displayId: Int? get() = virtualDisplay?.display?.displayId
/**
* Shows or hides our own Compose UI. A Presentation is a TYPE_PRESENTATION window, so it
* draws above any activity on the same display and must be hidden to reveal one.
*/
fun setOverlayVisible(visible: Boolean) {
if (visible) presentation?.show() else presentation?.hide()
}
/**
* Launches another app's activity onto the projected display. The Presentation is hidden
* first, since it would otherwise draw over the app.
*
* Does not work yet for third-party apps, and measuring why is recorded here so the next
* attempt doesn't repeat it. Two separate platform gates, both confirmed on Android 17:
* 1. `setLaunchDisplayId` requires INTERNAL_SYSTEM_WINDOW in the *caller*. We don't hold
* it, so this throws SecurityException. Launching the same activity from a shell-uid
* caller places the task on this display fine.
* 2. Even once placed, the task stays visible=false, because this display is created
* FLAG_PRIVATE - a private display only ever shows its owner's windows.
* Fixing (2) needs a display created with VIRTUAL_DISPLAY_FLAG_TRUSTED, which needs the
* signature permission ADD_TRUSTED_DISPLAY. The route that avoids shell privilege entirely
* is VirtualDeviceManager (a companion-device app-streaming association), which hands out
* a display built for hosting other apps plus its own virtual touchscreen.
*/
fun launchApp(intent: Intent): Boolean {
val display = virtualDisplay ?: return false
return try {
val options = ActivityOptions.makeBasic().setLaunchDisplayId(display.display.displayId)
presentation?.hide()
context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), options.toBundle())
GearslipLog.i("launched ${intent.component ?: intent.`package`} on display ${display.display.displayId}")
true
} catch (t: Throwable) {
GearslipLog.e("launchApp failed", t)
presentation?.show()
false
}
}
/** [action] is already an Android MotionEvent action; x/y are in projected coordinates. */
fun dispatchTouch(action: Int, x: Float, y: Float) {
val target = root ?: return
val now = SystemClock.uptimeMillis()
if (action == MotionEvent.ACTION_DOWN || downTime == 0L) downTime = now
val event = MotionEvent.obtain(downTime, now, action, x, y, 0)
try {
target.dispatchTouchEvent(event)
} finally {
event.recycle()
}
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) downTime = 0L
}
fun stop() {
runCatching { presentation?.dismiss() }
runCatching { owner?.destroy() }
runCatching { virtualDisplay?.release() }
presentation = null
owner = null
root = null
virtualDisplay = null
downTime = 0L
}
private class ViewTreeOwner : LifecycleOwner, SavedStateRegistryOwner, ViewModelStoreOwner {
private val registry = LifecycleRegistry(this)
private val savedState = SavedStateRegistryController.create(this)
override val lifecycle: Lifecycle get() = registry
override val savedStateRegistry: SavedStateRegistry get() = savedState.savedStateRegistry
override val viewModelStore = ViewModelStore()
init {
savedState.performRestore(null)
registry.currentState = Lifecycle.State.RESUMED
}
fun destroy() {
registry.currentState = Lifecycle.State.DESTROYED
viewModelStore.clear()
}
}
}
@@ -0,0 +1,124 @@
package app.seb3thehacker.gearslip
import java.io.ByteArrayInputStream
import java.math.BigInteger
import java.security.KeyPair
import java.security.KeyPairGenerator
import java.security.KeyStore
import java.security.SecureRandom
import java.security.Signature
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
/**
* Generates a self-signed RSA-2048 certificate - the thing under test.
*
* Written by hand rather than with BouncyCastle so the spike has no dependencies and no
* second security provider sitting next to Conscrypt. A self-signed X.509 is a fixed
* structure, so this is mechanical; the risk is a silent malformation, which
* [generate] closes off by parsing its own output back through CertificateFactory and
* verifying the signature before returning. A bad certificate therefore fails loudly on the
* bench instead of looking like a head-unit rejection in the car.
*
* RSA-2048 matches aasdk's own key (cert/headunit.key) - see cert/README.md.
*/
object SelfSignedCert {
const val ALIAS = "phone"
val PASSWORD: CharArray = "spike".toCharArray()
// Pre-encoded OIDs, so there is no OID encoder to get wrong.
private val OID_SHA256_RSA = byteArrayOf(
0x06, 0x09, 0x2A, 0x86.toByte(), 0x48, 0x86.toByte(), 0xF7.toByte(), 0x0D, 0x01, 0x01, 0x0B
)
private val OID_CN = byteArrayOf(0x06, 0x03, 0x55, 0x04, 0x03)
private val OID_O = byteArrayOf(0x06, 0x03, 0x55, 0x04, 0x0A)
class Result(val keyStore: KeyStore, val certificate: X509Certificate)
fun generate(commonName: String = "Gearslip", organisation: String = "Gearslip"): Result {
val keyPair = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair()
val now = System.currentTimeMillis()
val notBefore = Date(now - 86_400_000L) // a day of slack for head-unit clock skew
val notAfter = Date(now + 20L * 365 * 86_400_000L) // ~2046, still inside UTCTime's pre-2050 range
val der = buildCertificate(keyPair, commonName, organisation, notBefore, notAfter)
// Self-check: malformed DER throws here, on the bench, not in the car.
val certificate = CertificateFactory.getInstance("X.509")
.generateCertificate(ByteArrayInputStream(der)) as X509Certificate
certificate.verify(keyPair.public)
certificate.checkValidity()
val keyStore = KeyStore.getInstance("PKCS12").apply {
load(null, null)
setKeyEntry(ALIAS, keyPair.private, PASSWORD, arrayOf(certificate))
}
return Result(keyStore, certificate)
}
private fun buildCertificate(
keyPair: KeyPair,
commonName: String,
organisation: String,
notBefore: Date,
notAfter: Date,
): ByteArray {
val algorithmId = seq(OID_SHA256_RSA, tlv(0x05, ByteArray(0))) // sha256WithRSAEncryption, NULL params
val name = seq(rdn(OID_CN, commonName), rdn(OID_O, organisation))
val serial = BigInteger(64, SecureRandom()).add(BigInteger.ONE)
val tbs = seq(
tlv(0xA0, integer(BigInteger.valueOf(2))), // [0] EXPLICIT version, v3
integer(serial),
algorithmId,
name,
seq(utcTime(notBefore), utcTime(notAfter)),
name,
keyPair.public.encoded, // already a DER SubjectPublicKeyInfo
)
val signature = Signature.getInstance("SHA256withRSA").run {
initSign(keyPair.private)
update(tbs)
sign()
}
return seq(tbs, algorithmId, bitString(signature))
}
// --- minimal DER writer ------------------------------------------------------------
private fun length(n: Int): ByteArray {
if (n < 0x80) return byteArrayOf(n.toByte())
var value = n
val out = ArrayList<Byte>()
while (value > 0) {
out.add(0, (value and 0xFF).toByte())
value = value ushr 8
}
return byteArrayOf((0x80 or out.size).toByte()) + out.toByteArray()
}
private fun tlv(tag: Int, body: ByteArray) = byteArrayOf(tag.toByte()) + length(body.size) + body
private fun seq(vararg parts: ByteArray) = tlv(0x30, parts.reduce { a, b -> a + b })
private fun integer(v: BigInteger) = tlv(0x02, v.toByteArray())
private fun bitString(body: ByteArray) = tlv(0x03, byteArrayOf(0x00) + body)
private fun rdn(oid: ByteArray, value: String) =
tlv(0x31, seq(oid, tlv(0x0C, value.toByteArray(Charsets.UTF_8))))
private fun utcTime(date: Date): ByteArray {
val format = SimpleDateFormat("yyMMddHHmmss", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
return tlv(0x17, (format.format(date) + "Z").toByteArray(Charsets.US_ASCII))
}
}
@@ -0,0 +1,180 @@
package app.seb3thehacker.gearslip
/**
* Pulls the video service out of a ServiceDiscoveryResponse.
*
* Shape from aasdk (`service/Service.proto`, `service/media/sink/MediaSinkService.proto`,
* `.../message/VideoConfiguration.proto`):
*
* ServiceDiscoveryResponse { repeated Service channels = 1; ... }
* Service { required int32 id = 1; optional MediaSinkService media_sink_service = 3; ... }
* MediaSinkService { optional MediaCodecType available_type = 1;
* repeated VideoConfiguration video_configs = 4; ... }
* VideoConfiguration { codec_resolution = 1; frame_rate = 2; density = 5; ... }
*
* The channel id used in the frame header is this service's `id` - real Android Auto assigns
* channel ids dynamically (aasdk's static ChannelId enum is an approximation; see the TODO
* in include/aasdk/Messenger/ChannelId.hpp).
*/
object ServiceDiscovery {
/**
* [widthMargin]/[heightMargin] are the head unit's own numbers (VideoConfiguration fields 3
* and 4): pixels of the frame it does not treat as usable UI. Logged, and available to a
* vehicle profile, but not applied automatically - what they mean varies by unit.
*/
class VideoConfig(
val resolution: Int,
val frameRate: Int,
val density: Int,
val widthMargin: Int = 0,
val heightMargin: Int = 0,
) {
val resolutionName: String get() = RESOLUTIONS[resolution] ?: "unknown($resolution)"
val frameRateName: String get() = if (frameRate == 1) "60fps" else "30fps"
/** Pixel dimensions for this resolution enum, or null if we don't know it. */
fun pixelSize(): Pair<Int, Int>? = PIXEL_SIZES[resolution]
fun pixelCount(): Int = pixelSize()?.let { it.first * it.second } ?: Int.MAX_VALUE
override fun toString() =
"$resolutionName @ $frameRateName, density=$density, margin=${widthMargin}x$heightMargin"
}
class VideoService(val serviceId: Int, val codecType: Int, val configs: List<VideoConfig>) {
val codecName: String get() = CODECS[codecType] ?: "unknown($codecType)"
}
class AudioConfig(val sampleRate: Int, val bits: Int, val channels: Int)
class AudioService(
val serviceId: Int,
val streamType: Int,
val codecType: Int,
val configs: List<AudioConfig>,
) {
val codecName: String get() = CODECS[codecType] ?: "unknown($codecType)"
val streamName: String get() = STREAMS[streamType] ?: "unknown($streamType)"
}
/**
* Every audio sink the head unit offers (media, guidance, system, telephony). Same
* MediaSinkService as video, told apart by carrying `audio_type = 2` and
* `repeated AudioConfiguration audio_configs = 3 { sampling_rate = 1; bits = 2; channels = 3 }`.
*/
fun findAudioServices(response: ByteArray): List<AudioService> = buildList {
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
val service = Wire.fields(channel)
val id = Wire.varint(service, 1)?.toInt() ?: continue
val sink = Wire.bytes(service, 3) ?: continue
val sinkFields = Wire.fields(sink)
val audioConfigs = Wire.allBytes(sinkFields, 3)
if (audioConfigs.isEmpty()) continue
add(
AudioService(
serviceId = id,
streamType = Wire.varint(sinkFields, 2)?.toInt() ?: 0,
codecType = Wire.varint(sinkFields, 1)?.toInt() ?: 1,
configs = audioConfigs.map {
val f = Wire.fields(it)
AudioConfig(
sampleRate = Wire.varint(f, 1)?.toInt() ?: 48_000,
bits = Wire.varint(f, 2)?.toInt() ?: 16,
channels = Wire.varint(f, 3)?.toInt() ?: 2,
)
},
),
)
}
}
private val STREAMS = mapOf(0 to "NONE", 1 to "GUIDANCE", 2 to "SYSTEM_AUDIO", 3 to "MEDIA", 4 to "TELEPHONY")
private val RESOLUTIONS = mapOf(
1 to "800x480", 2 to "1280x720", 3 to "1920x1080", 4 to "2560x1440",
5 to "3840x2160", 6 to "720x1280", 7 to "1080x1920", 8 to "1440x2560", 9 to "2160x3840",
)
private val PIXEL_SIZES = mapOf(
1 to (800 to 480), 2 to (1280 to 720), 3 to (1920 to 1080), 4 to (2560 to 1440),
5 to (3840 to 2160), 6 to (720 to 1280), 7 to (1080 to 1920), 8 to (1440 to 2560),
9 to (2160 to 3840),
)
private val CODECS = mapOf(
1 to "AUDIO_PCM", 2 to "AUDIO_AAC_LC", 3 to "VIDEO_H264_BP",
4 to "AUDIO_AAC_LC_ADTS", 5 to "VIDEO_VP9", 6 to "VIDEO_AV1", 7 to "VIDEO_H265",
)
/**
* The head unit's touchscreen, from InputSourceService (Service field 4):
* InputSourceService { keycodes = 1; repeated TouchScreen touchscreen = 2; ... }
* TouchScreen { required int32 width = 1; required int32 height = 2; type = 3; }
*
* Touch coordinates arrive in this space (1258x708 on the 2018 Uconnect) and must be
* scaled to the projected video size before injection.
*/
class InputService(val serviceId: Int, val touchWidth: Int, val touchHeight: Int)
fun findInputService(response: ByteArray): InputService? {
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
val service = Wire.fields(channel)
val id = Wire.varint(service, 1)?.toInt() ?: continue
val input = Wire.bytes(service, 4) ?: continue
val touch = Wire.bytes(Wire.fields(input), 2) ?: continue
val touchFields = Wire.fields(touch)
val width = Wire.varint(touchFields, 1)?.toInt() ?: continue
val height = Wire.varint(touchFields, 2)?.toInt() ?: continue
return InputService(id, width, height)
}
return null
}
fun findVideoService(response: ByteArray): VideoService? {
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
val service = Wire.fields(channel)
val id = Wire.varint(service, 1)?.toInt() ?: continue
val sink = Wire.bytes(service, 3) ?: continue
val sinkFields = Wire.fields(sink)
val videoConfigs = Wire.allBytes(sinkFields, 4)
if (videoConfigs.isEmpty()) continue // audio sink, not video
val codecType = Wire.varint(sinkFields, 1)?.toInt() ?: 0
val configs = videoConfigs.map {
val f = Wire.fields(it)
VideoConfig(
resolution = Wire.varint(f, 1)?.toInt() ?: 0,
frameRate = Wire.varint(f, 2)?.toInt() ?: 0,
density = Wire.varint(f, 5)?.toInt() ?: 0,
widthMargin = Wire.varint(f, 3)?.toInt() ?: 0,
heightMargin = Wire.varint(f, 4)?.toInt() ?: 0,
)
}
return VideoService(id, codecType, configs)
}
return null
}
/**
* Who the head unit says it is, from the top-level strings of ServiceDiscoveryResponse.
* Field numbers as seen from a 2018 Dodge Challenger: 2="Uconnect", 3="Dodge Challenger",
* 4="18", 7="Delphi", 8="VP2_R Uconnect 7".
*/
class HeadUnitInfo(
val headUnitName: String,
val carModel: String,
val carYear: String,
val headUnitMake: String,
val headUnitModel: String,
) {
override fun toString() =
"name=\"$headUnitName\" car=\"$carModel\" year=\"$carYear\" " +
"make=\"$headUnitMake\" model=\"$headUnitModel\""
}
fun findHeadUnitInfo(response: ByteArray): HeadUnitInfo {
val fields = Wire.fields(response)
fun text(number: Int) = Wire.bytes(fields, number)?.toString(Charsets.UTF_8).orEmpty()
return HeadUnitInfo(text(2), text(3), text(4), text(7), text(8))
}
}
@@ -0,0 +1,46 @@
package app.seb3thehacker.gearslip
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* What the phone UI shows about the current car session.
*
* [GearslipRunner] reports progress here as it moves through the handshake, so the UI reads
* a plain state instead of parsing log lines. A failure stays on screen until the next
* attach starts, so the reason is still there when the user picks the phone back up.
*/
object SessionStatus {
enum class Phase { IDLE, CONNECTING, PROJECTING, FAILED, DISCONNECTED }
data class Snapshot(val phase: Phase, val headline: String, val detail: String)
private val flow = MutableStateFlow(
Snapshot(
Phase.IDLE,
"Not connected",
"Plug the phone into the car's USB port. Gearslip opens by itself when the head unit connects.",
),
)
val state: StateFlow<Snapshot> = flow
fun connecting(stage: String) {
flow.value = Snapshot(Phase.CONNECTING, "Connecting", stage)
}
fun projecting() {
flow.value = Snapshot(Phase.PROJECTING, "Projecting", "Streaming to the head unit.")
}
fun failed(headline: String, detail: String) {
flow.value = Snapshot(Phase.FAILED, headline, detail)
}
/** Keeps an earlier failure visible: a rejection is followed by a disconnect every time. */
fun disconnected() {
if (flow.value.phase == Phase.FAILED) return
flow.value = Snapshot(Phase.DISCONNECTED, "Disconnected", "The head unit is no longer connected.")
}
}
@@ -0,0 +1,149 @@
package app.seb3thehacker.gearslip
import java.net.Socket
import java.nio.ByteBuffer
import java.security.SecureRandom
import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLEngine
import javax.net.ssl.SSLEngineResult
import javax.net.ssl.SSLEngineResult.HandshakeStatus
import javax.net.ssl.X509ExtendedTrustManager
/**
* Runs [PhoneTls] against an in-process TLS client and checks that a full handshake plus a
* data round trip works.
*
* This exists because of one specific hazard: a bug in the certificate, the key manager or
* the engine wiring produces a dead connection in the car that is indistinguishable from a
* head unit rejecting us - and the whole point of the spike is a trustworthy go/no-go
* signal. Run this on the bench first; if it passes, a failure in the car is about the head
* unit, not about this code.
*/
object TlsSelfTest {
fun run(identity: CertProvider.Identity): Boolean {
GearslipLog.i("=== TLS self-test starting ===")
GearslipLog.i("cert source: ${identity.source}")
GearslipLog.i(" subject = ${identity.certificate.subjectX500Principal}")
GearslipLog.i(" issuer = ${identity.certificate.issuerX500Principal}")
return try {
val server = PhoneTls(identity.keyStore, identity.password)
val client = Driver(clientEngine())
var outbound = client.pump(null) // ClientHello
var rounds = 0
while (rounds++ < 20) {
if (outbound.isEmpty()) break
val fromServer = mutableListOf<ByteArray>()
outbound.forEach { fromServer += server.pumpHandshake(it) }
if (server.handshakeComplete && fromServer.isEmpty()) break
val fromClient = mutableListOf<ByteArray>()
fromServer.forEach { fromClient += client.pump(it) }
outbound = fromClient
}
if (!server.handshakeComplete) {
GearslipLog.e("self-test FAILED: handshake did not complete after $rounds rounds")
return false
}
// Data round trip, exercising the same encrypt path the spike uses for
// ServiceDiscoveryRequest.
val plaintext = "gearslip round trip".toByteArray()
val decrypted = client.decrypt(server.encrypt(plaintext))
if (!decrypted.contentEquals(plaintext)) {
GearslipLog.e("self-test FAILED: round trip mismatch (${decrypted.size} bytes back)")
return false
}
GearslipLog.i("=== TLS self-test PASSED - certificate, key manager and engine are sound ===")
true
} catch (t: Throwable) {
GearslipLog.e("self-test FAILED with an exception", t)
false
}
}
private fun clientEngine(): SSLEngine {
val context = SSLContext.getInstance("TLSv1.2")
context.init(null, arrayOf(TrustAll()), SecureRandom())
return context.createSSLEngine().apply {
useClientMode = true
enabledProtocols = arrayOf("TLSv1.2")
beginHandshake()
}
}
/** Deliberately a second, independent implementation - it should not share PhoneTls's bugs. */
private class Driver(private val engine: SSLEngine) {
private var netIn = ByteArray(0)
private var app = ByteBuffer.allocate(32 * 1024)
private var net = ByteBuffer.allocate(32 * 1024)
fun pump(incoming: ByteArray?): List<ByteArray> {
if (incoming != null) netIn += incoming
val out = mutableListOf<ByteArray>()
val source = ByteBuffer.wrap(netIn)
loop@ while (true) {
when (engine.handshakeStatus) {
HandshakeStatus.NEED_TASK -> {
var task = engine.delegatedTask
while (task != null) { task.run(); task = engine.delegatedTask }
}
HandshakeStatus.NEED_WRAP -> {
net.clear()
val r = engine.wrap(ByteBuffer.allocate(0), net)
if (r.status == SSLEngineResult.Status.BUFFER_OVERFLOW) {
net = ByteBuffer.allocate(net.capacity() * 2); continue@loop
}
net.flip()
if (net.hasRemaining()) out += ByteArray(net.remaining()).also { net.get(it) }
if (r.status == SSLEngineResult.Status.CLOSED) break@loop
}
HandshakeStatus.NEED_UNWRAP -> {
app.clear()
val r = engine.unwrap(source, app)
when (r.status) {
SSLEngineResult.Status.BUFFER_UNDERFLOW -> break@loop
SSLEngineResult.Status.BUFFER_OVERFLOW -> {
app = ByteBuffer.allocate(app.capacity() * 2); continue@loop
}
SSLEngineResult.Status.CLOSED -> break@loop
else -> Unit
}
}
else -> break@loop
}
}
netIn = ByteArray(source.remaining()).also { source.get(it) }
return out
}
fun decrypt(cipher: ByteArray): ByteArray {
val source = ByteBuffer.wrap(cipher)
val out = java.io.ByteArrayOutputStream()
while (source.hasRemaining()) {
app.clear()
val r = engine.unwrap(source, app)
if (r.status == SSLEngineResult.Status.BUFFER_OVERFLOW) {
app = ByteBuffer.allocate(app.capacity() * 2); continue
}
app.flip()
out.write(ByteArray(app.remaining()).also { app.get(it) })
if (r.bytesConsumed() == 0 && r.bytesProduced() == 0) break
}
return out.toByteArray()
}
}
private class TrustAll : X509ExtendedTrustManager() {
override fun checkClientTrusted(c: Array<out X509Certificate>?, a: String?) = Unit
override fun checkClientTrusted(c: Array<out X509Certificate>?, a: String?, s: Socket?) = Unit
override fun checkClientTrusted(c: Array<out X509Certificate>?, a: String?, e: SSLEngine?) = Unit
override fun checkServerTrusted(c: Array<out X509Certificate>?, a: String?) = Unit
override fun checkServerTrusted(c: Array<out X509Certificate>?, a: String?, s: Socket?) = Unit
override fun checkServerTrusted(c: Array<out X509Certificate>?, a: String?, e: SSLEngine?) = Unit
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
}
}
@@ -0,0 +1,114 @@
package app.seb3thehacker.gearslip
import android.content.Context
import java.io.File
import org.json.JSONArray
import org.json.JSONObject
/** Pixels of the video frame the car UI keeps clear on each side (the frame is painted, just not used). */
data class Insets(val top: Int = 0, val bottom: Int = 0, val left: Int = 0, val right: Int = 0) {
companion object { val NONE = Insets() }
}
/**
* Per-vehicle display tuning. Head units disagree about how they crop, offset and scale the
* projected frame, so nothing here is applied unless a profile matches the connected unit.
*
* Profiles live in `vehicles.json` in the app's external files dir, so they can be edited and
* pushed with adb without a rebuild:
*
* adb push vehicles.json /sdcard/Android/data/app.seb3thehacker.gearslip/files/vehicles.json
*
* A profile matches when every key in [match] equals the head unit's value (case-insensitive);
* keys it leaves out match anything. The first matching profile wins.
*/
data class VehicleProfile(
val name: String,
val match: Map<String, String>,
/** Preferred video resolution, e.g. "800x480". Null keeps the default choice. */
val resolution: String?,
val insets: Insets,
)
object VehicleProfiles {
private const val FILE = "vehicles.json"
private val DEFAULT_JSON = """
{
"_comment": "Per-vehicle display tuning. match keys: headUnitName, carModel, carYear, headUnitMake, headUnitModel (see the log line 'head unit: ...'). insets are pixels of the video frame to keep clear.",
"vehicles": [
{
"name": "2018 Dodge Challenger (Uconnect 4)",
"match": { "carModel": "Dodge Challenger", "headUnitName": "Uconnect" },
"resolution": "800x480",
"insets": { "top": 30, "bottom": 30, "left": 0, "right": 0 }
}
]
}
""".trimIndent()
/** Reads the file, writing the default one first if there is none so there is something to edit. */
fun load(context: Context): List<VehicleProfile> {
val file = context.getExternalFilesDir(null)?.let { File(it, FILE) } ?: return emptyList()
if (!file.isFile) runCatching { file.writeText(DEFAULT_JSON) }
return runCatching { parse(file.readText()) }
.onFailure { GearslipLog.e("could not read $FILE - ignoring vehicle profiles", it) }
.getOrDefault(emptyList())
}
/** Writes [insets] into the profile called [name] in the file, leaving everything else as it was. */
fun saveInsets(context: Context, name: String, insets: Insets): Boolean {
val file = context.getExternalFilesDir(null)?.let { File(it, FILE) } ?: return false
return runCatching {
val root = JSONObject(file.readText())
val vehicles = root.getJSONArray("vehicles")
var found = false
for (i in 0 until vehicles.length()) {
val v = vehicles.getJSONObject(i)
if (v.optString("name") == name) {
v.put(
"insets",
JSONObject()
.put("top", insets.top).put("bottom", insets.bottom)
.put("left", insets.left).put("right", insets.right),
)
found = true
}
}
if (found) file.writeText(root.toString(2))
found
}.onFailure { GearslipLog.e("could not save insets to $FILE", it) }.getOrDefault(false)
}
fun parse(json: String): List<VehicleProfile> {
val vehicles: JSONArray = JSONObject(json).optJSONArray("vehicles") ?: return emptyList()
return (0 until vehicles.length()).map { i ->
val v = vehicles.getJSONObject(i)
val match = v.optJSONObject("match")?.let { m -> m.keys().asSequence().associateWith { m.getString(it) } }
?: emptyMap()
val ins = v.optJSONObject("insets")
VehicleProfile(
name = v.optString("name", "vehicle $i"),
match = match,
resolution = v.optString("resolution").ifEmpty { null },
insets = if (ins == null) Insets.NONE else Insets(
ins.optInt("top"), ins.optInt("bottom"), ins.optInt("left"), ins.optInt("right"),
),
)
}
}
fun find(profiles: List<VehicleProfile>, info: ServiceDiscovery.HeadUnitInfo): VehicleProfile? {
val values = mapOf(
"headUnitName" to info.headUnitName,
"carModel" to info.carModel,
"carYear" to info.carYear,
"headUnitMake" to info.headUnitMake,
"headUnitModel" to info.headUnitModel,
)
return profiles.firstOrNull { p ->
p.match.isNotEmpty() && p.match.all { (k, want) -> values[k].equals(want, ignoreCase = true) }
}
}
}
@@ -0,0 +1,202 @@
package app.seb3thehacker.gearslip
import android.media.MediaCodec
import android.media.MediaCodecInfo
import android.media.MediaFormat
import android.os.Bundle
import android.media.Image
import android.view.Surface
/**
* Encodes a generated test card to H.264 Baseline and hands the bitstream to a callback.
*
* Feeds the encoder YUV420 buffers via getInputImage() rather than a Surface: a MediaCodec
* input Surface requires an EGL/OpenGL context to render into (Surface.lockCanvas is not
* supported on it), and for a static test card that is a lot of machinery for no benefit.
* Byte-buffer input keeps the spike dependency-free and fully deterministic. Phase C swaps
* this for a real Surface + GL path when the source becomes a live WebView.
*
* The head unit negotiated MEDIA_CODEC_VIDEO_H264_BP, so the profile is pinned to Baseline.
*/
class VideoSource(
private val width: Int,
private val height: Int,
private val frameRate: Int,
private val mode: Mode = Mode.TEST_CARD,
private val onCodecConfig: (ByteArray) -> Unit,
private val onFrame: (data: ByteArray, presentationTimeUs: Long, keyFrame: Boolean) -> Unit,
) {
/**
* TEST_CARD feeds generated YUV buffers; SURFACE exposes a MediaCodec input Surface for
* something else to render into (Phase C drives it from a VirtualDisplay + Presentation,
* which is how Android screen mirroring works and avoids hand-written EGL entirely).
*/
enum class Mode { TEST_CARD, SURFACE }
/** Non-null in SURFACE mode once [start] has run. */
var inputSurface: Surface? = null
private set
private var codec: MediaCodec? = null
private var worker: Thread? = null
@Volatile private var running = false
private var frameIndex = 0L
fun start() {
val format = MediaFormat.createVideoFormat(MIME, width, height).apply {
setInteger(
MediaFormat.KEY_COLOR_FORMAT,
if (mode == Mode.SURFACE) {
MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface
} else {
MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible
},
)
setInteger(MediaFormat.KEY_BIT_RATE, 2_000_000)
setInteger(MediaFormat.KEY_FRAME_RATE, frameRate)
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1)
setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline)
setInteger(MediaFormat.KEY_LEVEL, MediaCodecInfo.CodecProfileLevel.AVCLevel31)
}
val encoder = MediaCodec.createEncoderByType(MIME)
encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
if (mode == Mode.SURFACE) inputSurface = encoder.createInputSurface()
encoder.start()
codec = encoder
running = true
GearslipLog.i("encoder started: ${width}x$height @ ${frameRate}fps, H.264 Baseline, mode=$mode")
worker = Thread {
runCatching { pump(encoder) }
.onFailure { if (running) GearslipLog.e("encoder loop failed", it) }
}.apply { name = "gearslip-encoder"; start() }
}
/**
* Forces the next encoded frame to be a full keyframe (IDR) rather than a P-frame.
*
* Needed whenever a frame gets dropped between us and the head unit: every frame after
* the drop is a P-frame that assumes the decoder still has the reference picture we
* never delivered. Without a resync, the decoder free-runs on a corrupted reference
* forever - visually, exactly the smeared/ghosted look of a partial e-ink refresh,
* because nothing ever forces a clean full-frame redraw.
*/
fun requestSyncFrame() {
runCatching {
codec?.setParameters(Bundle().apply {
putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0)
})
}.onFailure { GearslipLog.w("requestSyncFrame failed: ${it.message}") }
}
fun stop() {
running = false
worker?.join(1_000)
runCatching { inputSurface?.release() }
inputSurface = null
runCatching { codec?.stop() }
runCatching { codec?.release() }
codec = null
}
private fun pump(encoder: MediaCodec) {
val info = MediaCodec.BufferInfo()
val frameIntervalUs = 1_000_000L / frameRate
while (running) {
// SURFACE mode has no input buffers to fill - frames arrive from the display.
val inIndex = if (mode == Mode.SURFACE) -1 else encoder.dequeueInputBuffer(TIMEOUT_US)
if (inIndex >= 0) {
val image = encoder.getInputImage(inIndex)
if (image != null) {
drawTestCard(image, frameIndex)
encoder.queueInputBuffer(
inIndex, 0, width * height * 3 / 2, frameIndex * frameIntervalUs, 0,
)
} else {
encoder.queueInputBuffer(inIndex, 0, 0, frameIndex * frameIntervalUs, 0)
}
frameIndex++
}
var outIndex = encoder.dequeueOutputBuffer(info, TIMEOUT_US)
while (outIndex >= 0) {
val buffer = encoder.getOutputBuffer(outIndex)
if (buffer != null && info.size > 0) {
val data = ByteArray(info.size)
buffer.position(info.offset)
buffer.get(data)
if (info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) {
onCodecConfig(data)
} else {
onFrame(
data,
info.presentationTimeUs,
info.flags and MediaCodec.BUFFER_FLAG_KEY_FRAME != 0,
)
}
}
encoder.releaseOutputBuffer(outIndex, false)
outIndex = encoder.dequeueOutputBuffer(info, 0)
}
Thread.sleep(maxOf(1L, frameIntervalUs / 2000))
}
}
/**
* Colour bars with a bar that advances each second, so the car screen shows something
* unmistakably ours and obviously live rather than a frozen or stale frame.
*/
private fun drawTestCard(image: Image, frame: Long) {
val yPlane = image.planes[0]
val uPlane = image.planes[1]
val vPlane = image.planes[2]
val bars = BAR_COLOURS.size
val barHeight = maxOf(1, height / bars)
val marker = ((frame / frameRate) % bars).toInt()
val yBuf = yPlane.buffer
val yStride = yPlane.rowStride
for (row in 0 until height) {
val bar = minOf(bars - 1, row / barHeight)
val (luma, _, _) = BAR_COLOURS[bar]
val value = if (bar == marker) 235 else luma
val base = row * yStride
for (col in 0 until width) {
yBuf.put(base + col, value.toByte())
}
}
val uBuf = uPlane.buffer
val vBuf = vPlane.buffer
for (row in 0 until height / 2) {
val bar = minOf(bars - 1, (row * 2) / barHeight)
val (_, cb, cr) = BAR_COLOURS[bar]
for (col in 0 until width / 2) {
uBuf.put(row * uPlane.rowStride + col * uPlane.pixelStride, cb.toByte())
vBuf.put(row * vPlane.rowStride + col * vPlane.pixelStride, cr.toByte())
}
}
}
private companion object {
const val MIME = MediaFormat.MIMETYPE_VIDEO_AVC
const val TIMEOUT_US = 10_000L
/** (Y, Cb, Cr) - white, yellow, cyan, green, magenta, red, blue. */
val BAR_COLOURS = listOf(
Triple(180, 128, 128),
Triple(162, 44, 142),
Triple(131, 156, 44),
Triple(112, 72, 58),
Triple(84, 184, 198),
Triple(65, 100, 212),
Triple(35, 212, 114),
)
}
}
@@ -0,0 +1,193 @@
package app.seb3thehacker.gearslip.audio
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioPlaybackCaptureConfiguration
import android.media.AudioRecord
import android.media.projection.MediaProjection
import android.media.projection.MediaProjectionManager
import android.os.IBinder
import app.seb3thehacker.gearslip.GearslipLog
import kotlin.math.abs
/**
* Hears one app's audio so it can be played on the car.
*
* Playback capture is the one public route to another app's sound, and it hangs off a
* MediaProjection - so, as with [app.seb3thehacker.gearslip.mirror.MirrorService], the consent
* result is carried into a foreground service of type mediaProjection before it can be redeemed.
* Capture is scoped to the media app's uid: notification pings and other apps stay on the phone.
*/
class AudioCaptureService : Service() {
@Volatile private var reading = false
private var projection: MediaProjection? = null
private var thread: Thread? = null
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) {
stopSelf()
return START_NOT_STICKY
}
val resultCode = intent?.getIntExtra(EXTRA_RESULT_CODE, 0) ?: 0
val data = intent?.getParcelableExtra(EXTRA_RESULT_DATA, Intent::class.java)
if (data == null) {
GearslipLog.e("audio: started without a consent result")
CarAudio.denied()
stopSelf()
return START_NOT_STICKY
}
startForeground(NOTIFICATION_ID, notification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION)
projection = try {
getSystemService(MediaProjectionManager::class.java).getMediaProjection(resultCode, data)
} catch (t: Throwable) {
GearslipLog.e("audio: getMediaProjection failed", t)
null
}
val token = projection
if (token == null) {
CarAudio.denied()
stopSelf()
return START_NOT_STICKY
}
token.registerCallback(object : MediaProjection.Callback() {
override fun onStop() {
GearslipLog.w("audio: projection stopped")
stopSelf()
}
}, null)
beginCapture(token)
return START_NOT_STICKY
}
private fun beginCapture(token: MediaProjection) {
val rate = CarAudio.sampleRate
val channels = CarAudio.channels
val uid = CarAudio.uid
val record = try {
val config = AudioPlaybackCaptureConfiguration.Builder(token)
.addMatchingUsage(AudioAttributes.USAGE_MEDIA)
.addMatchingUsage(AudioAttributes.USAGE_GAME)
.addMatchingUsage(AudioAttributes.USAGE_UNKNOWN)
.apply { if (uid >= 0) addMatchingUid(uid) }
.build()
val mask = if (channels == 1) AudioFormat.CHANNEL_IN_MONO else AudioFormat.CHANNEL_IN_STEREO
val format = AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(rate)
.setChannelMask(mask)
.build()
val minimum = AudioRecord.getMinBufferSize(rate, mask, AudioFormat.ENCODING_PCM_16BIT)
AudioRecord.Builder()
.setAudioPlaybackCaptureConfig(config)
.setAudioFormat(format)
.setBufferSizeInBytes(maxOf(minimum, CHUNK_BYTES * 4))
.build()
} catch (t: Throwable) {
GearslipLog.e("audio: could not set up playback capture", t)
CarAudio.denied()
stopSelf()
return
}
reading = true
thread = Thread({
val buffer = ByteArray(CHUNK_BYTES)
var peak = 0
var heardAt = System.currentTimeMillis()
try {
record.startRecording()
GearslipLog.i("audio: capturing uid=$uid at ${rate}Hz x$channels")
CarAudio.captureStarted()
while (reading) {
val n = record.read(buffer, 0, buffer.size)
if (n <= 0) break
for (i in 0 until n - 1 step 2) {
val sample = (buffer[i].toInt() and 0xFF) or (buffer[i + 1].toInt() shl 8)
peak = maxOf(peak, abs(sample.toShort().toInt()))
}
val now = System.currentTimeMillis()
val level = peak / 32768f
if (now - heardAt >= LEVEL_LOG_MS) {
GearslipLog.i("audio: peak %.3f".format(level))
heardAt = now
peak = 0
}
CarAudio.deliver(buffer, n, level)
}
} catch (t: Throwable) {
GearslipLog.e("audio: capture loop ended", t)
} finally {
runCatching { record.stop() }
record.release()
CarAudio.captureStopped()
}
}, "gearslip-audio").apply { isDaemon = true; start() }
}
override fun onDestroy() {
super.onDestroy()
reading = false
runCatching { thread?.join(500) }
runCatching { projection?.stop() }
projection = null
}
private fun notification(): Notification {
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(
NotificationChannel(CHANNEL, "Car audio", NotificationManager.IMPORTANCE_LOW),
)
val stop = PendingIntent.getService(
this, 0,
Intent(this, AudioCaptureService::class.java).setAction(ACTION_STOP),
PendingIntent.FLAG_IMMUTABLE,
)
return Notification.Builder(this, CHANNEL)
.setContentTitle("Playing to the car")
.setContentText("Gearslip is sending a media app's audio to the car.")
.setSmallIcon(android.R.drawable.ic_lock_silent_mode_off)
.setOngoing(true)
.addAction(Notification.Action.Builder(null, "Stop", stop).build())
.build()
}
companion object {
private const val CHANNEL = "gearslip_audio"
private const val NOTIFICATION_ID = 43
private const val ACTION_STOP = "app.seb3thehacker.gearslip.STOP_AUDIO"
private const val EXTRA_RESULT_CODE = "resultCode"
private const val EXTRA_RESULT_DATA = "resultData"
/** About 21 ms of 48 kHz stereo: small enough for lip-sync, big enough not to flood the link. */
private const val CHUNK_BYTES = 4096
private const val LEVEL_LOG_MS = 3_000L
private var appContext: Context? = null
fun start(context: Context, resultCode: Int, data: Intent) {
appContext = context.applicationContext
context.startForegroundService(
Intent(context, AudioCaptureService::class.java)
.putExtra(EXTRA_RESULT_CODE, resultCode)
.putExtra(EXTRA_RESULT_DATA, data),
)
}
fun stop() {
val context = appContext ?: return
context.startService(Intent(context, AudioCaptureService::class.java).setAction(ACTION_STOP))
}
}
}
@@ -0,0 +1,148 @@
package app.seb3thehacker.gearslip.audio
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.Protobuf
import app.seb3thehacker.gearslip.ServiceDiscovery
import app.seb3thehacker.gearslip.Wire
/**
* The phone end of the head unit's media audio sink, the audio twin of the video channel:
*
* -> CHANNEL_OPEN_REQUEST <- CHANNEL_OPEN_RESPONSE
* -> Setup { PCM } <- Config { status, max_unacked, configuration_indices }
* -> AudioFocusRequest <- AudioFocusResponse (control channel)
* -> Start { session_id, configuration_index }
* -> Data { 8-byte timestamp, PCM }... <- Ack { session_id, ack }
* -> Stop
*
* Inverted from aasdk's AudioMediaSinkService. Bench-verified only as far as capture goes:
* nothing on this side of the wire has met a head unit yet.
*/
class AudioLink(
private val service: ServiceDiscovery.AudioService,
private val sendOnChannel: (messageId: Int, body: ByteArray) -> Unit,
private val sendControl: (messageId: Int, body: ByteArray) -> Unit,
) : CarAudio.Link {
private var configIndex = 0
@Volatile private var ready = false
@Volatile private var started = false
@Volatile private var inFlight = 0
@Volatile private var maxUnacked = 8
@Volatile private var lastAckAt = 0L
private var chunks = 0
private val config get() = service.configs.getOrNull(configIndex) ?: service.configs.firstOrNull()
override val sampleRate get() = config?.sampleRate ?: 48_000
override val channels get() = config?.channels ?: 2
val channelId get() = service.serviceId
fun opened(status: Int) {
if (status != 0) {
GearslipLog.w("audio: the head unit refused the media audio channel (status $status)")
return
}
sendOnChannel(MSG_SETUP, Protobuf.varintField(1, service.codecType.toLong()))
GearslipLog.i("audio: -> Setup(codec=${service.codecName})")
}
fun onMessage(messageId: Int, body: ByteArray) {
when (messageId) {
MSG_CHANNEL_OPEN_RESPONSE -> opened(Protobuf.readInt32Field(body, 1) ?: 0)
MSG_CONFIG -> {
val fields = Wire.fields(body)
val status = Wire.varint(fields, 1)?.toInt()
val window = Wire.varint(fields, 2)?.toInt()
val indices = fields.filter { it.number == 3 && it.wireType == 0 }.map { it.varint.toInt() }
GearslipLog.i("audio: <- Config status=$status max_unacked=$window indices=$indices")
if (window != null) maxUnacked = maxOf(1, window)
configIndex = indices.firstOrNull() ?: 0
if (status == STATUS_READY) {
ready = true
GearslipLog.i("audio: ready - ${sampleRate}Hz x$channels ${config?.bits}bit")
CarAudio.attach(this)
}
}
MSG_ACK -> {
val acked = Wire.varint(Wire.fields(body), 2)?.toInt() ?: 1
inFlight = maxOf(0, inFlight - maxOf(1, acked))
lastAckAt = System.currentTimeMillis()
}
else -> GearslipLog.i("audio: <- unhandled message id=$messageId (${body.size} bytes)")
}
}
fun onFocus(state: Int) {
GearslipLog.i("audio: <- AudioFocus state=$state (1=GAIN, 2=GAIN_TRANSIENT, 3=LOSS, 4=LOSS_TRANSIENT)")
}
override fun start() {
if (!ready || started) return
sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_GAIN.toLong()))
sendOnChannel(
MSG_START,
Protobuf.varintField(1, SESSION_ID.toLong()) + Protobuf.varintField(2, configIndex.toLong()),
)
inFlight = 0
lastAckAt = System.currentTimeMillis()
started = true
GearslipLog.i("audio: -> AudioFocusRequest(GAIN), Start(config=$configIndex)")
}
override fun stop() {
if (!started) return
started = false
runCatching {
sendOnChannel(MSG_STOP, Protobuf.varintField(1, SESSION_ID.toLong()))
sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_RELEASE.toLong()))
}
GearslipLog.i("audio: -> Stop, AudioFocusRequest(RELEASE) after $chunks chunks")
chunks = 0
}
override fun write(pcm: ByteArray, size: Int) {
if (!started) return
if (inFlight >= maxUnacked) {
// A lost ack must not silence the stream for good; after a stall assume the window cleared.
if (System.currentTimeMillis() - lastAckAt > STALL_MS) inFlight = 0 else return
}
val timestamp = ByteArray(8)
val micros = System.nanoTime() / 1_000
for (i in 0 until 8) timestamp[i] = ((micros shr ((7 - i) * 8)) and 0xFF).toByte()
try {
sendOnChannel(MSG_DATA, timestamp + pcm.copyOf(size))
} catch (e: java.io.IOException) {
started = false
return
}
inFlight++
chunks++
}
fun close() {
stop()
ready = false
CarAudio.detach(this)
}
companion object {
const val MSG_FOCUS_REQUEST = 18
const val MSG_FOCUS_RESPONSE = 19
private const val MSG_CHANNEL_OPEN_RESPONSE = 8
private const val MSG_DATA = 0
private const val MSG_SETUP = 32768
private const val MSG_START = 32769
private const val MSG_STOP = 32770
private const val MSG_CONFIG = 32771
private const val MSG_ACK = 32772
private const val STATUS_READY = 2
private const val FOCUS_GAIN = 1
private const val FOCUS_RELEASE = 4
private const val SESSION_ID = 2
private const val STALL_MS = 2_000L
}
}
@@ -0,0 +1,98 @@
package app.seb3thehacker.gearslip.audio
import app.seb3thehacker.gearslip.GearslipLog
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Joins the two ends of the audio path: [AudioCaptureService] on the phone, which hears what a
* media app plays, and the car's audio channel, which is where it has to end up.
*
* They come and go independently. The phone side needs on-device consent and can be running on
* the bench with no car attached; the car side exists only while a head unit is connected and
* offers a media audio sink. Whichever pair is present, samples flow.
*/
object CarAudio {
/** What the head unit's media sink accepts, and how to hand it audio. */
interface Link {
val sampleRate: Int
val channels: Int
fun start()
fun stop()
fun write(pcm: ByteArray, size: Int)
}
enum class Capture { OFF, ASKING, ON, DENIED }
private val _capture = MutableStateFlow(Capture.OFF)
val capture: StateFlow<Capture> = _capture.asStateFlow()
/** "-12 dB" style summary of what was last heard, for the media screen. */
private val _level = MutableStateFlow(0f)
val level: StateFlow<Float> = _level.asStateFlow()
/** Set by the activity: shows the permission and consent dialogs, which only it can. */
var onConsentRequested: (() -> Unit)? = null
/** The uid being captured (or waited on). */
@Volatile var uid: Int = -1
private set
@Volatile private var link: Link? = null
/** Rate and channel count the capture should use: the car's, or a sensible default. */
val sampleRate get() = link?.sampleRate ?: 48_000
val channels get() = link?.channels ?: 2
fun attach(link: Link) {
this.link = link
if (_capture.value == Capture.ON) link.start()
}
fun detach(link: Link) {
if (this.link === link) this.link = null
}
/** A media app was opened: start hearing it. */
fun request(uid: Int) {
if (this.uid == uid && _capture.value != Capture.OFF && _capture.value != Capture.DENIED) return
if (this.uid != uid) AudioCaptureService.stop()
this.uid = uid
_capture.value = Capture.ASKING
val ask = onConsentRequested
if (ask == null) {
GearslipLog.w("audio: nothing on the phone can ask for capture consent")
_capture.value = Capture.DENIED
} else {
ask()
}
}
fun release() {
uid = -1
AudioCaptureService.stop()
_capture.value = Capture.OFF
}
fun denied() {
_capture.value = Capture.DENIED
}
internal fun captureStarted() {
_capture.value = Capture.ON
link?.start()
}
internal fun captureStopped() {
link?.stop()
_level.value = 0f
if (_capture.value == Capture.ON) _capture.value = Capture.OFF
}
internal fun deliver(pcm: ByteArray, size: Int, peak: Float) {
_level.value = peak
link?.write(pcm, size)
}
}
@@ -0,0 +1,89 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.sp
/**
* Draws the raw video frame with pixel rulers, for measuring what the head unit actually shows.
*
* Numbers are frame pixels. Read the last number visible on each edge to see how much the head
* unit crops there (the frame is 800 wide by 480 tall when the ruler runs to those values), and
* tap anywhere: the crosshair is drawn where the phone believes the touch landed, so a mismatch
* with your finger is the touch offset. Ignores vehicle insets on purpose.
*/
@Composable
fun CalibrationApp() {
val navigator = LocalCarNavigator.current
val measurer = rememberTextMeasurer()
var touch by remember { mutableStateOf<Offset?>(null) }
Box(
Modifier
.fillMaxSize()
.background(Color.Black)
.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown()
touch = down.position
}
},
) {
Canvas(Modifier.fillMaxSize()) {
val label = TextStyle(color = Color.White, fontSize = 11.sp)
val faint = Color(0x55FFFFFF)
val w = size.width
val h = size.height
var y = 0
while (y <= h) {
drawLine(if (y % 100 == 0) Color.White else faint, Offset(0f, y.toFloat()), Offset(w, y.toFloat()), strokeWidth = 1f)
if (y % 20 == 0) {
drawText(measurer, "$y", Offset(2f, y.toFloat()), style = label)
drawText(measurer, "$y", Offset(w - 30f, y.toFloat()), style = label)
}
y += 20
}
var x = 0
while (x <= w) {
drawLine(if (x % 100 == 0) Color.White else faint, Offset(x.toFloat(), 0f), Offset(x.toFloat(), h), strokeWidth = 1f)
if (x % 50 == 0 && x > 0) {
drawText(measurer, "$x", Offset(x.toFloat() + 2f, 2f), style = label)
drawText(measurer, "$x", Offset(x.toFloat() + 2f, h - 46f), style = label)
}
x += 20
}
// Frame border, drawn last and inset by 1px so it is the outermost thing that can show.
drawRect(Color.Red, topLeft = Offset(1f, 1f), size = androidx.compose.ui.geometry.Size(w - 2f, h - 2f), style = androidx.compose.ui.graphics.drawscope.Stroke(2f))
drawText(measurer, "frame ${w.toInt()}x${h.toInt()}", Offset(w / 2 - 50f, h / 2 - 60f), style = TextStyle(color = Color.Yellow, fontSize = 14.sp))
touch?.let { t ->
drawLine(Color.Green, Offset(t.x, 0f), Offset(t.x, h), strokeWidth = 2f)
drawLine(Color.Green, Offset(0f, t.y), Offset(w, t.y), strokeWidth = 2f)
drawText(measurer, "touch ${t.x.toInt()},${t.y.toInt()}", Offset(w / 2 - 50f, h / 2 + 40f), style = TextStyle(color = Color.Green, fontSize = 14.sp))
}
}
Button(onClick = { navigator.home() }, modifier = Modifier.align(Alignment.Center)) {
Text("Exit calibration")
}
}
}
@@ -0,0 +1,191 @@
package app.seb3thehacker.gearslip.car
import android.view.SurfaceHolder
import android.view.SurfaceView
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.car.app.model.Template
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.host.CarAppConnection
/**
* Shows a connected templated app: its own drawing surface, with host chrome over the top.
*
* The surface is a plain SurfaceView. Because the whole car UI is a Presentation on a
* VirtualDisplay wired to the encoder, a SurfaceView here is composited by SurfaceFlinger into
* that display like any other layer - so the app's map reaches the head unit down the existing
* H.264 pipe without Gearslip ever touching a pixel of it.
*/
@Composable
fun CarAppStage(
connection: CarAppConnection,
status: CarAppConnection.Status,
frame: CarEnvironment.Frame,
/** Inside the home screen's map pane: no title bar, the template gets the whole area. */
embedded: Boolean = false,
onDisconnect: () -> Unit,
) {
val template by connection.template.collectAsStateWithLifecycle()
Box(Modifier.fillMaxSize()) {
// The app's own drawing goes underneath; everything else is the host's rendering of
// the template it described, laid over the top.
if (status.surfaceRequested) {
AppSurface(connection, frame, template, Modifier.fillMaxSize())
}
TemplateChrome(template, Modifier.fillMaxSize().padding(top = if (embedded) 0.dp else CHROME_BAR))
if (embedded) CornerMask(EMBEDDED_RADIUS, MaterialTheme.colorScheme.background)
if (!embedded) ChromeBar(status, onDisconnect, Modifier.align(Alignment.TopCenter))
}
}
/**
* A SurfaceView lent to the app for as long as it is on screen.
*
* The app writes into this from its own process, so the surface must outlive the handoff;
* [SurfaceHolder.Callback] is what tells us when that stops being true.
*/
@Composable
private fun AppSurface(
connection: CarAppConnection,
frame: CarEnvironment.Frame,
template: Template?,
modifier: Modifier = Modifier,
) {
var size by remember { mutableStateOf(0 to 0) }
// The app lays its map out around whatever the host covers, so a template change moves the
// visible area even though the surface itself hasn't changed.
LaunchedEffect(template, size) {
val (width, height) = size
if (width > 0 && height > 0) {
val visible = visibleAreaFor(template, width, height)
connection.reportAreas(visible = visible, stable = visible)
}
}
AndroidView(
modifier = modifier
.pointerInput(connection) {
detectTapGestures { connection.surfaceClick(it.x, it.y) }
}
.pointerInput(connection) {
detectTransformGestures { centroid, pan, zoom, _ ->
// GestureDetector reports scroll as previous-minus-current; a Compose pan
// is the other way round.
if (pan != Offset.Zero) connection.surfaceScroll(-pan.x, -pan.y)
if (zoom != 1f) connection.surfaceScale(centroid.x, centroid.y, zoom)
}
},
factory = { context ->
SurfaceView(context).apply {
holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) = Unit
override fun surfaceChanged(
holder: SurfaceHolder,
format: Int,
width: Int,
height: Int,
) {
GearslipLog.i("stage: surface ready at ${width}x$height")
size = width to height
connection.attachSurface(holder.surface, width, height, frame.densityDpi)
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
GearslipLog.i("stage: surface destroyed")
connection.detachSurface()
}
})
}
},
)
DisposableEffect(connection) { onDispose { connection.detachSurface() } }
}
@Composable
private fun ChromeBar(
status: CarAppConnection.Status,
onDisconnect: () -> Unit,
modifier: Modifier = Modifier,
) {
Surface(
color = Color.Black.copy(alpha = 0.55f),
contentColor = Color.White,
modifier = modifier.fillMaxWidth(),
) {
Row(
Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(
status.app ?: "Car app",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
val detail = buildList {
status.template?.let { add(it) }
add(if (status.surfaceAttached) "drawing" else "waiting for the app")
}.joinToString(" - ")
Text(detail, style = MaterialTheme.typography.bodySmall)
}
TextButton(onClick = onDisconnect) { Text("Stop", color = Color.White) }
}
}
}
/** Corner radius of the home screen's map pane and player. */
val EMBEDDED_RADIUS = 8.dp
/**
* Rounds the corners of a SurfaceView, which Compose cannot clip: the app draws into its own
* layer, so the corners are covered from above with the surrounding colour instead.
*/
@Composable
private fun CornerMask(radius: androidx.compose.ui.unit.Dp, color: Color) {
androidx.compose.foundation.Canvas(Modifier.fillMaxSize()) {
val bounds = androidx.compose.ui.geometry.Rect(0f, 0f, size.width, size.height)
val cut = androidx.compose.ui.graphics.Path().apply {
addRect(bounds)
addRoundRect(
androidx.compose.ui.geometry.RoundRect(
bounds, androidx.compose.ui.geometry.CornerRadius(radius.toPx()),
),
)
fillType = androidx.compose.ui.graphics.PathFillType.EvenOdd
}
drawPath(cut, color)
}
}
/** Height of [ChromeBar]; the template's own chrome starts below it. */
private val CHROME_BAR = 44.dp
@@ -0,0 +1,40 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Settings
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import app.seb3thehacker.gearslip.ProjectedContent
/** One tile in the launcher and the screen it opens. */
class CarApp(
val id: String,
val label: String,
val icon: ImageVector,
val content: @Composable () -> Unit,
)
/** Built-in apps. Third-party (Android Auto) apps are a later step and will register here too. */
object CarApps {
val all: List<CarApp> = listOf(
CarApp("web", "Web", Icons.Filled.Search) {
WebApp(content = ProjectedContent.resolve(LocalContext.current))
},
CarApp("phone", "Screen sharing", Icons.Filled.Share) {
PhoneAppsScreen()
},
CarApp("calibrate", "Display test", Icons.Filled.Info) {
CalibrationApp()
},
CarApp("settings", "Settings", Icons.Filled.Settings) {
CarSettingsScreen()
},
)
fun find(id: String): CarApp? = all.firstOrNull { it.id == id }
}
@@ -0,0 +1,140 @@
package app.seb3thehacker.gearslip.car
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.res.Configuration
import android.os.BatteryManager
import android.os.Handler
import android.os.Looper
import java.util.Calendar
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* What the car UI and the apps inside it can know about the world outside the UI.
*
* [phoneDark] is the phone's own theme and picks the car UI's colours. [darkOutside] is a
* separate signal, "is it dark out", for apps that want to adapt (a map, a web page).
*
* Where [darkOutside] comes from is deliberately swappable. Today it is a time-of-day guess.
* The right source is the head unit's own night-mode sensor, which arrives on the AA sensor
* channel once that exists; feed it in through [setSensorNight] and it takes over.
*/
object CarEnvironment {
data class Battery(val percent: Int, val charging: Boolean)
private val phoneDarkFlow = MutableStateFlow(false)
private val darkOutsideFlow = MutableStateFlow(false)
private val batteryFlow = MutableStateFlow(Battery(-1, false))
private val displayFlow = MutableStateFlow("")
/** The projected frame's real geometry, for anything that has to lay out against it. */
data class Frame(val width: Int, val height: Int, val densityDpi: Int)
private val frameFlow = MutableStateFlow(Frame(800, 480, 160))
val frame: StateFlow<Frame> = frameFlow
val phoneDark: StateFlow<Boolean> = phoneDarkFlow
val darkOutside: StateFlow<Boolean> = darkOutsideFlow
val battery: StateFlow<Battery> = batteryFlow
/** Human-readable projected resolution, for the connection info in car Settings. */
val display: StateFlow<String> = displayFlow
private val vehicleFlow = MutableStateFlow("")
private val insetsFlow = MutableStateFlow(app.seb3thehacker.gearslip.Insets.NONE)
/** Name of the matched vehicle profile, for the connection info in car Settings. */
val vehicle: StateFlow<String> = vehicleFlow
/** How much of the video frame the car UI keeps clear, from the matched vehicle profile. */
val insets: StateFlow<app.seb3thehacker.gearslip.Insets> = insetsFlow
/** Live adjustment from car Settings; not persisted until saved to the vehicle profile. */
fun setInsets(insets: app.seb3thehacker.gearslip.Insets) {
insetsFlow.value = insets
}
fun setVehicle(name: String, insets: app.seb3thehacker.gearslip.Insets) {
vehicleFlow.value = name
insetsFlow.value = insets
}
@Volatile private var sensorNight: Boolean? = null
private var appContext: Context? = null
private var running = false
private val handler = Handler(Looper.getMainLooper())
private val tick = object : Runnable {
override fun run() {
refresh()
handler.postDelayed(this, 60_000)
}
}
private val batteryReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
intent?.let(::readBattery)
}
}
fun setPhoneTheme(configuration: Configuration) {
phoneDarkFlow.value =
(configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
}
/** For the AA sensor channel: null goes back to the time-of-day guess. */
fun setSensorNight(night: Boolean?) {
sensorNight = night
refresh()
}
fun setDisplay(width: Int, height: Int, densityDpi: Int) {
displayFlow.value = "${width}x$height @ ${densityDpi}dpi"
frameFlow.value = Frame(width, height, densityDpi)
}
fun start(context: Context) {
if (running) return
running = true
appContext = context.applicationContext
setPhoneTheme(context.resources.configuration)
val sticky = context.applicationContext.registerReceiver(
batteryReceiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED), Context.RECEIVER_NOT_EXPORTED,
)
sticky?.let(::readBattery)
handler.post(tick)
}
fun stop() {
if (!running) return
running = false
handler.removeCallbacks(tick)
runCatching { appContext?.unregisterReceiver(batteryReceiver) }
}
fun refresh() {
darkOutsideFlow.value = when (CarSettings.nightMode.value) {
NightMode.DAY -> false
NightMode.NIGHT -> true
NightMode.AUTO -> sensorNight ?: guessNightFromClock()
}
}
private fun guessNightFromClock(): Boolean {
val hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY)
return hour >= 20 || hour < 6
}
private fun readBattery(intent: Intent) {
val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, 100)
val status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1)
val charging = status == BatteryManager.BATTERY_STATUS_CHARGING ||
status == BatteryManager.BATTERY_STATUS_FULL
batteryFlow.value = Battery(if (level >= 0 && scale > 0) level * 100 / scale else -1, charging)
}
}
@@ -0,0 +1,183 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.draw.clip
import android.media.session.PlaybackState
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.seb3thehacker.gearslip.host.CarAppConnection
import app.seb3thehacker.gearslip.media.CarMedia
import app.seb3thehacker.gearslip.media.MediaArt
import kotlinx.coroutines.delay
/**
* The home screen: the map on the left, what is playing on the right.
*
* The map is whatever navigation app was used last, brought back on its own. The player only
* takes its share of the screen while something is loaded; otherwise the map has all of it.
*/
@Composable
fun CarHome() {
val frame by CarEnvironment.frame.collectAsStateWithLifecycle()
val navStatus by CarServices.nav.status.collectAsStateWithLifecycle()
val phase by CarServices.media.phase.collectAsStateWithLifecycle()
val now by CarServices.media.now.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { CarServices.autostart(frame) }
LaunchedEffect(phase) { if (phase == CarMedia.Phase.READY) CarServices.autoplayIfDue() }
val showPlayer = phase == CarMedia.Phase.READY && now.hasTrack
Row(Modifier.fillMaxSize().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(
Modifier
.weight(if (showPlayer) 1.8f else 1f)
.fillMaxHeight()
.clip(RoundedCornerShape(EMBEDDED_RADIUS)),
) {
MapPane(navStatus, frame)
}
if (showPlayer) {
MiniPlayer(Modifier.weight(1f).fillMaxHeight())
}
}
}
@Composable
private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame) {
val navigator = LocalCarNavigator.current
if (status.phase == CarAppConnection.Phase.RUNNING) {
CarAppStage(CarServices.nav, status, frame, embedded = true) { CarServices.stopNav() }
return
}
Column(
Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
val hasLast = CarSettings.lastNav.value != null
when (status.phase) {
CarAppConnection.Phase.BINDING, CarAppConnection.Phase.HANDSHAKE ->
Text("Starting ${status.app ?: "the map"}…", style = MaterialTheme.typography.titleMedium)
CarAppConnection.Phase.REJECTED, CarAppConnection.Phase.FAILED -> {
Text("${status.app ?: "The map app"} would not start.", style = MaterialTheme.typography.titleMedium)
status.detail?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) }
TextButton(onClick = navigator::apps) { Text("Choose another app") }
}
else -> {
Text(
if (hasLast) "Starting the map…" else "No map app chosen yet.",
style = MaterialTheme.typography.titleMedium,
)
if (!hasLast) {
Text(
"Open Car apps and connect a navigation app; it will start here next time.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TextButton(onClick = { navigator.apps() }) { Text("Car apps") }
}
}
}
}
}
/** Play/pause and a seek bar, and nothing else: the full controls are in the media screen. */
@Composable
private fun MiniPlayer(modifier: Modifier) {
val media = CarServices.media
val now by media.now.collectAsStateWithLifecycle()
val context = LocalContext.current
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
var position by remember { mutableStateOf(0L) }
LaunchedEffect(now) {
while (true) {
position = now.currentPosition()
delay(500)
}
}
val navigator = LocalCarNavigator.current
Surface(
onClick = navigator::media,
modifier = modifier,
shape = RoundedCornerShape(EMBEDDED_RADIUS),
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Column(
Modifier.fillMaxSize().padding(10.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Box(Modifier.size(120.dp).clip(RoundedCornerShape(EMBEDDED_RADIUS))) {
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
}
Spacer(Modifier.height(10.dp))
Text(
now.title.ifEmpty { "Nothing playing" },
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
Text(
now.artist,
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
if (now.durationMs > 0) {
WavyProgress(
fraction = position.toFloat() / now.durationMs,
playing = now.state == PlaybackState.STATE_PLAYING,
onSeek = { media.seek((it * now.durationMs).toLong()) },
modifier = Modifier.padding(vertical = 4.dp),
)
} else {
Spacer(Modifier.height(16.dp))
}
FilledIconButton(onClick = media::togglePlay, modifier = Modifier.size(64.dp)) {
Icon(
if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow,
if (now.playing) "Pause" else "Play",
Modifier.size(38.dp),
)
}
}
}
}
@@ -0,0 +1,155 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.clickable
import androidx.compose.foundation.shape.RoundedCornerShape
/**
* A keyboard the host draws itself.
*
* Text entry in a car can't use the phone's IME: the car UI lives in a Presentation on a
* virtual display, where a system keyboard has nowhere sensible to appear, and a phone keyboard
* is the wrong shape for a screen a driver glances at anyway. Android Auto draws its own for
* the same reasons, so this does too - large keys, no long-press, no prediction.
*
* It is a pure function of [text]: every key reports the new string and holds no state beyond
* which layer is showing.
*/
@Composable
fun CarKeyboard(
text: String,
onTextChange: (String) -> Unit,
onSubmit: () -> Unit,
modifier: Modifier = Modifier,
submitIcon: Boolean = true,
submitImage: ImageVector? = null,
) {
var symbols by remember { mutableStateOf(false) }
var shifted by remember { mutableStateOf(false) }
val rows = if (symbols) SYMBOL_ROWS else LETTER_ROWS
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = modifier.fillMaxWidth(),
) {
Column(
Modifier.padding(horizontal = 6.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
rows.forEach { row ->
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
row.forEach { key ->
val label = if (shifted && !symbols) key.uppercase() else key
Key(label, Modifier.weight(1f)) {
onTextChange(text + label)
shifted = false
}
}
}
}
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Key(if (symbols) "abc" else "?123", Modifier.weight(1.4f)) { symbols = !symbols }
if (!symbols) {
Key(if (shifted) "SHIFT" else "shift", Modifier.weight(1.4f)) { shifted = !shifted }
}
Key("space", Modifier.weight(3f)) { onTextChange("$text ") }
Key("⌫", Modifier.weight(1.4f)) {
if (text.isNotEmpty()) onTextChange(text.dropLast(1))
}
SubmitKey(Modifier.weight(1.6f), submitIcon, submitImage, onSubmit)
}
}
}
}
@Composable
private fun Key(label: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor = MaterialTheme.colorScheme.onSurface,
shape = RoundedCornerShape(8.dp),
modifier = modifier.height(KEY_HEIGHT).clickable(onClick = onClick),
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
label,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium,
maxLines = 1,
)
}
}
}
@Composable
private fun SubmitKey(modifier: Modifier, search: Boolean, image: ImageVector?, onClick: () -> Unit) {
Surface(
color = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
shape = RoundedCornerShape(8.dp),
modifier = modifier.height(KEY_HEIGHT).clickable(onClick = onClick),
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
image ?: if (search) Icons.Filled.Search else Icons.Filled.ArrowBack,
contentDescription = "Submit",
)
}
}
}
/**
* Big enough to hit on a bumpy road, small enough that four rows plus a field plus a few
* results all fit a 480px-tall screen - which is the real constraint here, not the key size.
*/
private val KEY_HEIGHT = 34.dp
private val LETTER_ROWS = listOf(
"qwertyuiop".map { it.toString() },
"asdfghjkl".map { it.toString() },
"zxcvbnm".map { it.toString() },
)
private val SYMBOL_ROWS = listOf(
"1234567890".map { it.toString() },
listOf("-", "/", ":", ";", "(", ")", "$", "&", "@", "\""),
listOf(".", ",", "?", "!", "'", "#", "%", "*", "+", "="),
)
@@ -0,0 +1,127 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.graphics.drawable.toBitmap
import app.seb3thehacker.gearslip.host.CarAppCatalog
import app.seb3thehacker.gearslip.media.MediaCatalog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/** One tile on the launcher: either a phone app's own icon or a built-in glyph. */
private class Tile(
val label: String,
val icon: ImageBitmap? = null,
val glyph: ImageVector? = null,
val onClick: () -> Unit,
)
/** The app launcher: Web and Screen sharing, every car app the phone has, then Settings, as an icon grid. */
@Composable
fun CarLauncher() {
val context = LocalContext.current
val navigator = LocalCarNavigator.current
val frame by CarEnvironment.frame.collectAsState()
val installed by produceState(initialValue = emptyList<Tile>(), context, frame) {
value = withContext(Dispatchers.IO) {
fun iconOf(pkg: String) = runCatching {
context.packageManager.getApplicationIcon(pkg).toBitmap(128, 128).asImageBitmap()
}.getOrNull()
val nav = CarAppCatalog.installed(context).map { app ->
Tile(app.label, iconOf(app.component.packageName)) {
CarServices.connectNav(app, frame)
navigator.home()
}
}
val media = MediaCatalog.installed(context).map { app ->
Tile(app.label, iconOf(app.component.packageName)) {
CarServices.openMedia(app)
navigator.media()
}
}
(nav + media).sortedBy { it.label.lowercase() }
}
}
val tiles = listOf(
Tile("Web", glyph = Icons.Filled.Search) { navigator.open("web") },
Tile("Screen sharing", glyph = Icons.Filled.Share) { navigator.open("phone") },
) + installed + listOf(
Tile("Settings", glyph = Icons.Filled.Settings) { navigator.settings() },
)
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 108.dp),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(20.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(tiles) { AppTile(it) }
}
}
@Composable
private fun AppTile(tile: Tile) {
Surface(
onClick = tile.onClick,
modifier = Modifier.height(120.dp),
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
) {
Column(
Modifier.fillMaxSize().padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
when {
tile.icon != null -> Image(tile.icon, contentDescription = null, modifier = Modifier.size(52.dp))
tile.glyph != null -> Icon(tile.glyph, contentDescription = null, modifier = Modifier.size(52.dp))
}
Spacer(Modifier.height(8.dp))
Text(
tile.label,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@@ -0,0 +1,56 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
sealed interface CarScreen {
data object Home : CarScreen
data object Settings : CarScreen
data object Apps : CarScreen
/** The current media app, full size: browse, lyrics and controls. */
data object Media : CarScreen
/** [replyTo] is a notification key to open straight into its reply pane. */
data class Notifications(val replyTo: String? = null) : CarScreen
data class App(val id: String) : CarScreen
}
/**
* Where the car UI is. A car touchscreen has no hardware back button, so [back] is driven by
* the on-screen one; an open app can claim it first (a browser going back a page) through
* [backInterceptor].
*/
class CarNavigator(startId: String) {
var current: CarScreen by mutableStateOf(
if (CarApps.find(startId) != null) CarScreen.App(startId) else CarScreen.Home,
)
private set
var backInterceptor: (() -> Boolean)? = null
fun home() { current = CarScreen.Home }
fun media() { current = CarScreen.Media }
fun apps() { current = CarScreen.Apps }
fun settings() { current = CarScreen.Settings }
fun notifications(replyTo: String? = null) { current = CarScreen.Notifications(replyTo) }
fun open(id: String) { current = CarScreen.App(id) }
fun back() {
if (backInterceptor?.invoke() == true) return
current = CarScreen.Home
}
}
val LocalCarNavigator = staticCompositionLocalOf<CarNavigator> { error("no CarNavigator provided") }
/** "Is it dark outside", for apps that adapt to it. See [CarEnvironment]. */
val LocalDarkOutside = staticCompositionLocalOf { false }
@@ -0,0 +1,113 @@
package app.seb3thehacker.gearslip.car
import android.content.ComponentName
import android.content.Context
import app.seb3thehacker.gearslip.audio.CarAudio
import app.seb3thehacker.gearslip.host.CarAppCatalog
import app.seb3thehacker.gearslip.host.CarAppConnection
import app.seb3thehacker.gearslip.host.TemplateApp
import app.seb3thehacker.gearslip.media.CarMedia
import app.seb3thehacker.gearslip.media.MediaApp
import app.seb3thehacker.gearslip.media.MediaCatalog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
/**
* The two long-lived things the car keeps running: the map app and the media app.
*
* They belong to the car session rather than to whichever screen is showing, so the home
* screen, the Car apps list and the media screen all look at the same connection. Leaving a
* screen must not drop the route being followed or the song being played.
*/
object CarServices {
private var context: Context? = null
val nav: CarAppConnection by lazy { CarAppConnection(requireNotNull(context)) }
val media: CarMedia by lazy { CarMedia(requireNotNull(context)) }
private val _mediaApp = MutableStateFlow<MediaApp?>(null)
val mediaApp: StateFlow<MediaApp?> = _mediaApp.asStateFlow()
/** Set when the driver stops the map by hand, so going Home doesn't immediately restart it. */
private var navStopped = false
private var autoplayPending = false
fun init(appContext: Context) {
context = appContext.applicationContext
app.seb3thehacker.gearslip.notify.CarMotion.start(appContext)
}
// --- map -----------------------------------------------------------------------------
fun connectNav(app: TemplateApp, frame: CarEnvironment.Frame) {
navStopped = false
CarSettings.setLastNav(app.component.flattenToString())
nav.connect(app, frame.width, frame.height, frame.densityDpi)
}
fun stopNav() {
navStopped = true
nav.disconnect()
}
// --- media ---------------------------------------------------------------------------
fun openMedia(app: MediaApp, autoplay: Boolean = false) {
CarSettings.setLastMedia(app.component.flattenToString())
val phase = media.phase.value
val live = phase == CarMedia.Phase.READY || phase == CarMedia.Phase.CONNECTING
if (_mediaApp.value?.component == app.component && live) return
CarAudio.release()
_mediaApp.value = app
autoplayPending = autoplay
media.connect(app)
if (CarSettings.pipeAudio.value) CarAudio.request(app.uid)
}
/** Called once the media app is connected: starts playback if that was asked for. */
fun autoplayIfDue() {
if (!autoplayPending) return
autoplayPending = false
if (!media.now.value.playing) media.togglePlay()
}
// --- start and stop ------------------------------------------------------------------
/** Brings back whatever was in use last time. Safe to call repeatedly. */
suspend fun autostart(frame: CarEnvironment.Frame) {
val ctx = context ?: return
if (!navStopped && nav.status.value.phase == CarAppConnection.Phase.IDLE) {
val last = CarSettings.lastNav.value
val app = last?.let { id ->
withContext(Dispatchers.IO) { CarAppCatalog.installed(ctx) }
.firstOrNull { it.component == ComponentName.unflattenFromString(id) }
}
if (app != null) connectNav(app, frame)
}
if (_mediaApp.value == null) {
val last = CarSettings.lastMedia.value
val app = last?.let { id ->
withContext(Dispatchers.IO) { MediaCatalog.installed(ctx) }
.firstOrNull { it.component == ComponentName.unflattenFromString(id) }
}
if (app != null) openMedia(app, autoplay = CarSettings.autoplay.value)
}
}
/** The car went away: nothing should keep running for a screen that no longer exists. */
fun shutdown() {
context?.let { app.seb3thehacker.gearslip.notify.CarMotion.stop(it) }
runCatching { nav.disconnect() }
runCatching { media.disconnect() }
CarAudio.release()
_mediaApp.value = null
navStopped = false
}
}
@@ -0,0 +1,117 @@
package app.seb3thehacker.gearslip.car
import android.content.Context
import app.seb3thehacker.gearslip.AppSettings
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
enum class NightMode { AUTO, DAY, NIGHT }
/** The car UI's own colours. Separate from [NightMode], which is about the light outside (the map). */
enum class AppTheme { PHONE, LIGHT, DARK }
/**
* Settings changed from the car screen itself. Held as flows so the car UI reacts the moment a
* button is tapped, and persisted through [AppSettings] so they survive the next connection.
*/
object CarSettings {
private const val KEY_SCALE = "car_scale"
private const val KEY_OPEN = "car_open_on_connect"
private const val KEY_NIGHT = "car_night_mode"
private const val KEY_PIPE_AUDIO = "car_pipe_audio"
private const val KEY_LAST_NAV = "car_last_nav"
private const val KEY_LAST_MEDIA = "car_last_media"
private const val KEY_AUTOPLAY = "car_autoplay"
private const val KEY_THEME = "car_app_theme"
/** Id of the app to open on connect; [HOME] opens the launcher. */
const val HOME = "home"
private lateinit var app: Context
private val scaleFlow = MutableStateFlow(1f)
private val openFlow = MutableStateFlow(HOME)
private val nightFlow = MutableStateFlow(NightMode.AUTO)
private val pipeAudioFlow = MutableStateFlow(false)
private val lastNavFlow = MutableStateFlow<String?>(null)
private val lastMediaFlow = MutableStateFlow<String?>(null)
private val autoplayFlow = MutableStateFlow(false)
private val themeFlow = MutableStateFlow(AppTheme.PHONE)
val appTheme: StateFlow<AppTheme> = themeFlow
/** Flattened component names of the map and media apps used last, brought back on connect. */
val lastNav: StateFlow<String?> = lastNavFlow
val lastMedia: StateFlow<String?> = lastMediaFlow
/** Whether the media app starts playing by itself when the car connects. */
val autoplay: StateFlow<Boolean> = autoplayFlow
val scale: StateFlow<Float> = scaleFlow
val openOnConnect: StateFlow<String> = openFlow
val nightMode: StateFlow<NightMode> = nightFlow
/**
* Whether a media app's sound is captured and sent over the car link. Off by default: the
* platform only allows that behind a screen-capture consent dialog, and without it the app
* simply plays through the phone's normal output (e.g. Bluetooth to the car).
*/
val pipeAudio: StateFlow<Boolean> = pipeAudioFlow
fun init(context: Context) {
app = context.applicationContext
scaleFlow.value = AppSettings.getString(app, KEY_SCALE, "1.0").toFloatOrNull() ?: 1f
openFlow.value = AppSettings.getString(app, KEY_OPEN, HOME)
lastNavFlow.value = AppSettings.getString(app, KEY_LAST_NAV, "").ifEmpty { null }
lastMediaFlow.value = AppSettings.getString(app, KEY_LAST_MEDIA, "").ifEmpty { null }
themeFlow.value = runCatching {
AppTheme.valueOf(AppSettings.getString(app, KEY_THEME, AppTheme.PHONE.name))
}.getOrDefault(AppTheme.PHONE)
autoplayFlow.value = AppSettings.getString(app, KEY_AUTOPLAY, "false") == "true"
pipeAudioFlow.value = AppSettings.getString(app, KEY_PIPE_AUDIO, "false") == "true"
nightFlow.value = runCatching {
NightMode.valueOf(AppSettings.getString(app, KEY_NIGHT, NightMode.AUTO.name))
}.getOrDefault(NightMode.AUTO)
}
fun setScale(value: Float) {
scaleFlow.value = value
AppSettings.putString(app, KEY_SCALE, value.toString())
}
fun setOpenOnConnect(id: String) {
openFlow.value = id
AppSettings.putString(app, KEY_OPEN, id)
}
fun setLastNav(component: String) {
lastNavFlow.value = component
AppSettings.putString(app, KEY_LAST_NAV, component)
}
fun setLastMedia(component: String) {
lastMediaFlow.value = component
AppSettings.putString(app, KEY_LAST_MEDIA, component)
}
fun setAppTheme(theme: AppTheme) {
themeFlow.value = theme
AppSettings.putString(app, KEY_THEME, theme.name)
}
fun setAutoplay(on: Boolean) {
autoplayFlow.value = on
AppSettings.putString(app, KEY_AUTOPLAY, on.toString())
}
fun setPipeAudio(on: Boolean) {
pipeAudioFlow.value = on
AppSettings.putString(app, KEY_PIPE_AUDIO, on.toString())
}
fun setNightMode(mode: NightMode) {
nightFlow.value = mode
AppSettings.putString(app, KEY_NIGHT, mode.name)
CarEnvironment.refresh()
}
}
@@ -0,0 +1,219 @@
package app.seb3thehacker.gearslip.car
import android.os.Build
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.ui.CertSummary
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Settings that make sense while sitting in the car. Anything that needs a keyboard (the
* startup URL, certificates) lives on the phone, not here.
*/
@Composable
fun CarSettingsScreen() {
val scale by CarSettings.scale.collectAsState()
val open by CarSettings.openOnConnect.collectAsState()
val night by CarSettings.nightMode.collectAsState()
val pipeAudio by CarSettings.pipeAudio.collectAsState()
val autoplay by CarSettings.autoplay.collectAsState()
val theme by CarSettings.appTheme.collectAsState()
val display by CarEnvironment.display.collectAsState()
val vehicle by CarEnvironment.vehicle.collectAsState()
val context = LocalContext.current
val cert by produceState<CertSummary?>(null) {
value = withContext(Dispatchers.Default) { CertSummary.read(context) }
}
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
Text("Settings", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
ChoiceRow(
"Open on connect",
listOf(CarSettings.HOME to "Home") + CarApps.all.filter { it.id != "settings" }.map { it.id to it.label },
open,
) { CarSettings.setOpenOnConnect(it) }
ChoiceRow(
"Size",
listOf(1f to "Normal", 1.25f to "Large", 1.5f to "Extra large"),
scale,
) { CarSettings.setScale(it) }
ChoiceRow(
"App theme",
listOf(AppTheme.PHONE to "Follow phone", AppTheme.LIGHT to "Light", AppTheme.DARK to "Dark"),
theme,
) { CarSettings.setAppTheme(it) }
ChoiceRow(
"Map day/night",
listOf(NightMode.AUTO to "Auto", NightMode.DAY to "Day", NightMode.NIGHT to "Night"),
night,
) { CarSettings.setNightMode(it) }
ChoiceRow(
"Autoplay on connect",
listOf(false to "Off", true to "On"),
autoplay,
) { CarSettings.setAutoplay(it) }
ChoiceRow(
"Media audio",
listOf(false to "Phone output", true to "Through Gearslip"),
pipeAudio,
) { CarSettings.setPipeAudio(it) }
Text(
"Phone output needs no permission: the app plays as it normally would, e.g. over " +
"Bluetooth to the car. Through Gearslip sends the sound over the car link, and " +
"Android asks for screen-capture consent each time.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
ScreenFit(vehicle)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Connection", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
Info("Phone", "${Build.MANUFACTURER} ${Build.MODEL}")
Info("Vehicle", vehicle.ifEmpty { "-" })
Info("Video", display.ifEmpty { "-" })
Info("Certificate", cert?.headline ?: "Checking…")
}
val navigator = LocalCarNavigator.current
Button(onClick = { navigator.open("calibrate") }) { Text("Display test") }
Spacer(Modifier.height(8.dp))
}
}
@Composable
private fun <T> ChoiceRow(title: String, options: List<Pair<T, String>>, selected: T, onSelect: (T) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
options.forEach { (value, label) ->
val on = value == selected
Surface(
onClick = { onSelect(value) },
modifier = Modifier
.weight(1f)
.height(64.dp),
shape = MaterialTheme.shapes.large,
color = if (on) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh,
contentColor = if (on) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
) {
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) {
Text(label, style = MaterialTheme.typography.titleMedium, fontWeight = if (on) FontWeight.Bold else FontWeight.Normal)
}
}
}
}
}
}
@Composable
private fun Info(label: String, value: String) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text(label, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 4.dp))
Text(value, style = MaterialTheme.typography.bodyLarge)
}
}
/**
* Nudges how much of the frame the UI keeps clear at the top and bottom, live, so a car's
* display can be fitted from the driver's seat. Save writes it to this vehicle's profile.
*/
@Composable
private fun ScreenFit(vehicle: String) {
val insets by CarEnvironment.insets.collectAsState()
val context = LocalContext.current.applicationContext
var saved by remember { mutableStateOf<String?>(null) }
val known = vehicle.isNotEmpty() && vehicle != "Unknown vehicle"
fun change(new: app.seb3thehacker.gearslip.Insets) {
saved = null
CarEnvironment.setInsets(new)
}
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Screen fit", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
Stepper("Top", insets.top) { change(insets.copy(top = (insets.top + it).coerceAtLeast(0))) }
Stepper("Bottom", insets.bottom) { change(insets.copy(bottom = (insets.bottom + it).coerceAtLeast(0))) }
Text(
"Usable height ${480 - insets.top - insets.bottom}px of 480",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(
onClick = {
val ok = app.seb3thehacker.gearslip.VehicleProfiles.saveInsets(context, vehicle, insets)
saved = if (ok) "Saved to $vehicle" else "Could not save"
},
enabled = known,
modifier = Modifier.fillMaxWidth().height(56.dp),
) { Text(if (known) "Save for this vehicle" else "No vehicle profile matched") }
saved?.let { Text(it, style = MaterialTheme.typography.bodyMedium) }
}
}
@Composable
private fun Stepper(label: String, value: Int, onStep: (Int) -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(label, style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1.1f))
StepButton("−5", Modifier.weight(1f)) { onStep(-5) }
StepButton("−1", Modifier.weight(1f)) { onStep(-1) }
Text(
"${value}px",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.weight(1.3f),
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
StepButton("+1", Modifier.weight(1f)) { onStep(1) }
StepButton("+5", Modifier.weight(1f)) { onStep(5) }
}
}
@Composable
private fun StepButton(text: String, modifier: Modifier, onClick: () -> Unit) {
Surface(
onClick = onClick,
modifier = modifier.height(64.dp),
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
Text(text, style = MaterialTheme.typography.titleLarge)
}
}
}
@@ -0,0 +1,195 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.background
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.notify.CarNotifications
import kotlinx.coroutines.delay
import java.text.DateFormat
import java.util.Date
/**
* The whole car screen: the current screen on top, the nav bar along the bottom.
*
* Colours follow the phone's theme (Material You). The size setting scales everything by
* changing the density, so text, icons and touch targets grow together.
*/
@Composable
fun CarUi() {
val scale by CarSettings.scale.collectAsState()
val phoneDark by CarEnvironment.phoneDark.collectAsState()
val appTheme by CarSettings.appTheme.collectAsState()
val mediaApp by CarServices.mediaApp.collectAsState()
val darkOutside by CarEnvironment.darkOutside.collectAsState()
val appContext = LocalContext.current.applicationContext
val dark = when (appTheme) {
AppTheme.PHONE -> phoneDark
AppTheme.LIGHT -> false
AppTheme.DARK -> true
}
val colors = if (dark) dynamicDarkColorScheme(appContext) else dynamicLightColorScheme(appContext)
val base = LocalDensity.current
val insets by CarEnvironment.insets.collectAsState()
val navigator = remember { CarNavigator(CarSettings.openOnConnect.value) }
remember(appContext) { CarServices.init(appContext) }
DisposableEffect(Unit) { onDispose { CarServices.shutdown() } }
// The map follows the light outside, whatever the app's own theme is set to.
LaunchedEffect(darkOutside) { CarServices.nav.pushConfiguration() }
CompositionLocalProvider(
LocalDensity provides Density(base.density * scale, base.fontScale),
LocalCarNavigator provides navigator,
LocalDarkOutside provides darkOutside,
) {
MaterialTheme(colorScheme = colors) {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
// Insets are in video-frame pixels, so convert with the un-scaled density.
val pad = with(base) {
PaddingValues(
start = insets.left.toDp(), top = insets.top.toDp(),
end = insets.right.toDp(), bottom = insets.bottom.toDp(),
)
}
// The calibration screen is the raw frame: no insets, no nav bar.
val screen = navigator.current
if (screen == CarScreen.App("calibrate")) {
CarApps.find("calibrate")?.content?.invoke()
} else {
Column(Modifier.fillMaxSize().padding(pad)) {
Box(Modifier.weight(1f).fillMaxWidth()) {
when (screen) {
CarScreen.Home -> CarHome()
CarScreen.Apps -> CarLauncher()
CarScreen.Media -> mediaApp?.let { MediaScreen(it) { navigator.home() } }
CarScreen.Settings -> CarSettingsScreen()
is CarScreen.Notifications -> NotificationsScreen(screen.replyTo)
is CarScreen.App -> CarApps.find(screen.id)?.content?.invoke()
}
// The history screen is already showing them, so it needs no popup.
if (screen !is CarScreen.Notifications) {
NotificationPopup(Modifier.align(Alignment.TopCenter))
}
}
CarNavBar(navigator)
}
}
}
}
}
}
/** Back, Home and Apps on the left; clock, phone battery and the darkness signal on the right. */
@Composable
private fun CarNavBar(navigator: CarNavigator) {
val battery by CarEnvironment.battery.collectAsState()
val darkOutside by CarEnvironment.darkOutside.collectAsState()
val now by rememberNow()
Surface(color = MaterialTheme.colorScheme.surfaceContainer) {
Row(
Modifier
.fillMaxWidth()
.height(80.dp)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
NavItem(Icons.AutoMirrored.Filled.ArrowBack, "Back", selected = false) { navigator.back() }
Spacer(Modifier.width(8.dp))
NavItem(Icons.Filled.Home, "Home", navigator.current == CarScreen.Home) { navigator.home() }
Spacer(Modifier.width(8.dp))
NavItem(Icons.Filled.Menu, "Apps", navigator.current == CarScreen.Apps) { navigator.apps() }
Spacer(Modifier.weight(1f))
Column(horizontalAlignment = Alignment.End) {
Text(
DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(now)),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold,
)
val phone = if (battery.percent >= 0) "${battery.percent}%${if (battery.charging) " charging" else ""}" else ""
val light = if (darkOutside) "Night" else "Day"
Text(
listOf(phone, light).filter { it.isNotEmpty() }.joinToString(" · "),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.width(16.dp))
NavItem(
Icons.Filled.Notifications, null, navigator.current is CarScreen.Notifications,
) { navigator.notifications() }
}
}
}
@Composable
private fun NavItem(icon: ImageVector, label: String?, selected: Boolean, onClick: () -> Unit) {
val scheme = MaterialTheme.colorScheme
Column(
Modifier
.clip(RoundedCornerShape(24.dp))
.background(if (selected) scheme.secondaryContainer else scheme.surfaceContainer)
.clickable(onClick = onClick)
.padding(horizontal = 24.dp, vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Box {
Icon(icon, contentDescription = null, modifier = Modifier.size(32.dp))
}
if (label != null) Text(label, style = MaterialTheme.typography.labelLarge)
}
}
/** Wall-clock time, ticking on the minute: no reason to redraw (and re-encode) more often. */
@Composable
internal fun rememberNow(): State<Long> = produceState(System.currentTimeMillis()) {
while (true) {
value = System.currentTimeMillis()
delay(60_000 - value % 60_000)
}
}
@@ -0,0 +1,336 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Button
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.addPathNodes
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.seb3thehacker.gearslip.audio.CarAudio
import app.seb3thehacker.gearslip.media.CarMedia
import app.seb3thehacker.gearslip.media.MediaApp
import app.seb3thehacker.gearslip.media.MediaArt
import app.seb3thehacker.gearslip.media.LyricsResult
import app.seb3thehacker.gearslip.media.Lyrics
import app.seb3thehacker.gearslip.media.MediaEntry
import app.seb3thehacker.gearslip.media.NowPlaying
import android.media.session.PlaybackState
/**
* A media app on the car screen: what it is playing and its controls on the left, the tree it
* offers to browse on the right. The app owns the content; this only draws it.
*/
@Composable
fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
val context = LocalContext.current
val media = CarServices.media
val phase by media.phase.collectAsStateWithLifecycle()
val now by media.now.collectAsStateWithLifecycle()
val browse by media.browse.collectAsStateWithLifecycle()
val capture by CarAudio.capture.collectAsStateWithLifecycle()
var tab by remember { mutableStateOf(Tab.BROWSE) }
// The connection outlives this screen: the home screen's player is the same one.
LaunchedEffect(app) { CarServices.openMedia(app) }
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onExit) { Icon(Icons.Filled.ArrowBack, contentDescription = "Back to apps") }
Text(app.label, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.weight(1f))
AudioBadge(capture)
}
when (phase) {
CarMedia.Phase.REJECTED -> Notice(
"${app.label} would not let Gearslip browse it.",
"Some media apps only accept Google's own host.",
)
CarMedia.Phase.CONNECTING, CarMedia.Phase.IDLE -> Notice("Connecting to ${app.label}…", null)
CarMedia.Phase.READY -> Row(
Modifier.fillMaxSize().padding(top = 6.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
NowPlayingPanel(now, media, Modifier.width(250.dp).fillMaxHeight())
Column(Modifier.weight(1f).fillMaxHeight()) {
Row {
TextButton(onClick = { tab = Tab.BROWSE }) {
Text("Browse", fontWeight = if (tab == Tab.BROWSE) FontWeight.Bold else FontWeight.Normal)
}
TextButton(onClick = { tab = Tab.LYRICS }) {
Text("Lyrics", fontWeight = if (tab == Tab.LYRICS) FontWeight.Bold else FontWeight.Normal)
}
}
when (tab) {
Tab.BROWSE -> BrowsePanel(browse, media, Modifier.fillMaxSize())
Tab.LYRICS -> LyricsPanel(now, Modifier.fillMaxSize())
}
}
}
}
}
}
private enum class Tab { BROWSE, LYRICS }
/**
* The song's lyrics, fetched when this tab opens. Synced lyrics follow the playback position with
* the current line in the accent colour; unsynced ones are plain scrolling text.
*/
@Composable
private fun LyricsPanel(now: NowPlaying, modifier: Modifier) {
val lyrics by produceState<LyricsState>(LyricsState.Loading, now.title, now.artist, now.album, now.durationMs) {
value = LyricsState.Loading
value = Lyrics.find(now.title, now.artist, now.album, now.durationMs)
?.let { LyricsState.Found(it) } ?: LyricsState.Missing
}
val list = rememberLazyListState()
// Ticks with playback so the highlighted line moves; only synced lyrics need it.
var position by remember { mutableLongStateOf(0L) }
LaunchedEffect(now) {
while (true) {
position = now.currentPosition()
kotlinx.coroutines.delay(300)
}
}
when (val state = lyrics) {
LyricsState.Loading -> Notice("Looking for lyrics…", null)
LyricsState.Missing -> Notice(
if (now.title.isEmpty()) "Nothing playing." else "No lyrics found for this track.",
null,
)
is LyricsState.Found -> {
val lines = state.result.lines
val current = if (state.result.synced) lines.indexOfLast { it.timeMs <= position } else -1
LaunchedEffect(current) {
if (current >= 0) list.animateScrollToItem((current - 1).coerceAtLeast(0))
}
LazyColumn(modifier, state = list, verticalArrangement = Arrangement.spacedBy(6.dp)) {
itemsIndexed(lines) { index, line ->
Text(
line.text.ifEmpty { " " },
style = MaterialTheme.typography.titleLarge,
fontWeight = if (index == current) FontWeight.Bold else FontWeight.Normal,
color = when {
!state.result.synced -> MaterialTheme.colorScheme.onSurface
index == current -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
}
}
}
private sealed interface LyricsState {
data object Loading : LyricsState
data object Missing : LyricsState
class Found(val result: LyricsResult) : LyricsState
}
@Composable
private fun AudioBadge(capture: CarAudio.Capture) {
val text = when (capture) {
CarAudio.Capture.ON -> "Audio to car"
CarAudio.Capture.ASKING -> "Allow audio capture on the phone"
CarAudio.Capture.DENIED -> "Audio stays on the phone"
CarAudio.Capture.OFF -> return
}
Text(text, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
@Composable
private fun Notice(title: String, detail: String?) {
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
Text(title, style = MaterialTheme.typography.titleMedium)
detail?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) }
}
}
// --- now playing -----------------------------------------------------------------------------
@Composable
private fun NowPlayingPanel(now: NowPlaying, media: CarMedia, modifier: Modifier) {
val context = LocalContext.current
val art by produceState(now.art, now.art, now.artUri) {
value = now.art ?: MediaArt.load(context, now.artUri)
}
var position by remember { mutableLongStateOf(0L) }
LaunchedEffect(now) {
while (true) {
position = now.currentPosition()
kotlinx.coroutines.delay(500)
}
}
Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) {
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.size(170.dp),
) {
art?.let {
Image(it.asImageBitmap(), null, Modifier.fillMaxSize().clip(MaterialTheme.shapes.large), contentScale = ContentScale.Crop)
}
}
Spacer(Modifier.height(8.dp))
Text(
now.title.ifEmpty { "Nothing playing" },
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
Text(
listOf(now.artist, now.album).filter { it.isNotEmpty() }.joinToString(" - "),
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
now.error?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, maxLines = 2) }
if (now.durationMs > 0) {
WavyProgress(
fraction = position.toFloat() / now.durationMs,
playing = now.state == PlaybackState.STATE_PLAYING,
onSeek = { media.seek((it * now.durationMs).toLong()) },
modifier = Modifier.padding(vertical = 4.dp),
)
} else {
Spacer(Modifier.height(14.dp))
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
IconButton(onClick = media::previous, enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), modifier = Modifier.size(56.dp)) {
Icon(MediaIcons.Previous, "Previous", Modifier.size(32.dp))
}
FilledIconButton(onClick = media::togglePlay, modifier = Modifier.size(68.dp)) {
Icon(if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow, if (now.playing) "Pause" else "Play", Modifier.size(40.dp))
}
IconButton(onClick = media::next, enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_NEXT), modifier = Modifier.size(56.dp)) {
Icon(MediaIcons.Next, "Next", Modifier.size(32.dp))
}
}
// Whatever extra buttons the app publishes (shuffle, repeat, like), by name: their
// icons are resources inside the app's own package.
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
now.custom.take(3).forEach { action ->
TextButton(onClick = { media.custom(action) }) { Text(action.name, maxLines = 1, overflow = TextOverflow.Ellipsis) }
}
}
}
}
// --- browsing --------------------------------------------------------------------------------
@Composable
private fun BrowsePanel(browse: app.seb3thehacker.gearslip.media.BrowseState, media: CarMedia, modifier: Modifier) {
Column(modifier) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (media.canGoUp) {
IconButton(onClick = media::up) { Icon(Icons.Filled.ArrowBack, "Up") }
}
Text(
browse.trail.lastOrNull() ?: "Browse",
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
when {
browse.loading -> Notice("Loading…", null)
browse.failed -> Notice("Could not load this list.", null)
browse.entries.isEmpty() -> Notice("Nothing here.", null)
else -> LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(browse.entries, key = { it.id }) { entry -> EntryRow(entry, media) }
}
}
}
}
@Composable
private fun EntryRow(entry: MediaEntry, media: CarMedia) {
val context = LocalContext.current
val icon by produceState(entry.iconBitmap, entry.iconUri) {
value = entry.iconBitmap ?: MediaArt.load(context, entry.iconUri)
}
Surface(
onClick = { media.select(entry) },
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth(),
) {
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(52.dp).clip(MaterialTheme.shapes.medium).background(MaterialTheme.colorScheme.surface)) {
icon?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
}
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
Text(entry.title, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (entry.subtitle.isNotEmpty()) {
Text(entry.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
if (entry.browsable && entry.playable) {
IconButton(onClick = { media.playAll(entry) }) { Icon(Icons.Filled.PlayArrow, "Play") }
}
if (entry.browsable) Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null)
}
}
}
/** Material's transport icons, from their published path data (they live in the extended set). */
internal object MediaIcons {
val Pause = icon("Pause", "M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z")
val Next = icon("SkipNext", "M6,18l8.5,-6L6,6v12zM16,6v12h2V6h-2z")
val Previous = icon("SkipPrevious", "M6,6h2v12L6,18zM9.5,12l8.5,6V6z")
private fun icon(name: String, path: String) = ImageVector.Builder(
name, 24.dp, 24.dp, 24f, 24f,
).addPath(addPathNodes(path), fill = SolidColor(androidx.compose.ui.graphics.Color.Black)).build()
}
@@ -0,0 +1,277 @@
package app.seb3thehacker.gearslip.car
import android.content.Intent
import android.provider.Settings
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Button
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.notify.CarNotification
import app.seb3thehacker.gearslip.notify.CarNotifications
import kotlinx.coroutines.delay
import java.text.DateFormat
import java.util.Date
/** The notification history, with a reply pane for anything the sending app lets us answer. */
@Composable
fun NotificationsScreen(replyTo: String?) {
val history by CarNotifications.history.collectAsState()
var replying by remember(replyTo) { mutableStateOf(replyTo) }
// Anything arriving while this is open is being looked at, so it never counts as unread.
LaunchedEffect(history) { CarNotifications.markRead() }
val target = history.firstOrNull { it.key == replying && it.reply != null }
if (target != null) {
ReplyPane(target, onDone = { replying = null })
} else {
NotificationList(history, onReply = { replying = it.key })
}
}
@Composable
private fun NotificationList(history: List<CarNotification>, onReply: (CarNotification) -> Unit) {
val listening by CarNotifications.listening.collectAsState()
val context = LocalContext.current.applicationContext
Column(Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"Notifications",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
)
if (history.isNotEmpty()) TextButton(onClick = CarNotifications::clear) { Text("Clear all") }
}
if (!listening) {
AccessBanner {
context.startActivity(
Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
}
}
if (history.isEmpty()) {
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
Text(
"Nothing yet. New notifications from the phone will show up here.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
LazyColumn(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(history, key = { it.key }) { NotificationRow(it, onReply) }
}
}
}
}
@Composable
private fun AccessBanner(onOpen: () -> Unit) {
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
) {
Row(
Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(Icons.Filled.Warning, contentDescription = null)
Text(
"Notifications are off. Allow Gearslip under Settings > Notifications > Notification access on the phone.",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
Button(onClick = onOpen) { Text("Open") }
}
}
}
@Composable
private fun NotificationRow(n: CarNotification, onReply: (CarNotification) -> Unit) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceContainer,
modifier = Modifier.fillMaxWidth(),
) {
Row(
Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
NotificationIcon(n)
Column(Modifier.weight(1f)) {
Text(
"${n.appLabel} · ${DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(n.postedAt))}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (n.title.isNotBlank()) {
Text(n.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
if (n.text.isNotBlank()) {
Text(n.text, style = MaterialTheme.typography.bodyMedium, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
when {
n.replied -> Icon(Icons.Filled.Check, contentDescription = "Replied", tint = MaterialTheme.colorScheme.primary)
n.reply != null -> FilledTonalButton(onClick = { onReply(n) }) { Text("Reply") }
}
}
}
}
@Composable
private fun NotificationIcon(n: CarNotification, size: androidx.compose.ui.unit.Dp = 44.dp) {
val icon = n.icon
if (icon != null) {
Image(icon.asImageBitmap(), contentDescription = null, modifier = Modifier.size(size).clip(RoundedCornerShape(10.dp)))
} else {
Icon(Icons.Filled.Notifications, contentDescription = null, modifier = Modifier.size(size))
}
}
/** Same shape as search: a field on top, the host's own keyboard below. */
@Composable
private fun ReplyPane(n: CarNotification, onDone: () -> Unit) {
val context = LocalContext.current.applicationContext
var text by remember(n.key) { mutableStateOf("") }
fun send() {
if (text.isBlank()) return
CarNotifications.reply(context, n, text.trim())
onDone()
}
Column(Modifier.fillMaxSize()) {
Row(
Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
IconButton(onClick = onDone) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") }
Surface(
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surfaceContainerHighest,
modifier = Modifier.weight(1f),
) {
Text(
if (text.isEmpty()) "Reply to ${n.title.ifBlank { n.appLabel }}" else "$text|",
color = if (text.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
)
}
}
// What is being answered, so the driver isn't replying blind.
Row(
Modifier.weight(1f).fillMaxWidth().padding(horizontal = 20.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
NotificationIcon(n)
Text(n.text.ifBlank { n.title }, style = MaterialTheme.typography.bodyLarge, maxLines = 3, overflow = TextOverflow.Ellipsis)
}
CarKeyboard(text, onTextChange = { text = it }, onSubmit = ::send, submitImage = Icons.AutoMirrored.Filled.Send)
}
}
/**
* The popup shown over whatever the car is displaying. It times itself out, and tapping it opens
* the history - or straight into a reply, when the app allows one.
*/
@Composable
fun NotificationPopup(modifier: Modifier = Modifier) {
val popup by CarNotifications.popup.collectAsState()
val navigator = LocalCarNavigator.current
val n = popup ?: return
LaunchedEffect(n.id) {
delay(POPUP_MS)
CarNotifications.dismissPopup(n.id)
}
Surface(
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
contentColor = MaterialTheme.colorScheme.onSurface,
shadowElevation = 8.dp,
modifier = modifier
.padding(top = 8.dp)
.fillMaxWidth(0.7f)
.clickable { navigator.notifications() },
) {
Row(
Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
NotificationIcon(n)
Column(Modifier.weight(1f)) {
Text(n.appLabel, style = MaterialTheme.typography.labelMedium, maxLines = 1)
if (n.title.isNotBlank()) {
Text(n.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
if (n.text.isNotBlank()) {
Text(n.text, style = MaterialTheme.typography.bodyMedium, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
if (n.reply != null) {
FilledTonalButton(onClick = { navigator.notifications(replyTo = n.key) }) { Text("Reply") }
}
IconButton(onClick = { CarNotifications.dismissPopup(n.id) }) {
Icon(Icons.Filled.Close, contentDescription = "Dismiss")
}
}
}
}
private const val POPUP_MS = 8_000L
@@ -0,0 +1,130 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.produceState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Warning
import app.seb3thehacker.gearslip.mirror.PhoneApp
import app.seb3thehacker.gearslip.mirror.PhoneApps
import app.seb3thehacker.gearslip.mirror.PhoneMirror
import app.seb3thehacker.gearslip.mirror.TouchRelayService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Starts a phone app on the car screen.
*
* Picking one hands the whole frame over to the mirror, so this screen is the last thing
* Gearslip draws until mirroring stops - either from the notification on the phone, or by
* tapping the black bars beside the mirrored picture.
*/
@Composable
fun PhoneAppsScreen() {
val context = LocalContext.current
val apps by produceState(initialValue = emptyList<PhoneApp>(), context) {
value = withContext(Dispatchers.IO) { PhoneApps.installed(context) }
}
Column(Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 12.dp)) {
if (!TouchRelayService.enabled) {
TouchRelayWarning()
Spacer(Modifier.height(12.dp))
}
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 108.dp),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 8.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(apps) { app ->
PhoneAppTile(app) { PhoneMirror.open(context, app.packageName) }
}
}
}
}
/**
* Mirroring shows the phone whether or not touch works, so this warns rather than blocks -
* the picture is still useful, it just cannot be driven from the car yet.
*/
@Composable
private fun TouchRelayWarning() {
val context = LocalContext.current
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.fillMaxWidth(),
) {
Row(
Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(Icons.Filled.Warning, contentDescription = null)
Text(
"Touch is off. Enable Gearslip under Settings > Accessibility on the phone.",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
Button(onClick = { PhoneApps.openAccessibilitySettings(context) }) { Text("Open") }
}
}
}
@Composable
private fun PhoneAppTile(app: PhoneApp, onClick: () -> Unit) {
Surface(
onClick = onClick,
modifier = Modifier.height(120.dp),
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
) {
Column(
Modifier.fillMaxSize().padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
app.icon?.let { Image(it, contentDescription = null, modifier = Modifier.size(52.dp)) }
Spacer(Modifier.height(8.dp))
Text(
app.label,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@@ -0,0 +1,340 @@
package app.seb3thehacker.gearslip.car
import android.graphics.Rect
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.car.app.model.Template
import androidx.car.app.model.PlaceListMapTemplate
import androidx.car.app.navigation.model.MapTemplate
import androidx.car.app.navigation.model.MapWithContentTemplate
import androidx.car.app.navigation.model.NavigationTemplate
import androidx.car.app.navigation.model.PlaceListNavigationTemplate
import androidx.car.app.navigation.model.RoutePreviewNavigationTemplate
import androidx.car.app.navigation.model.RoutingInfo
import androidx.car.app.navigation.model.TravelEstimate
import app.seb3thehacker.gearslip.host.color
import app.seb3thehacker.gearslip.host.display
import app.seb3thehacker.gearslip.host.remainingTime
import app.seb3thehacker.gearslip.host.text
/**
* Draws the template an app handed us, over the surface it is drawing its map into.
*
* A templated app never draws its own chrome: it describes a screen in the Car App Library's
* model types and every host renders those to suit its own screen. This is Gearslip's
* rendering. It is deliberately laid out for a wide, short car display - controls to the
* right where a hand reaches from the driver's seat, information to the left.
*
* Templates split into two kinds. The six map-backed ones here leave room for the app's own
* drawing and lay chrome around it; everything else fills the screen and is drawn by
* [ContentTemplate].
*/
@Composable
fun TemplateChrome(template: Template?, modifier: Modifier = Modifier) {
when (template) {
null -> Unit
is NavigationTemplate -> NavigationChrome(template, modifier)
is MapWithContentTemplate -> MapWithContentChrome(template, modifier)
is MapTemplate -> MapChrome(template, modifier)
is PlaceListNavigationTemplate -> PlaceListNavigationChrome(template, modifier)
is RoutePreviewNavigationTemplate -> RoutePreviewChrome(template, modifier)
is PlaceListMapTemplate -> PlaceListMapChrome(template, modifier)
else -> ContentTemplate(template, modifier)
}
}
/**
* How much of the surface Gearslip's chrome covers, so the app can keep what matters clear of
* it. Expressed as the visible rectangle within a [width] x [height] surface.
*
* A template that isn't map-backed covers the surface completely, and reporting an empty
* rectangle is how the app is told to stop bothering.
*/
fun visibleAreaFor(template: Template?, width: Int, height: Int): Rect = when (template) {
is NavigationTemplate -> Rect(0, 0, width, (height * (1f - ESTIMATE_FRACTION)).toInt())
is MapWithContentTemplate, is MapTemplate, is PlaceListNavigationTemplate,
is RoutePreviewNavigationTemplate, is PlaceListMapTemplate,
-> Rect((width * CONTENT_PANE_FRACTION).toInt(), 0, width, height)
null -> Rect(0, 0, width, height)
else -> Rect(0, 0, 0, 0)
}
// --- navigation ------------------------------------------------------------------------------
@Composable
private fun NavigationChrome(template: NavigationTemplate, modifier: Modifier) {
Box(modifier.fillMaxWidth()) {
(template.navigationInfo as? RoutingInfo)?.let {
RoutingCard(it, Modifier.align(Alignment.TopStart).padding(12.dp).width(320.dp))
}
RightEdgeControls(
template.actionStrip,
template.mapActionStrip,
Modifier.align(Alignment.TopEnd).padding(12.dp),
)
template.destinationTravelEstimate?.let {
TravelEstimateBar(it, Modifier.align(Alignment.BottomStart))
}
}
}
/** The manoeuvre card: what to do next, how far away it is, and onto what road. */
@Composable
private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) {
if (info.isLoading) {
ChromeSurface(modifier) {
Text("Finding a route…", Modifier.padding(16.dp), style = MaterialTheme.typography.titleMedium)
}
return
}
val step = info.currentStep ?: return
ChromeSurface(modifier) {
Column(Modifier.padding(14.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
CarGlyph(step.maneuver?.icon, Modifier.size(44.dp))
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
val distance = info.currentDistance.display()
if (distance.isNotEmpty()) {
Text(
distance,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
)
}
Text(
step.cue.text(),
style = MaterialTheme.typography.titleMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
val road = step.road.text()
if (road.isNotEmpty()) {
Text(
road,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
info.nextStep?.let { next ->
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"then",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(8.dp))
CarGlyph(next.maneuver?.icon, Modifier.size(20.dp))
Spacer(Modifier.width(8.dp))
Text(
next.cue.text(),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
/** Time and distance left, along the bottom where it stays out of the map's way. */
@Composable
private fun TravelEstimateBar(estimate: TravelEstimate, modifier: Modifier = Modifier) {
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
val time = remainingTime(estimate.remainingTimeSeconds)
val distance = estimate.remainingDistance.display()
val trip = estimate.tripText.text()
if (time.isEmpty() && distance.isEmpty() && trip.isEmpty()) return
Surface(
color = Color.Black.copy(alpha = 0.6f),
contentColor = Color.White,
modifier = modifier.fillMaxWidth(),
) {
Row(
Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(20.dp),
) {
if (time.isNotEmpty()) {
Text(
time,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = estimate.remainingTimeColor.color(dark, Color.White),
)
}
if (distance.isNotEmpty()) {
Text(
distance,
style = MaterialTheme.typography.titleMedium,
color = estimate.remainingDistanceColor.color(dark, Color.White),
)
}
if (trip.isNotEmpty()) {
Text(trip, style = MaterialTheme.typography.bodyMedium, maxLines = 1)
}
}
}
}
// --- map with a side pane ----------------------------------------------------------------------
/**
* The shape every remaining map-backed template shares: a pane down the left, the app's map
* showing through on the right, and both action strips stacked at the right edge.
*/
@Composable
private fun MapPaneLayout(
modifier: Modifier,
actionStrip: androidx.car.app.model.ActionStrip?,
mapActionStrip: androidx.car.app.model.ActionStrip?,
pane: @Composable (Modifier) -> Unit,
) {
Box(modifier.fillMaxWidth()) {
pane(
Modifier
.align(Alignment.CenterStart)
.fillMaxHeight()
.fillMaxWidth(CONTENT_PANE_FRACTION),
)
RightEdgeControls(
actionStrip,
mapActionStrip,
Modifier.align(Alignment.TopEnd).padding(12.dp),
)
}
}
@Composable
private fun MapWithContentChrome(template: MapWithContentTemplate, modifier: Modifier) {
MapPaneLayout(
modifier,
template.actionStrip,
template.mapController?.mapActionStrip,
) { paneModifier ->
ContentTemplate(template.contentTemplate, paneModifier)
}
}
@Composable
private fun MapChrome(template: MapTemplate, modifier: Modifier) {
MapPaneLayout(
modifier,
template.actionStrip,
template.mapController?.mapActionStrip,
) { paneModifier ->
ChromeSurface(paneModifier, shape = androidx.compose.foundation.shape.RoundedCornerShape(0.dp)) {
Column {
HeaderBar(
headerTitle(template.header, ""),
template.header?.startHeaderAction,
template.header?.endHeaderActions.orEmpty(),
)
if (template.pane != null) PaneRows(template.pane)
else ItemListColumn(template.itemList)
}
}
}
}
@Composable
private fun PlaceListNavigationChrome(template: PlaceListNavigationTemplate, modifier: Modifier) {
MapPaneLayout(modifier, template.actionStrip, template.mapActionStrip) { paneModifier ->
ListPane(
headerTitle(template.header, template.title.text()),
template.header?.startHeaderAction ?: template.headerAction,
template.isLoading,
template.itemList,
paneModifier,
)
}
}
@Composable
private fun PlaceListMapChrome(template: PlaceListMapTemplate, modifier: Modifier) {
MapPaneLayout(modifier, template.actionStrip, null) { paneModifier ->
ListPane(
template.title.text(),
template.headerAction,
template.isLoading,
template.itemList,
paneModifier,
)
}
}
/** Route choices down the side, with the app's "start" action pinned under them. */
@Composable
private fun RoutePreviewChrome(template: RoutePreviewNavigationTemplate, modifier: Modifier) {
MapPaneLayout(modifier, template.actionStrip, template.mapActionStrip) { paneModifier ->
ChromeSurface(paneModifier, shape = androidx.compose.foundation.shape.RoundedCornerShape(0.dp)) {
Column(Modifier.fillMaxHeight()) {
HeaderBar(
headerTitle(template.header, template.title.text()),
template.header?.startHeaderAction ?: template.headerAction,
template.header?.endHeaderActions.orEmpty(),
)
Box(Modifier.weight(1f)) {
if (template.isLoading) {
Text("Finding routes…", Modifier.padding(14.dp))
} else {
ItemListColumn(template.itemList)
}
}
template.navigateAction?.let {
ActionRow(listOf(it), Modifier.padding(12.dp))
}
}
}
}
}
@Composable
private fun ListPane(
title: String,
startAction: androidx.car.app.model.Action?,
loading: Boolean,
itemList: androidx.car.app.model.ItemList?,
modifier: Modifier,
) {
ChromeSurface(modifier, shape = androidx.compose.foundation.shape.RoundedCornerShape(0.dp)) {
Column {
HeaderBar(title, startAction)
if (loading) Text("Loading…", Modifier.padding(14.dp))
else ItemListColumn(itemList)
}
}
}
/** Fraction of the surface the content pane covers beside a map. */
private const val CONTENT_PANE_FRACTION = 0.42f
/** Fraction of the surface the travel-estimate bar covers along the bottom. */
private const val ESTIMATE_FRACTION = 0.14f
@@ -0,0 +1,498 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.car.app.model.GridTemplate
import androidx.car.app.model.Item
import androidx.car.app.model.ItemList
import androidx.car.app.model.ListTemplate
import androidx.car.app.model.LongMessageTemplate
import androidx.car.app.model.MessageTemplate
import androidx.car.app.model.PaneTemplate
import androidx.car.app.model.Row as CarRow
import androidx.car.app.model.SearchTemplate
import androidx.car.app.model.Section
import androidx.car.app.model.SectionedItemTemplate
import androidx.car.app.model.TabTemplate
import androidx.car.app.model.Template
import androidx.car.app.model.signin.InputSignInMethod
import androidx.car.app.model.signin.PinSignInMethod
import androidx.car.app.model.signin.ProviderSignInMethod
import androidx.car.app.model.signin.QRCodeSignInMethod
import androidx.car.app.model.signin.SignInTemplate
import androidx.car.app.media.model.MediaPlaybackTemplate
import androidx.car.app.OnDoneCallback
import androidx.car.app.serialization.Bundleable
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.host.inputChanged
import app.seb3thehacker.gearslip.host.inputSubmitted
import app.seb3thehacker.gearslip.host.submitted
import app.seb3thehacker.gearslip.host.tabSelected
import app.seb3thehacker.gearslip.host.text
import app.seb3thehacker.gearslip.host.textChanged
/**
* Templates that fill a screen with content rather than sitting over a map.
*
* A map-backed template can hold one of these in its side pane, which is why they are drawn by
* the same function either way - the only difference is how much room they get.
*/
@Composable
fun ContentTemplate(template: Template?, modifier: Modifier = Modifier) {
when (template) {
null -> Unit
is ListTemplate -> ListContent(template, modifier)
is GridTemplate -> GridContent(template, modifier)
is PaneTemplate -> PaneContent(template, modifier)
is MessageTemplate -> MessageContent(template, modifier)
is LongMessageTemplate -> LongMessageContent(template, modifier)
is SearchTemplate -> SearchContent(template, modifier)
is SignInTemplate -> SignInContent(template, modifier)
is TabTemplate -> TabContent(template, modifier)
is SectionedItemTemplate -> SectionedContent(template, modifier)
is MediaPlaybackTemplate -> MediaPlaybackContent(template, modifier)
else -> UnsupportedChrome(template, modifier)
}
}
/** True when this template is one [ContentTemplate] knows how to draw. */
fun isContentTemplate(template: Template?): Boolean = when (template) {
is ListTemplate, is GridTemplate, is PaneTemplate, is MessageTemplate,
is LongMessageTemplate, is SearchTemplate, is SignInTemplate, is TabTemplate,
is SectionedItemTemplate, is MediaPlaybackTemplate,
-> true
else -> false
}
// --- lists, grids and panes ---------------------------------------------------------------------
@Composable
private fun ListContent(template: ListTemplate, modifier: Modifier) {
ContentSurface(modifier) {
HeaderBar(
headerTitle(template.header, template.title.text()),
template.header?.startHeaderAction ?: template.headerAction,
template.header?.endHeaderActions.orEmpty(),
template.actionStrip,
)
if (template.isLoading) Loading()
else ItemListColumn(template.singleList, template.sectionedLists?.map { it.itemList })
ActionRow(template.actions.orEmpty(), Modifier.padding(12.dp))
}
}
@Composable
private fun GridContent(template: GridTemplate, modifier: Modifier) {
ContentSurface(modifier) {
HeaderBar(
headerTitle(template.header, template.title.text()),
template.header?.startHeaderAction ?: template.headerAction,
template.header?.endHeaderActions.orEmpty(),
template.actionStrip,
)
if (template.isLoading) Loading() else GridItems(template.singleList)
ActionRow(template.actions.orEmpty(), Modifier.padding(12.dp))
}
}
@Composable
private fun PaneContent(template: PaneTemplate, modifier: Modifier) {
ContentSurface(modifier) {
HeaderBar(
headerTitle(template.header, template.title.text()),
template.header?.startHeaderAction ?: template.headerAction,
template.header?.endHeaderActions.orEmpty(),
template.actionStrip,
)
if (template.pane?.isLoading == true) Loading() else PaneRows(template.pane)
}
}
// --- messages -------------------------------------------------------------------------------
@Composable
private fun MessageContent(template: MessageTemplate, modifier: Modifier) {
ContentSurface(modifier) {
HeaderBar(
headerTitle(template.header, template.title.text()),
template.header?.startHeaderAction ?: template.headerAction,
template.header?.endHeaderActions.orEmpty(),
template.actionStrip,
)
if (template.isLoading) {
Loading()
return@ContentSurface
}
Row(
Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
template.icon?.let {
CarGlyph(it, Modifier.size(32.dp))
Spacer(Modifier.width(12.dp))
}
Text(template.message.text(), style = MaterialTheme.typography.bodyLarge)
}
ActionRow(template.actions.orEmpty(), Modifier.padding(16.dp))
}
}
/**
* The long form is the same idea with a scroll: it's what apps use for terms and conditions,
* which is also why it is only ever shown parked.
*/
@Composable
private fun LongMessageContent(template: LongMessageTemplate, modifier: Modifier) {
ContentSurface(modifier) {
HeaderBar(
template.title.text(),
template.headerAction,
actionStrip = template.actionStrip,
)
Text(
template.message.text(),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp),
)
ActionRow(template.actions.orEmpty(), Modifier.padding(16.dp))
}
}
// --- search ---------------------------------------------------------------------------------
/**
* Search: the host owns the text and the keyboard, and the app owns the results.
*
* Every keystroke goes to the app through [SearchTemplate.getSearchCallbackDelegate], which is
* what lets results update as you type. The text lives here rather than in the template
* because the app never sends it back - it only ever sends a new list of results.
*/
@Composable
private fun SearchContent(template: SearchTemplate, modifier: Modifier) {
// Seeded once. The app refreshes this template on every keystroke, so re-seeding from
// initialSearchText would fight whatever is being typed.
var query by remember { mutableStateOf(template.initialSearchText.orEmpty()) }
val delegate = template.searchCallbackDelegate
ContentSurface(modifier) {
// Back button and field share a row: on a 480px screen the keyboard and the results
// are already competing for every pixel, so the header can't have one to itself.
Row(
Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
template.headerAction?.let { ActionButton(it) }
SearchField(query, template.searchHint.orEmpty(), Modifier.weight(1f))
ActionRow(template.actionStrip?.actions.orEmpty())
}
Box(Modifier.weight(1f)) {
if (template.isLoading) Loading()
else ItemListColumn(template.itemList, modifier = Modifier.fillMaxSize())
}
CarKeyboard(
text = query,
onTextChange = {
query = it
delegate.textChanged(it)
},
onSubmit = { delegate.submitted(query) },
)
}
}
@Composable
private fun SearchField(query: String, hint: String, modifier: Modifier = Modifier) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = RoundedCornerShape(10.dp),
modifier = modifier.fillMaxWidth(),
) {
Text(
query.ifEmpty { hint.ifEmpty { "Search" } },
style = MaterialTheme.typography.titleMedium,
color = if (query.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant
else MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
)
}
}
// --- sign-in --------------------------------------------------------------------------------
/**
* Sign-in comes in four shapes and the host has to draw whichever the app picked. Only the
* typed one needs the keyboard; a PIN or QR code is something the driver reads off the screen
* and completes on their phone.
*/
@Composable
private fun SignInContent(template: SignInTemplate, modifier: Modifier) {
ContentSurface(modifier) {
HeaderBar(
template.title.text(),
template.headerAction,
actionStrip = template.actionStrip,
)
if (template.isLoading) {
Loading()
return@ContentSurface
}
val instructions = template.instructions.text()
if (instructions.isNotEmpty()) {
Text(
instructions,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
)
}
when (val method = template.signInMethod) {
is InputSignInMethod -> InputSignIn(method, Modifier.weight(1f))
is PinSignInMethod -> Callout(method.pinCode.toString(), Modifier.weight(1f))
is QRCodeSignInMethod -> Callout(method.uri.toString(), Modifier.weight(1f))
is ProviderSignInMethod -> Box(Modifier.weight(1f), Alignment.Center) {
ActionRow(listOf(method.action))
}
else -> Spacer(Modifier.weight(1f))
}
val additional = template.additionalText.text()
if (additional.isNotEmpty()) {
Text(
additional,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
ActionRow(template.actions.orEmpty(), Modifier.padding(12.dp))
}
}
@Composable
private fun InputSignIn(method: InputSignInMethod, modifier: Modifier) {
var value by remember { mutableStateOf(method.defaultValue.text()) }
val delegate = method.inputCallbackDelegate
val masked = method.inputType == InputSignInMethod.INPUT_TYPE_PASSWORD
Column(modifier) {
SearchField(if (masked) "•".repeat(value.length) else value, method.hint.text())
val error = method.errorMessage.text()
if (error.isNotEmpty()) {
Text(
error,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
Spacer(Modifier.weight(1f))
CarKeyboard(
text = value,
onTextChange = {
value = it
delegate.inputChanged(it)
},
onSubmit = { delegate.inputSubmitted(value) },
submitIcon = false,
)
}
}
/** A code the driver reads off the screen. Big, centred, nothing else competing with it. */
@Composable
private fun Callout(value: String, modifier: Modifier) {
Box(modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Text(
value,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
}
}
// --- tabs -----------------------------------------------------------------------------------
@Composable
private fun TabContent(template: TabTemplate, modifier: Modifier) {
val active = template.activeTabContentId
ContentSurface(modifier) {
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 6.dp)
.verticalScrollNone(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
template.headerAction?.let { ActionButton(it) }
template.tabs.forEach { tab ->
val selected = tab.contentId == active
Surface(
color = if (selected) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer
else MaterialTheme.colorScheme.onSurface,
shape = RoundedCornerShape(20.dp),
modifier = Modifier.clickable {
template.tabCallbackDelegate.tabSelected(tab.contentId)
},
) {
Row(
Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
tab.icon?.let {
CarGlyph(it, Modifier.size(20.dp))
Spacer(Modifier.width(6.dp))
}
Text(tab.title.text(), style = MaterialTheme.typography.labelLarge, maxLines = 1)
}
}
}
}
if (template.isLoading) Loading()
else ContentTemplate(template.tabContents?.template, Modifier.weight(1f))
}
}
/** Tabs share a row; this keeps the modifier chain readable where no scroll is wanted. */
private fun Modifier.verticalScrollNone(): Modifier = this
// --- sectioned items --------------------------------------------------------------------------
/**
* Sections fetch their items across the binder on demand rather than carrying them, so each one
* is asked for its full range once and the answer is held while the section is on screen.
*/
@Composable
private fun SectionedContent(template: SectionedItemTemplate, modifier: Modifier) {
ContentSurface(modifier) {
HeaderBar(
headerTitle(template.header, ""),
template.header?.startHeaderAction,
template.header?.endHeaderActions.orEmpty(),
)
if (template.isLoading) {
Loading()
return@ContentSurface
}
Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) {
template.sections.forEach { section ->
PaneTitle(section.title.text())
SectionRows(section)
}
}
ActionRow(template.actions.orEmpty(), Modifier.padding(12.dp))
}
}
@Composable
private fun SectionRows(section: Section<*>) {
var items by remember(section) { mutableStateOf<List<Item>>(emptyList()) }
LaunchedEffect(section) {
val delegate = section.itemsDelegate
val size = runCatching { delegate.size }.getOrDefault(0)
if (size <= 0) return@LaunchedEffect
runCatching {
delegate.requestItemRange(0, size - 1, object : OnDoneCallback {
override fun onSuccess(response: Bundleable?) {
val value = response?.let { runCatching { it.get() }.getOrNull() }
@Suppress("UNCHECKED_CAST")
items = (value as? List<Item>).orEmpty()
}
override fun onFailure(response: Bundleable) =
GearslipLog.w("host: a section refused to hand over its items")
})
}.onFailure { GearslipLog.w("host: could not fetch section items: ${it.message}") }
}
if (items.isEmpty()) {
val message = section.noItemsMessage.text()
if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp))
return
}
items.filterIsInstance<CarRow>().forEach { RowItem(it) }
}
// --- media ----------------------------------------------------------------------------------
/**
* Media playback is drawn from the app's MediaSession rather than from the template, which
* carries only a header. Gearslip has no media channel yet, so this says so plainly instead of
* showing an empty player.
*/
@Composable
private fun MediaPlaybackContent(template: MediaPlaybackTemplate, modifier: Modifier) {
ContentSurface(modifier) {
HeaderBar(
headerTitle(template.header, ""),
template.header?.startHeaderAction,
template.header?.endHeaderActions.orEmpty(),
)
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
Text(
"Playback controls come from the app's media session,\nwhich Gearslip doesn't carry yet.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
}
}
}
// --- shared ---------------------------------------------------------------------------------
@Composable
private fun ContentSurface(modifier: Modifier, content: @Composable ColumnScopeAlias.() -> Unit) {
ChromeSurface(modifier, shape = RoundedCornerShape(0.dp)) {
Column(Modifier.fillMaxSize(), content = content)
}
}
private typealias ColumnScopeAlias = androidx.compose.foundation.layout.ColumnScope
@Composable
private fun Loading() {
Box(Modifier.fillMaxWidth().height(80.dp), contentAlignment = Alignment.Center) {
Text("Loading…", style = MaterialTheme.typography.bodyLarge)
}
}
@@ -0,0 +1,383 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.runtime.remember
import androidx.compose.material3.LocalContentColor
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.car.app.model.Action
import androidx.car.app.model.ActionStrip
import androidx.car.app.model.CarIcon
import androidx.car.app.model.GridItem
import androidx.car.app.model.Header
import androidx.car.app.model.ItemList
import androidx.car.app.model.Pane
import androidx.car.app.model.Row as CarRow
import androidx.car.app.model.Template
import app.seb3thehacker.gearslip.host.click
import app.seb3thehacker.gearslip.host.color
import app.seb3thehacker.gearslip.host.image
import app.seb3thehacker.gearslip.host.isDrawable
import app.seb3thehacker.gearslip.host.standardType
import app.seb3thehacker.gearslip.host.text
/**
* The pieces every template is built from.
*
* The Car App Library's templates are combinations of a small set of parts - a header, an
* action strip, a list of rows, a grid of tiles - so the host only has to decide once how each
* part looks, and each template is then mostly an arrangement of them.
*/
// --- surfaces and text -------------------------------------------------------------------------
/**
* The app keeps drawing its map across the whole surface - the visible area we report tells it
* where to put its own markers, not where to stop painting. So anything the host lays over the
* map is opaque by default, or the map reads through it.
*/
@Composable
internal fun ChromeSurface(
modifier: Modifier = Modifier,
shape: Shape = RoundedCornerShape(16.dp),
alpha: Float = 1f,
content: @Composable () -> Unit,
) {
Surface(
color = MaterialTheme.colorScheme.surface.copy(alpha = alpha),
contentColor = MaterialTheme.colorScheme.onSurface,
shape = shape,
modifier = modifier,
content = content,
)
}
@Composable
internal fun UnsupportedChrome(template: Template, modifier: Modifier = Modifier) {
androidx.compose.foundation.layout.Box(modifier, contentAlignment = Alignment.Center) {
ChromeSurface {
Text(
"${template.javaClass.simpleName} isn't drawn yet",
Modifier.padding(16.dp),
style = MaterialTheme.typography.titleMedium,
)
}
}
}
/**
* A template's header: a back or app action on the left, a title, and the app's own actions on
* the right. Templates carry this either as a [Header] or as the older title-plus-action pair.
*/
@Composable
internal fun HeaderBar(
title: String,
startAction: Action? = null,
endActions: List<Action> = emptyList(),
actionStrip: ActionStrip? = null,
) {
val end = (endActions + actionStrip?.actions.orEmpty()).filter { it.isDrawable() }
if (title.isEmpty() && startAction == null && end.isEmpty()) return
Row(
Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
startAction?.takeIf { it.isDrawable() }?.let { ActionButton(it) }
Text(
title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
end.forEach { ActionButton(it) }
}
}
/** Reads a header whichever way the template carries it. */
internal fun headerTitle(header: Header?, fallback: String): String =
header?.title.text().ifEmpty { fallback }
// --- actions ------------------------------------------------------------------------------------
/**
* Both strips share the right edge, where a hand reaches from the driver's seat: the app's own
* actions in a row along the top, its map controls stacked under them. Keeping them in one
* column is what stops them colliding on a screen only 480px tall.
*/
@Composable
internal fun RightEdgeControls(
actionStrip: ActionStrip?,
mapActionStrip: ActionStrip?,
modifier: Modifier = Modifier,
) {
val actions = actionStrip?.actions.orEmpty().filter { it.isDrawable() }
val mapActions = mapActionStrip?.actions.orEmpty().filter { it.isDrawable() }
if (actions.isEmpty() && mapActions.isEmpty()) return
Column(modifier, horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (actions.isNotEmpty()) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
actions.forEach { ActionButton(it) }
}
}
mapActions.forEach { ActionButton(it) }
}
}
@Composable
internal fun ActionRow(actions: List<Action>, modifier: Modifier = Modifier) {
val drawable = actions.filter { it.isDrawable() }
if (drawable.isEmpty()) return
Row(modifier, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
drawable.forEach { ActionButton(it) }
}
}
@Composable
internal fun ActionButton(action: Action) {
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
val title = action.title.text()
val background = action.backgroundColor.color(dark, Color.Black.copy(alpha = 0.6f))
val hasGlyph = action.icon != null || action.type != Action.TYPE_CUSTOM
Surface(
color = background,
contentColor = Color.White,
shape = RoundedCornerShape(24.dp),
modifier = Modifier.clickable(enabled = action.isEnabled) {
action.onClickDelegate.click("action")
},
) {
Row(
Modifier.padding(horizontal = if (title.isEmpty()) 10.dp else 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (hasGlyph) CarGlyph(action.icon, Modifier.size(24.dp), standardFor(action))
if (title.isNotEmpty()) {
if (hasGlyph) Spacer(Modifier.width(8.dp))
Text(title, style = MaterialTheme.typography.labelLarge, maxLines = 1)
}
}
}
}
/** A standard Action carries no icon of its own - the host is expected to supply the glyph. */
private fun standardFor(action: Action): Int? = when (action.type) {
Action.TYPE_BACK -> CarIcon.TYPE_BACK
Action.TYPE_PAN -> CarIcon.TYPE_PAN
Action.TYPE_APP_ICON -> CarIcon.TYPE_APP_ICON
else -> null
}
// --- icons ----------------------------------------------------------------------------------
/**
* Draws an icon the app described, falling back to our own glyph for the standard types the
* host owns. A custom icon that fails to load leaves the space empty rather than a placeholder,
* since a wrong glyph on a map control is worse than none.
*/
@Composable
internal fun CarGlyph(icon: CarIcon?, modifier: Modifier = Modifier, standard: Int? = null) {
val context = LocalContext.current
val bitmap = icon.image(context)
if (bitmap != null) {
// An icon that names a tint (DEFAULT included) expects the host to colour it for the
// surface it sits on; without this a white glyph vanishes on a light theme.
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
val content = LocalContentColor.current
val white = remember(bitmap) { bitmap.isPlainWhite() }
// Untinted white artwork is a template-app habit from dark surfaces; it is coloured like
// text so it stays readable on a light one.
val tint = icon?.tint?.color(dark, content) ?: content.takeIf { white }
Image(
bitmap, contentDescription = null, modifier = modifier,
colorFilter = tint?.let { ColorFilter.tint(it) },
)
return
}
val vector = when (standard ?: icon.standardType) {
CarIcon.TYPE_BACK -> Icons.Filled.ArrowBack
CarIcon.TYPE_ALERT, CarIcon.TYPE_ERROR -> Icons.Filled.Warning
// No four-way pan glyph in material-icons-core; this is the closest honest stand-in.
CarIcon.TYPE_PAN -> Icons.Filled.Menu
else -> null
} ?: return
Icon(vector, contentDescription = null, modifier = modifier)
}
// --- lists, grids and panes ---------------------------------------------------------------------
@Composable
internal fun PaneTitle(title: String) {
if (title.isEmpty()) return
Text(
title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
)
}
@Composable
internal fun ItemListColumn(
single: ItemList?,
sectioned: List<ItemList>? = null,
modifier: Modifier = Modifier,
) {
val rows = buildList {
single?.items?.filterIsInstance<CarRow>()?.let(::addAll)
sectioned?.forEach { list -> addAll(list.items.filterIsInstance<CarRow>()) }
}
if (rows.isEmpty()) {
val message = single?.noItemsMessage.text()
if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp))
return
}
LazyColumn(modifier, contentPadding = PaddingValues(bottom = 8.dp)) {
items(rows) { RowItem(it) }
}
}
/** A grid of large tappable tiles - what apps use for a home screen beside the map. */
@Composable
internal fun GridItems(list: ItemList?, modifier: Modifier = Modifier) {
val items = list?.items?.filterIsInstance<GridItem>().orEmpty()
if (items.isEmpty()) return
LazyVerticalGrid(
columns = GridCells.Adaptive(96.dp),
modifier = modifier,
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
items(items) { item ->
Column(
Modifier
.clickable { item.onClickDelegate.click("grid item") }
.padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CarGlyph(item.image, Modifier.size(36.dp))
Spacer(Modifier.height(6.dp))
Text(
item.title.text(),
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val subtitle = item.text.text()
if (subtitle.isNotEmpty()) {
Text(
subtitle,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
@Composable
internal fun PaneRows(pane: Pane?, modifier: Modifier = Modifier) {
val rows = pane?.rows.orEmpty()
Column(modifier) {
rows.forEach { RowItem(it) }
ActionRow(
pane?.actions.orEmpty(),
Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
)
}
}
@Composable
internal fun RowItem(row: CarRow) {
val clickable = row.onClickDelegate != null && row.isEnabled
Row(
Modifier
.fillMaxWidth()
.let { if (clickable) it.clickable { row.onClickDelegate.click("row") } else it }
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
row.image?.let {
CarGlyph(it, Modifier.size(28.dp))
Spacer(Modifier.width(12.dp))
}
Column(Modifier.weight(1f)) {
Text(
row.title.text(),
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
row.texts.orEmpty().take(2).forEach { line ->
Text(
line.text(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
ActionRow(row.actions.orEmpty())
}
}
internal fun Color.luminanceIsDark(): Boolean =
(red * 0.299f + green * 0.587f + blue * 0.114f) < 0.5f
/** True for a glyph drawn entirely in white (any transparency): a mask, not a picture. */
private fun androidx.compose.ui.graphics.ImageBitmap.isPlainWhite(): Boolean {
val pixels = IntArray(width * height)
readPixels(pixels)
var seen = 0
for (pixel in pixels) {
val alpha = pixel ushr 24
if (alpha < 40) continue
seen++
if ((pixel shr 16 and 0xFF) < 225 || (pixel shr 8 and 0xFF) < 225 || (pixel and 0xFF) < 225) return false
}
return seen > 0
}
@@ -0,0 +1,92 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import kotlin.math.PI
import kotlin.math.sin
/**
* A progress bar whose played part is a wave, and which flattens out while paused.
*
* The wave runs only while [playing], so a paused player costs the encoder nothing. Tapping or
* dragging seeks; the position is committed on release rather than on every pixel of a drag,
* because each commit is a request to the media app.
*/
@Composable
fun WavyProgress(
fraction: Float,
playing: Boolean,
onSeek: (Float) -> Unit,
modifier: Modifier = Modifier,
) {
val played = MaterialTheme.colorScheme.primary
val remaining = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
val amplitude by animateFloatAsState(if (playing) 1f else 0f, tween(400), label = "wave")
val phase = if (playing) {
rememberInfiniteTransition(label = "phase").animateFloat(
0f, (2 * PI).toFloat(),
infiniteRepeatable(tween(1400, easing = LinearEasing), RepeatMode.Restart),
label = "phase",
).value
} else 0f
var dragging by remember { mutableStateOf<Float?>(null) }
val shown = (dragging ?: fraction).coerceIn(0f, 1f)
Canvas(
modifier
.fillMaxWidth()
.height(32.dp)
.pointerInput(Unit) { detectTapGestures { onSeek((it.x / size.width).coerceIn(0f, 1f)) } }
.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragStart = { dragging = (it.x / size.width).coerceIn(0f, 1f) },
onDragEnd = { dragging?.let(onSeek); dragging = null },
onDragCancel = { dragging = null },
) { change, _ -> dragging = (change.position.x / size.width).coerceIn(0f, 1f) }
},
) {
val stroke = 4.dp.toPx()
val mid = size.height / 2
val end = size.width * shown
val wavelength = 22.dp.toPx()
val amp = 3.5.dp.toPx() * amplitude
// Unplayed part: a straight line, so the wave reads as "this much is done".
drawLine(remaining, Offset(end, mid), Offset(size.width, mid), stroke, StrokeCap.Round)
val wave = Path().apply {
moveTo(0f, mid + amp * sin(phase.toDouble()).toFloat())
var x = 0f
while (x < end) {
x = minOf(end, x + 2f)
lineTo(x, mid + amp * sin(2 * PI * x / wavelength + phase).toFloat())
}
}
drawPath(wave, played, style = Stroke(stroke, cap = StrokeCap.Round))
drawLine(played, Offset(end, mid - 10.dp.toPx()), Offset(end, mid + 10.dp.toPx()), stroke, StrokeCap.Round)
}
}
@@ -0,0 +1,83 @@
package app.seb3thehacker.gearslip.car
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import app.seb3thehacker.gearslip.GearslipLog
/**
* A browser inside the car UI. [content] is a URL, or raw HTML when it does not look like one.
* The bottom bar's Back walks the page history first and only leaves the app at the start.
*/
@Composable
fun WebApp(content: String) {
val navigator = LocalCarNavigator.current
val darkOutside = LocalDarkOutside.current
var web by remember { mutableStateOf<WebView?>(null) }
DisposableEffect(web) {
navigator.backInterceptor = {
val view = web
if (view != null && view.canGoBack()) {
view.goBack()
true
} else {
false
}
}
onDispose { navigator.backInterceptor = null }
}
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
WebView(context).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.loadWithOverviewMode = true
settings.useWideViewPort = true
webViewClient = LoggingClient()
if (content.startsWith("http://") || content.startsWith("https://")) {
loadUrl(content)
} else {
loadDataWithBaseURL(null, content, "text/html", "utf-8", null)
}
web = this
}
},
// Pages that honour it follow the darkness outside; a first, tweakable use of the signal.
update = { it.settings.isAlgorithmicDarkeningAllowed = darkOutside },
onRelease = { it.destroy() },
)
}
private class LoggingClient : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
GearslipLog.i("car web page loaded: $url")
}
// onPageFinished fires for error pages too, so without this a blocked load (missing
// INTERNET permission, no network) looks like a successful one.
override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) {
if (request?.isForMainFrame == true) {
GearslipLog.e("car web page FAILED: ${request.url} - ${error?.errorCode} ${error?.description}")
}
}
override fun onReceivedHttpError(view: WebView?, request: WebResourceRequest?, response: WebResourceResponse?) {
if (request?.isForMainFrame == true) {
GearslipLog.w("car web page HTTP ${response?.statusCode} for ${request.url}")
}
}
}
@@ -0,0 +1,17 @@
package app.seb3thehacker.gearslip.host
/**
* Google's own Android Auto, in whatever guise it appears. Gearslip replaces it, so its entries
* (there are many: one per screen it registers) are noise in every list of apps to open.
*/
object AndroidAuto {
private val PACKAGE_MARKERS = listOf("gearhead", "projection.gearhead", "android.auto", "androidauto")
fun matches(packageName: String, label: String): Boolean {
val pkg = packageName.lowercase()
if (PACKAGE_MARKERS.any { it in pkg }) return true
val name = label.lowercase().replace(Regex("[^a-z ]"), " ")
return "android auto" in name || "androidauto" in name.replace(" ", "")
}
}
@@ -0,0 +1,81 @@
package app.seb3thehacker.gearslip.host
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import androidx.car.app.CarAppService
/** A templated app installed on the phone, and the category it asked to be treated as. */
class TemplateApp(
val component: ComponentName,
val label: String,
val categories: List<String>,
) {
val isNavigation: Boolean get() = CarAppService.CATEGORY_NAVIGATION_APP in categories
/** "navigation", "media", ... - what the app told the system it is. */
val kind: String
get() = categories.firstOrNull { it.startsWith(CATEGORY_PREFIX) }
?.removePrefix(CATEGORY_PREFIX)?.lowercase()
?: "app"
private companion object {
const val CATEGORY_PREFIX = "androidx.car.app.category."
}
}
/**
* Finds apps that can be rendered by a host.
*
* A templated app exports a CarAppService and never draws anything itself - it hands the host
* a template and lets the host decide how it looks. That is what makes hosting worth doing:
* the app arrives already designed for a car screen, rather than a phone layout squeezed onto
* one, which is all mirroring could ever manage.
*/
object CarAppCatalog {
fun installed(context: Context): List<TemplateApp> {
val manager = context.packageManager
val intent = Intent(CarAppService.SERVICE_INTERFACE)
return manager.queryIntentServices(intent, 0)
.asSequence()
.filter { it.serviceInfo.packageName != context.packageName }
.filterNot { AndroidAuto.matches(it.serviceInfo.packageName, it.loadLabel(manager).toString()) }
.map { resolved ->
val info = resolved.serviceInfo
TemplateApp(
component = ComponentName(info.packageName, info.name),
label = info.applicationInfo?.loadLabel(manager)?.toString() ?: info.packageName,
categories = categoriesOf(context, info.packageName, info.name),
)
}
.sortedBy { it.label.lowercase() }
.toList()
}
/**
* queryIntentServices does not report the matched filter's categories, so the categories
* are recovered by re-querying with each one and seeing which still match.
*/
private fun categoriesOf(context: Context, packageName: String, className: String): List<String> {
val manager = context.packageManager
return KNOWN_CATEGORIES.filter { category ->
val probe = Intent(CarAppService.SERVICE_INTERFACE)
.setPackage(packageName)
.addCategory(category)
manager.queryIntentServices(probe, 0).any { it.serviceInfo.name == className }
}
}
private val KNOWN_CATEGORIES = listOf(
CarAppService.CATEGORY_NAVIGATION_APP,
CarAppService.CATEGORY_POI_APP,
CarAppService.CATEGORY_PARKING_APP,
CarAppService.CATEGORY_CHARGING_APP,
CarAppService.CATEGORY_MESSAGING_APP,
CarAppService.CATEGORY_CALLING_APP,
CarAppService.CATEGORY_SETTINGS_APP,
CarAppService.CATEGORY_IOT_APP,
CarAppService.CATEGORY_WEATHER_APP,
)
}
@@ -0,0 +1,564 @@
package app.seb3thehacker.gearslip.host
import app.seb3thehacker.gearslip.car.CarEnvironment
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.content.res.Configuration
import android.graphics.Rect
import android.location.Location
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.view.Surface
import androidx.car.app.CarAppService
import androidx.car.app.CarContext
import androidx.car.app.HandshakeInfo
import androidx.car.app.IAppHost
import androidx.car.app.IAppManager
import androidx.car.app.ICarApp
import androidx.car.app.ICarHost
import androidx.car.app.IOnDoneCallback
import androidx.car.app.model.Template
import androidx.car.app.model.TemplateWrapper
import androidx.car.app.ISurfaceCallback
import androidx.car.app.SurfaceContainer
import androidx.car.app.AppInfo
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.constraints.IConstraintHost
import androidx.car.app.suggestion.ISuggestionHost
import androidx.car.app.hardware.ICarHardwareHost
import androidx.car.app.hardware.ICarHardwareResult
import androidx.car.app.navigation.INavigationHost
import androidx.car.app.serialization.Bundleable
import androidx.car.app.versioning.CarAppApiLevels
import app.seb3thehacker.gearslip.GearslipLog
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Speaks the Car App Library's host half, so a templated app can be rendered by Gearslip.
*
* This is the opposite end of the interface an app implements: the app exports a
* CarAppService and describes a screen as a template, and whichever host it is bound to
* decides how that template looks. Android Auto is one such host. So is this.
*
* Whether an app will talk to us at all is decided by its HostValidator, which accepts a host
* that either holds android.car.permission.TEMPLATE_RENDERER or is signed by a key on its
* allow-list. Gearslip declares that permission in its manifest, which is the whole reason
* this can work from an ordinary app - see AndroidManifest.xml.
*
* Every binder callback lands on a binder thread and is hopped to the main thread, because
* the state here feeds Compose.
*/
class CarAppConnection(private val context: Context) {
enum class Phase { IDLE, BINDING, HANDSHAKE, RUNNING, REJECTED, FAILED }
data class Status(
val phase: Phase = Phase.IDLE,
val app: String? = null,
val detail: String? = null,
val apiLevel: Int = 0,
val template: String? = null,
val surfaceRequested: Boolean = false,
val surfaceAttached: Boolean = false,
)
/** The Surface lent to the app, plus the size the app should draw at. */
private data class Lent(val surface: Surface, val width: Int, val height: Int, val dpi: Int)
private val _status = MutableStateFlow(Status())
val status: StateFlow<Status> = _status.asStateFlow()
/** The app's current screen, as it described it. Null until the first one arrives. */
private val _template = MutableStateFlow<Template?>(null)
val template: StateFlow<Template?> = _template.asStateFlow()
private val main = Handler(Looper.getMainLooper())
private var carApp: ICarApp? = null
private var appManager: IAppManager? = null
/** Main thread only, both of these - the handoff is a race between them either way round. */
private var surfaceCallback: ISurfaceCallback? = null
private var lent: Lent? = null
private var binding: ServiceConnection? = null
private var component: ComponentName? = null
private var frameWidth = 800
private var frameHeight = 480
private var frameDensity = 160
// --- connection ---------------------------------------------------------------------
fun connect(app: TemplateApp, width: Int, height: Int, densityDpi: Int) {
disconnect()
frameWidth = width
frameHeight = height
frameDensity = densityDpi
component = app.component
update { Status(phase = Phase.BINDING, app = app.label) }
val intent = Intent(CarAppService.SERVICE_INTERFACE).setComponent(app.component)
val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, service: IBinder) {
main.post { onBound(service) }
}
override fun onServiceDisconnected(name: ComponentName) {
main.post { fail(Phase.FAILED, "the app's service disconnected") }
}
override fun onNullBinding(name: ComponentName) {
// A templated app that refuses a host returns no binder at all.
main.post { fail(Phase.REJECTED, "the app refused to bind - host not accepted") }
}
}
binding = connection
LocationKeepAlive.start(context)
val bound = runCatching {
context.bindService(intent, connection, Context.BIND_AUTO_CREATE or LocationKeepAlive.BIND_INCLUDE_CAPABILITIES)
}.getOrElse {
GearslipLog.e("bindService threw for ${app.component}", it)
false
}
if (!bound) {
binding = null
fail(Phase.FAILED, "bindService refused")
} else {
GearslipLog.i("host: binding to ${app.component.flattenToShortString()}")
}
}
fun disconnect() {
lent?.let { sendSurfaceDestroyed(it) }
lent = null
val carApp = this.carApp
if (carApp != null) {
runCatching { carApp.onAppPause(noop("onAppPause")) }
runCatching { carApp.onAppStop(noop("onAppStop")) }
}
LocationKeepAlive.stop(context)
binding?.let { runCatching { context.unbindService(it) } }
binding = null
this.carApp = null
appManager = null
surfaceCallback = null
component = null
_template.value = null
update { Status() }
}
// --- handshake ---------------------------------------------------------------------
private fun onBound(service: IBinder) {
val app = ICarApp.Stub.asInterface(service)
carApp = app
update { it.copy(phase = Phase.HANDSHAKE, detail = "bound, asking for app info") }
GearslipLog.i("host: bound; requesting app info")
call("getAppInfo", onValue = { value ->
val info = value as? AppInfo ?: return@call fail(Phase.FAILED, "app info was ${value?.javaClass?.simpleName}")
onAppInfo(info)
}) { app.getAppInfo(it) }
}
/**
* Picks the newest API level both sides understand. An app that needs more than this host
* offers cannot be rendered correctly, so it is refused here rather than half-drawn.
*/
private fun onAppInfo(info: AppInfo) {
val negotiated = minOf(HOST_API_LEVEL, info.latestCarAppApiLevel)
GearslipLog.i(
"host: app api ${info.minCarAppApiLevel}..${info.latestCarAppApiLevel} " +
"(library ${info.libraryDisplayVersion}), host $HOST_API_LEVEL -> $negotiated"
)
if (negotiated < info.minCarAppApiLevel) {
fail(Phase.FAILED, "app needs car api ${info.minCarAppApiLevel}, host offers $HOST_API_LEVEL")
return
}
val app = carApp ?: return
update { it.copy(apiLevel = negotiated, detail = "handshake") }
val handshake = runCatching { Bundleable.create(HandshakeInfo(context.packageName, negotiated)) }
.getOrElse { return fail(Phase.FAILED, "could not bundle the handshake: ${it.message}") }
call("onHandshakeCompleted", onValue = { onHandshakeAccepted() }) {
app.onHandshakeCompleted(handshake, it)
}
}
private fun onHandshakeAccepted() {
val app = carApp ?: return
GearslipLog.i("host: handshake accepted; creating the app")
val intent = Intent(Intent.ACTION_MAIN).setComponent(component)
call("onAppCreate", onValue = { onCreated() }) {
app.onAppCreate(carHost, intent, carConfiguration(), it)
}
}
private fun onCreated() {
val app = carApp ?: return
GearslipLog.i("host: created; starting")
call("onAppStart", onValue = {
call("onAppResume", onValue = { onRunning() }) { app.onAppResume(it) }
}) { app.onAppStart(it) }
}
private fun onRunning() {
val app = carApp ?: return
update { it.copy(phase = Phase.RUNNING, detail = "connected") }
GearslipLog.verdict(
"${component?.packageName} accepted Gearslip as a template host",
"Handshake completed at car API level ${_status.value.apiLevel}.",
)
call("getManager(app)", onValue = { value ->
appManager = when (value) {
is IAppManager -> value
is IBinder -> IAppManager.Stub.asInterface(value)
else -> null
}
if (appManager == null) GearslipLog.e("host: app manager was ${value?.javaClass?.name}")
requestTemplate()
}) { app.getManager(CarContext.APP_SERVICE, it) }
}
/** The app describes its current screen; this is what the host draws. */
private fun requestTemplate() {
val manager = appManager ?: return
call("getTemplate", onValue = { value ->
// TemplateWrapper carries the template plus the id the host echoes back on refresh.
val template = (value as? TemplateWrapper)?.template ?: value as? Template
if (template == null) {
GearslipLog.w("host: getTemplate returned ${value?.javaClass?.name}")
return@call
}
GearslipLog.i("host: template = ${template.javaClass.simpleName}")
_template.value = template
update { it.copy(template = template.javaClass.simpleName) }
}) { manager.getTemplate(it) }
}
// --- the surface the app draws its map into -------------------------------------------
/**
* Lends the app a Surface to draw into.
*
* Navigation apps render their own map rather than describing it as a template, so the host
* has to give them somewhere to draw. Gearslip's car UI already lives in a Presentation on a
* VirtualDisplay backed by the encoder, so the Surface handed over here belongs to a
* SurfaceView in that Presentation: the app draws straight into the car's picture and
* SurfaceFlinger composites it under our chrome, with no readback or GL on our side.
*
* Safe to call before the app has asked for a surface - whichever of the two arrives second
* completes the handoff.
*/
fun attachSurface(surface: Surface, width: Int, height: Int, dpi: Int) {
main.post {
val current = lent
if (current != null &&
current.surface == surface &&
current.width == width &&
current.height == height
) {
return@post
}
current?.let { sendSurfaceDestroyed(it) }
lent = Lent(surface, width, height, dpi)
sendSurfaceAvailable()
}
}
/** Takes the Surface back, e.g. when the SurfaceView goes away. */
fun detachSurface() {
main.post {
val current = lent ?: return@post
lent = null
sendSurfaceDestroyed(current)
update { it.copy(surfaceAttached = false) }
}
}
private fun sendSurfaceAvailable() {
val callback = surfaceCallback ?: return
val surface = lent ?: return
if (!surface.surface.isValid) {
GearslipLog.w("host: not lending an invalid surface")
return
}
val container = runCatching {
Bundleable.create(
SurfaceContainer(surface.surface, surface.width, surface.height, surface.dpi)
)
}.getOrElse {
GearslipLog.e("host: could not bundle the surface", it)
return
}
val sent = runCatching { callback.onSurfaceAvailable(container, noop("onSurfaceAvailable")) }
if (sent.isFailure) {
GearslipLog.e("host: onSurfaceAvailable threw", sent.exceptionOrNull())
return
}
// The app draws for the whole surface; the visible area is what isn't covered by host
// chrome, and the stable area is the part that never gets covered. We keep our chrome
// to a top bar, so both are the surface minus that bar - reported by the UI via
// [reportAreas] once it has measured. Until then the whole surface is fair game.
val whole = Rect(0, 0, surface.width, surface.height)
reportAreas(whole, whole)
GearslipLog.i(
"host: lent the app a ${surface.width}x${surface.height} @${surface.dpi}dpi surface"
)
update { it.copy(surfaceAttached = true) }
}
private fun sendSurfaceDestroyed(surface: Lent) {
val callback = surfaceCallback ?: return
val container = runCatching {
Bundleable.create(
SurfaceContainer(surface.surface, surface.width, surface.height, surface.dpi)
)
}.getOrNull() ?: return
runCatching { callback.onSurfaceDestroyed(container, noop("onSurfaceDestroyed")) }
GearslipLog.i("host: took the surface back")
}
/** Tells the app which part of its surface the driver can actually see. */
fun reportAreas(visible: Rect, stable: Rect) {
val callback = surfaceCallback ?: return
runCatching { callback.onStableAreaChanged(stable, noop("onStableAreaChanged")) }
runCatching { callback.onVisibleAreaChanged(visible, noop("onVisibleAreaChanged")) }
}
// --- gestures on the app's surface -----------------------------------------------------
//
// These are the map interactions a host forwards: the app never sees raw touches, only
// these four. Coordinates are in surface pixels. Scroll distances follow GestureDetector's
// convention (previous minus current), which is the opposite sign to a Compose drag.
fun surfaceClick(x: Float, y: Float) = onSurface("onClick") { it.onClick(x, y) }
fun surfaceScroll(distanceX: Float, distanceY: Float) =
onSurface("onScroll") { it.onScroll(distanceX, distanceY) }
fun surfaceFling(velocityX: Float, velocityY: Float) =
onSurface("onFling") { it.onFling(velocityX, velocityY) }
fun surfaceScale(focusX: Float, focusY: Float, scaleFactor: Float) =
onSurface("onScale") { it.onScale(focusX, focusY, scaleFactor) }
private inline fun onSurface(name: String, send: (ISurfaceCallback) -> Unit) {
val callback = surfaceCallback ?: return
runCatching { send(callback) }.onFailure { GearslipLog.w("host: $name failed: ${it.message}") }
}
// --- host interfaces the app calls back into -----------------------------------------
private val appHost = object : IAppHost.Stub() {
override fun invalidate() {
GearslipLog.i("host: app invalidated its template")
main.post { requestTemplate() }
}
override fun showToast(text: CharSequence?, duration: Int) {
GearslipLog.i("host: toast \"$text\"")
}
/**
* Navigation apps draw their own map, so the host lends them a Surface. Receiving this
* is the signal that the app is ready to render into the car's picture.
*/
override fun setSurfaceCallback(callback: ISurfaceCallback?) {
GearslipLog.i("host: app asked for a surface (callback=${callback != null})")
main.post {
surfaceCallback = callback
update { it.copy(surfaceRequested = callback != null) }
if (callback != null) sendSurfaceAvailable()
}
}
override fun sendLocation(location: Location?) = Unit
override fun showAlert(alert: Bundleable?) = Unit
override fun dismissAlert(alertId: Int) = Unit
override fun openMicrophone(request: Bundleable?): Bundleable? = null
}
private val navigationHost = object : INavigationHost.Stub() {
override fun navigationStarted() = GearslipLog.i("host: navigation started")
override fun navigationEnded() = GearslipLog.i("host: navigation ended")
override fun updateTrip(trip: Bundleable?) = Unit
}
/**
* Answers car-hardware questions with "this car doesn't have that".
*
* A phone knows nothing about a car's sensors, so every answer here is "unsupported" - but
* it has to be an actual answer. Returning no binder for this service at all is what made
* Organic Maps crash in onAppStart: its CarSensorsManager assumes a host that offers the
* service, and the library's own "unsupported" path only runs once a reply arrives.
*
* The reply's Bundleable is ignored by the library whenever isSupported is false, but the
* IBinder must be a real IOnDoneCallback - the app dispatches the reply through it.
*/
private val hardwareHost = object : ICarHardwareHost.Stub() {
override fun getCarHardwareResult(
type: Int,
params: Bundleable?,
result: ICarHardwareResult?,
) = unsupported(type, result)
override fun subscribeCarHardwareResult(
type: Int,
params: Bundleable?,
result: ICarHardwareResult?,
) = unsupported(type, result)
override fun unsubscribeCarHardwareResult(type: Int, params: Bundleable?) = Unit
private fun unsupported(type: Int, result: ICarHardwareResult?) {
val target = result ?: return
runCatching {
target.onCarHardwareResult(type, false, null, noop("carHardwareResult").asBinder())
}.onFailure { GearslipLog.w("host: could not answer hardware result $type") }
}
}
/**
* Tells apps how much content this screen can take.
*
* These are safety limits in a real car - how many rows a driver may be shown at once -
* and the app trims its own lists to them. Gearslip's numbers match what the Car App
* Library documents for a projected head unit; they are about the screen, not about us.
*/
private val constraintHost = object : IConstraintHost.Stub() {
override fun getContentLimit(type: Int): Int = when (type) {
ConstraintManager.CONTENT_LIMIT_TYPE_GRID -> 12
ConstraintManager.CONTENT_LIMIT_TYPE_PANE -> 4
ConstraintManager.CONTENT_LIMIT_TYPE_ROUTE_LIST -> 3
else -> 12
}
/** The app may refresh its own template without the driver touching anything. */
override fun isAppDrivenRefreshEnabled(): Boolean = true
}
/**
* Accepts the shortcuts an app offers for a car launcher.
*
* Gearslip has nowhere to show these yet, but accepting and dropping them is right: an app
* that pushes suggestions shouldn't fail because its host doesn't surface them.
*/
private val suggestionHost = object : ISuggestionHost.Stub() {
override fun updateSuggestions(suggestions: Bundleable?) = Unit
}
private val carHost = object : ICarHost.Stub() {
override fun getHost(type: String?): IBinder? = when (type) {
CarContext.APP_SERVICE -> appHost.asBinder()
CarContext.NAVIGATION_SERVICE -> navigationHost.asBinder()
CarContext.HARDWARE_SERVICE -> hardwareHost.asBinder()
CarContext.CONSTRAINT_SERVICE -> constraintHost.asBinder()
CarContext.SUGGESTION_SERVICE -> suggestionHost.asBinder()
else -> {
GearslipLog.i("host: app asked for an unsupported service \"$type\"")
null
}
}
override fun startCarApp(intent: Intent?) {
GearslipLog.i("host: app wants to start $intent")
}
override fun finish() {
GearslipLog.i("host: app finished")
main.post { disconnect() }
}
}
// --- plumbing ------------------------------------------------------------------------
/**
* Every call in this protocol is one-way plus an IOnDoneCallback, so this pairs them back
* up and funnels a remote failure into the visible status rather than losing it.
*/
private fun call(name: String, onValue: (Any?) -> Unit, send: (IOnDoneCallback) -> Unit) {
val callback = object : IOnDoneCallback.Stub() {
override fun onSuccess(response: Bundleable?) {
val value = response?.let { runCatching { it.get() }.getOrNull() }
main.post { onValue(value) }
}
override fun onFailure(response: Bundleable?) {
val value = response?.let { runCatching { it.get() }.getOrNull() }
main.post { fail(Phase.FAILED, "$name failed: $value") }
}
}
runCatching { send(callback) }
.onFailure { fail(Phase.REJECTED, "$name threw: ${it.javaClass.simpleName} ${it.message}") }
}
private fun noop(name: String) = object : IOnDoneCallback.Stub() {
override fun onSuccess(response: Bundleable?) = Unit
override fun onFailure(response: Bundleable?) = GearslipLog.w("host: $name failed")
}
/**
* The app lays itself out for this, so it describes the car screen rather than the phone,
* including UI_MODE_TYPE_CAR - templated apps branch on that.
*/
/** The day/night value last handed to the app, so a change is only pushed when it is one. */
private var sentNight: Boolean? = null
/**
* Tells a running app that the car's light changed. Templated apps take their map style from
* the configuration the host reports, so this is what turns the map to day or night.
*/
fun pushConfiguration() {
val app = carApp ?: return
if (_status.value.phase != Phase.RUNNING) return
if (CarEnvironment.darkOutside.value == sentNight) return
val configuration = carConfiguration()
call("onConfigurationChanged", onValue = { }) { app.onConfigurationChanged(configuration, it) }
}
private fun carConfiguration(): Configuration {
val configuration = Configuration(context.resources.configuration)
val night = CarEnvironment.darkOutside.value
sentNight = night
configuration.uiMode = (configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or
if (night) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO
configuration.densityDpi = frameDensity
configuration.screenWidthDp = frameWidth * 160 / frameDensity
configuration.screenHeightDp = frameHeight * 160 / frameDensity
configuration.smallestScreenWidthDp =
minOf(configuration.screenWidthDp, configuration.screenHeightDp)
configuration.orientation =
if (frameWidth >= frameHeight) Configuration.ORIENTATION_LANDSCAPE
else Configuration.ORIENTATION_PORTRAIT
configuration.uiMode = (configuration.uiMode and Configuration.UI_MODE_TYPE_MASK.inv()) or
Configuration.UI_MODE_TYPE_CAR
return configuration
}
private fun fail(phase: Phase, detail: String) {
GearslipLog.e("host: $detail")
update { it.copy(phase = phase, detail = detail) }
}
private fun update(block: (Status) -> Status) {
_status.value = block(_status.value)
}
private companion object {
/** The newest template API this host claims to understand. */
const val HOST_API_LEVEL = CarAppApiLevels.LEVEL_8
}
}
@@ -0,0 +1,160 @@
package app.seb3thehacker.gearslip.host
import android.content.Context
import android.graphics.drawable.Drawable
import androidx.car.app.model.Action
import androidx.car.app.model.CarColor
import androidx.car.app.model.CarIcon
import androidx.car.app.model.CarText
import androidx.car.app.model.Distance
import androidx.car.app.model.InputCallbackDelegate
import androidx.car.app.model.OnClickDelegate
import androidx.car.app.model.OnContentRefreshDelegate
import androidx.car.app.model.SearchCallbackDelegate
import androidx.car.app.model.TabCallbackDelegate
import androidx.car.app.OnDoneCallback
import androidx.car.app.serialization.Bundleable
import androidx.compose.ui.graphics.Color
import androidx.core.graphics.drawable.toBitmap
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import app.seb3thehacker.gearslip.GearslipLog
import java.util.concurrent.TimeUnit
/**
* Turns the Car App Library's model objects into things Compose can draw.
*
* A templated app describes its screen in these types and says nothing about how they should
* look - that is the host's job, and every host looks different. These helpers are the seam
* between the app's description and Gearslip's rendering of it.
*/
/** The app's text, preferring the longest variant that will fit; variants are ordered longest first. */
fun CarText?.text(): String = this?.takeIf { !it.isEmpty }?.toCharSequence()?.toString().orEmpty()
/**
* Loads an icon the app described.
*
* A [CarIcon] is either a standard type the host is expected to draw itself, or an IconCompat
* pointing into the app's own resources - which is why this needs a Context that can see the
* app, and why a failure here is a missing glyph rather than an error worth stopping for.
*/
fun CarIcon?.image(context: Context): ImageBitmap? {
val icon = this?.icon ?: return null
val drawable: Drawable = runCatching { icon.loadDrawable(context) }.getOrNull() ?: return null
val width = drawable.intrinsicWidth.takeIf { it > 0 } ?: ICON_FALLBACK_PX
val height = drawable.intrinsicHeight.takeIf { it > 0 } ?: ICON_FALLBACK_PX
return runCatching { drawable.toBitmap(width, height).asImageBitmap() }.getOrNull()
}
/** Standard icons carry no bitmap - the host draws its own, so callers need to know which. */
val CarIcon?.standardType: Int? get() = this?.takeIf { it.icon == null }?.type
/**
* Resolves a colour the app asked for. The named colours deliberately stay close to what a
* driver expects them to mean rather than matching the app's brand.
*/
fun CarColor?.color(dark: Boolean, default: Color): Color = when (this?.type) {
null, CarColor.TYPE_DEFAULT -> default
CarColor.TYPE_CUSTOM -> Color(if (dark) colorDark else color)
CarColor.TYPE_RED -> Color(0xFFE53935)
CarColor.TYPE_GREEN -> Color(0xFF43A047)
CarColor.TYPE_BLUE -> Color(0xFF1E88E5)
CarColor.TYPE_YELLOW -> Color(0xFFFDD835)
else -> default
}
/** "500 ft", "2.4 mi" - the app picks the unit, the host formats it. */
fun Distance?.display(): String {
val distance = this ?: return ""
val unit = when (displayUnit) {
Distance.UNIT_METERS -> "m"
Distance.UNIT_KILOMETERS, Distance.UNIT_KILOMETERS_P1 -> "km"
Distance.UNIT_MILES, Distance.UNIT_MILES_P1 -> "mi"
Distance.UNIT_FEET -> "ft"
Distance.UNIT_YARDS -> "yd"
else -> ""
}
val decimals = when (displayUnit) {
Distance.UNIT_KILOMETERS_P1, Distance.UNIT_MILES_P1 -> 1
Distance.UNIT_KILOMETERS, Distance.UNIT_MILES -> if (displayDistance < 10) 1 else 0
else -> 0
}
return "%.${decimals}f $unit".format(distance.displayDistance)
}
/** "18 min" from the app's remaining-time estimate. */
fun remainingTime(seconds: Long): String {
if (seconds <= 0) return ""
val hours = TimeUnit.SECONDS.toHours(seconds)
val minutes = TimeUnit.SECONDS.toMinutes(seconds) % 60
return if (hours > 0) "$hours hr $minutes min" else "$minutes min"
}
/**
* Tells the app something was tapped.
*
* [OnClickDelegate.isParkedOnly] marks an action the app itself considers unsafe while moving.
* Gearslip has no speed signal yet, so these are allowed through and the decision is recorded
* here as the place to gate them once the sensor channel exists.
*/
fun OnClickDelegate?.click(label: String = "action") {
val delegate = this ?: return
runCatching { delegate.sendClick(doneCallback("a $label tap")) }
.onFailure { GearslipLog.w("host: could not send a $label tap: ${it.message}") }
}
/**
* Search is a conversation: the host owns the text and the keyboard, and tells the app about
* every keystroke so it can update its results as you type. [submitted] is the explicit "go".
*/
fun SearchCallbackDelegate?.textChanged(text: String) {
val delegate = this ?: return
runCatching { delegate.sendSearchTextChanged(text, doneCallback("search text")) }
.onFailure { GearslipLog.w("host: could not send search text: ${it.message}") }
}
fun SearchCallbackDelegate?.submitted(text: String) {
val delegate = this ?: return
runCatching { delegate.sendSearchSubmitted(text, doneCallback("search submit")) }
.onFailure { GearslipLog.w("host: could not submit a search: ${it.message}") }
}
/** Tells the app which tab the driver picked; the app answers with a new template. */
fun TabCallbackDelegate?.tabSelected(contentId: String) {
val delegate = this ?: return
runCatching { delegate.sendTabSelected(contentId, doneCallback("tab")) }
.onFailure { GearslipLog.w("host: could not select a tab: ${it.message}") }
}
/** Free text typed into a sign-in field, reported the same way search text is. */
fun InputCallbackDelegate?.inputChanged(text: String) {
val delegate = this ?: return
runCatching { delegate.sendInputTextChanged(text, doneCallback("input text")) }
.onFailure { GearslipLog.w("host: could not send input text: ${it.message}") }
}
fun InputCallbackDelegate?.inputSubmitted(text: String) {
val delegate = this ?: return
runCatching { delegate.sendInputSubmitted(text, doneCallback("input submit")) }
.onFailure { GearslipLog.w("host: could not submit input: ${it.message}") }
}
/** The driver asked for fresher content, e.g. by pulling a place list. */
fun OnContentRefreshDelegate?.refresh() {
val delegate = this ?: return
runCatching { delegate.sendContentRefreshRequested(doneCallback("refresh")) }
.onFailure { GearslipLog.w("host: could not request a refresh: ${it.message}") }
}
private fun doneCallback(label: String) = object : OnDoneCallback {
// onSuccess is annotated nullable and onFailure isn't - the asymmetry is real.
override fun onSuccess(response: Bundleable?) = Unit
override fun onFailure(response: Bundleable) = GearslipLog.w("host: the app rejected $label")
}
/** An action with nothing to show is one the host should skip rather than draw as a blank. */
fun Action.isDrawable(): Boolean =
icon != null || title.text().isNotEmpty() || type != Action.TYPE_CUSTOM
private const val ICON_FALLBACK_PX = 48
@@ -0,0 +1,58 @@
package app.seb3thehacker.gearslip.host
import android.Manifest
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.os.IBinder
/**
* Lets a hosted navigation app keep receiving the phone's location while Gearslip is not on screen.
*
* Android only gives an app location in the background if something visible to the user is
* running for it. A nav app bound as a car app has nothing of its own showing, so it goes
* without a fix as soon as the phone screen leaves it. Binding with BIND_INCLUDE_CAPABILITIES
* lends it this service's location capability, which is why this foreground service exists.
* It needs the ordinary location permission, granted on the phone.
*/
class LocationKeepAlive : Service() {
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(
NotificationChannel(CHANNEL, "Navigation location", NotificationManager.IMPORTANCE_LOW),
)
val notification = Notification.Builder(this, CHANNEL)
.setSmallIcon(android.R.drawable.ic_menu_mylocation)
.setContentTitle("Gearslip is sharing location with the car map")
.setOngoing(true)
.build()
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION)
return START_NOT_STICKY
}
companion object {
private const val CHANNEL = "gearslip_location"
private const val NOTIFICATION_ID = 44
/** Public flag value; the constant itself is hidden from the SDK. */
const val BIND_INCLUDE_CAPABILITIES = 0x1000
fun start(context: Context) {
val granted = context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
if (granted) runCatching { context.startForegroundService(Intent(context, LocationKeepAlive::class.java)) }
}
fun stop(context: Context) {
context.stopService(Intent(context, LocationKeepAlive::class.java))
}
}
}
@@ -0,0 +1,234 @@
package app.seb3thehacker.gearslip.media
import android.content.Context
import android.graphics.Bitmap
import android.media.MediaMetadata
import android.media.browse.MediaBrowser
import android.media.session.MediaController
import android.media.session.PlaybackState
import android.net.Uri
import android.os.Bundle
import android.os.SystemClock
import app.seb3thehacker.gearslip.GearslipLog
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
/** One row of an app's browse tree. */
class MediaEntry(
val id: String,
val title: String,
val subtitle: String,
val iconUri: Uri?,
val iconBitmap: Bitmap?,
val browsable: Boolean,
val playable: Boolean,
)
class CustomAction(val id: String, val name: String, val extras: Bundle?)
/** What is playing, as the car's now-playing panel needs it. */
class NowPlaying(
val title: String = "",
val artist: String = "",
val album: String = "",
val art: Bitmap? = null,
val artUri: Uri? = null,
val state: Int = PlaybackState.STATE_NONE,
val positionMs: Long = 0,
val durationMs: Long = 0,
val speed: Float = 1f,
val updatedAt: Long = 0,
val actions: Long = 0,
val custom: List<CustomAction> = emptyList(),
val error: String? = null,
) {
val playing get() = state == PlaybackState.STATE_PLAYING || state == PlaybackState.STATE_BUFFERING
val hasTrack get() = title.isNotEmpty() || state != PlaybackState.STATE_NONE
fun canDo(action: Long) = actions and action != 0L
/** The position now, advanced from the last report the way the platform's own UIs do. */
fun currentPosition(): Long {
if (!playing || state != PlaybackState.STATE_PLAYING) return positionMs
val elapsed = SystemClock.elapsedRealtime() - updatedAt
val moved = positionMs + (elapsed * speed).toLong()
return if (durationMs > 0) moved.coerceAtMost(durationMs) else moved
}
}
class BrowseState(
/** Titles of the folders entered so far; empty at the app's root. */
val trail: List<String> = emptyList(),
val entries: List<MediaEntry> = emptyList(),
val loading: Boolean = true,
val failed: Boolean = false,
)
/**
* Talks to one media app the way Android Auto does: connect to its MediaBrowserService, browse
* its tree, and drive playback through the session it publishes. The app decides what its
* tree contains and what the buttons do; this only presents it.
*/
class CarMedia(private val context: Context) {
enum class Phase { IDLE, CONNECTING, READY, REJECTED }
private val _phase = MutableStateFlow(Phase.IDLE)
val phase: StateFlow<Phase> = _phase.asStateFlow()
private val _browse = MutableStateFlow(BrowseState())
val browse: StateFlow<BrowseState> = _browse.asStateFlow()
private val _now = MutableStateFlow(NowPlaying())
val now: StateFlow<NowPlaying> = _now.asStateFlow()
private var browser: MediaBrowser? = null
private var controller: MediaController? = null
private var subscribed: String? = null
private var rootId: String? = null
private val path = ArrayDeque<Pair<String, String>>() // id to title
fun connect(app: MediaApp) {
disconnect()
_phase.value = Phase.CONNECTING
_browse.value = BrowseState()
val mb = MediaBrowser(context, app.component, object : MediaBrowser.ConnectionCallback() {
override fun onConnected() = this@CarMedia.onConnected(app)
override fun onConnectionFailed() {
GearslipLog.w("media: ${app.label} refused the connection")
_phase.value = Phase.REJECTED
}
override fun onConnectionSuspended() {
GearslipLog.w("media: ${app.label} went away")
_phase.value = Phase.REJECTED
}
}, null)
browser = mb
runCatching { mb.connect() }.onFailure {
GearslipLog.e("media: connect threw", it)
_phase.value = Phase.REJECTED
}
}
private fun onConnected(app: MediaApp) {
val mb = browser ?: return
GearslipLog.i("media: connected to ${app.label}, root=${mb.root}")
controller = MediaController(context, mb.sessionToken).also {
it.registerCallback(callback)
update(it.metadata, it.playbackState)
}
rootId = mb.root
path.clear()
_phase.value = Phase.READY
open(mb.root)
}
private val callback = object : MediaController.Callback() {
override fun onMetadataChanged(metadata: MediaMetadata?) = update(metadata, controller?.playbackState)
override fun onPlaybackStateChanged(state: PlaybackState?) = update(controller?.metadata, state)
}
private fun update(metadata: MediaMetadata?, state: PlaybackState?) {
fun text(vararg keys: String) = keys.firstNotNullOfOrNull { metadata?.getString(it)?.takeIf(String::isNotEmpty) }.orEmpty()
fun art(vararg keys: String) = keys.firstNotNullOfOrNull { metadata?.getBitmap(it) }
fun uri(vararg keys: String) = keys.firstNotNullOfOrNull { metadata?.getString(it)?.takeIf(String::isNotEmpty) }?.let(Uri::parse)
_now.value = NowPlaying(
title = text(MediaMetadata.METADATA_KEY_DISPLAY_TITLE, MediaMetadata.METADATA_KEY_TITLE),
artist = text(MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE, MediaMetadata.METADATA_KEY_ARTIST),
album = text(MediaMetadata.METADATA_KEY_ALBUM),
art = art(MediaMetadata.METADATA_KEY_ALBUM_ART, MediaMetadata.METADATA_KEY_ART, MediaMetadata.METADATA_KEY_DISPLAY_ICON),
artUri = uri(MediaMetadata.METADATA_KEY_ALBUM_ART_URI, MediaMetadata.METADATA_KEY_ART_URI, MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI),
state = state?.state ?: PlaybackState.STATE_NONE,
positionMs = state?.position ?: 0,
durationMs = metadata?.getLong(MediaMetadata.METADATA_KEY_DURATION) ?: 0,
speed = state?.playbackSpeed ?: 1f,
updatedAt = state?.lastPositionUpdateTime ?: 0,
actions = state?.actions ?: 0,
custom = state?.customActions.orEmpty().map { CustomAction(it.action, it.name.toString(), it.extras) },
error = state?.errorMessage?.toString(),
)
}
// --- browsing ------------------------------------------------------------------------
private fun open(parentId: String) {
val mb = browser ?: return
subscribed?.let { runCatching { mb.unsubscribe(it) } }
subscribed = parentId
_browse.update { BrowseState(trail = path.map { it.second }, loading = true) }
mb.subscribe(parentId, object : MediaBrowser.SubscriptionCallback() {
override fun onChildrenLoaded(parentId: String, children: MutableList<MediaBrowser.MediaItem>) {
if (parentId != subscribed) return
_browse.value = BrowseState(
trail = path.map { it.second },
entries = children.map(::entryOf),
loading = false,
)
}
override fun onError(parentId: String) {
if (parentId != subscribed) return
GearslipLog.w("media: could not load $parentId")
_browse.value = BrowseState(trail = path.map { it.second }, loading = false, failed = true)
}
})
}
private fun entryOf(item: MediaBrowser.MediaItem): MediaEntry {
val d = item.description
return MediaEntry(
id = item.mediaId.orEmpty(),
title = d.title?.toString().orEmpty(),
subtitle = d.subtitle?.toString().orEmpty(),
iconUri = d.iconUri,
iconBitmap = d.iconBitmap,
browsable = item.isBrowsable,
playable = item.isPlayable,
)
}
/** Folders open; tracks play. Some entries are both (an album you can browse or play whole). */
fun select(entry: MediaEntry) {
if (entry.browsable) {
path.addLast(entry.id to entry.title)
open(entry.id)
} else if (entry.playable) {
controller?.transportControls?.playFromMediaId(entry.id, null)
}
}
fun playAll(entry: MediaEntry) {
controller?.transportControls?.playFromMediaId(entry.id, null)
}
val canGoUp get() = path.isNotEmpty()
fun up() {
if (path.isEmpty()) return
path.removeLast()
open(path.lastOrNull()?.first ?: rootId ?: return)
}
// --- transport -----------------------------------------------------------------------
fun togglePlay() {
val controls = controller?.transportControls ?: return
if (_now.value.playing) controls.pause() else controls.play()
}
fun next() = controller?.transportControls?.skipToNext()
fun previous() = controller?.transportControls?.skipToPrevious()
fun seek(ms: Long) = controller?.transportControls?.seekTo(ms)
fun custom(action: CustomAction) = controller?.transportControls?.sendCustomAction(action.id, action.extras)
fun disconnect() {
runCatching { controller?.unregisterCallback(callback) }
runCatching { subscribed?.let { browser?.unsubscribe(it) } }
runCatching { browser?.disconnect() }
controller = null
browser = null
subscribed = null
_phase.value = Phase.IDLE
_now.value = NowPlaying()
}
}
@@ -0,0 +1,95 @@
package app.seb3thehacker.gearslip.media
import app.seb3thehacker.gearslip.GearslipLog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
class LyricLine(val timeMs: Long, val text: String)
/** [synced] means every line carries a time, so the current one can be followed along. */
class LyricsResult(val lines: List<LyricLine>, val synced: Boolean)
/**
* Looks lyrics up on LRCLIB (lrclib.net), a free community database that needs no key.
*
* Media apps almost never publish lyrics in their session metadata, so this is the only general
* source. It sends the track's title, artist, album and length to lrclib.net, which is why the
* lookup happens only when the driver opens the lyrics view and never in the background.
*/
object Lyrics {
private val cache = HashMap<String, LyricsResult?>()
suspend fun find(title: String, artist: String, album: String, durationMs: Long): LyricsResult? {
if (title.isBlank()) return null
val key = "$title|$artist|$album|$durationMs"
synchronized(cache) { if (cache.containsKey(key)) return cache[key] }
val result = withContext(Dispatchers.IO) {
runCatching {
exact(title, artist, album, durationMs) ?: search(title, artist)
}.onFailure { GearslipLog.w("lyrics: lookup failed: ${it.message}") }.getOrNull()
}
synchronized(cache) { cache[key] = result }
return result
}
private fun exact(title: String, artist: String, album: String, durationMs: Long): LyricsResult? {
if (artist.isBlank()) return null
val query = buildString {
append("track_name=").append(enc(title)).append("&artist_name=").append(enc(artist))
if (album.isNotBlank()) append("&album_name=").append(enc(album))
if (durationMs > 0) append("&duration=").append(durationMs / 1000)
}
return get("https://lrclib.net/api/get?$query")?.let { parse(JSONObject(it)) }
}
private fun search(title: String, artist: String): LyricsResult? {
val body = get("https://lrclib.net/api/search?q=" + enc("$artist $title".trim())) ?: return null
val hits = JSONArray(body)
for (i in 0 until hits.length()) parse(hits.getJSONObject(i))?.let { return it }
return null
}
private fun parse(json: JSONObject): LyricsResult? {
val synced = json.optString("syncedLyrics").takeIf { it.isNotBlank() && it != "null" }
if (synced != null) {
val lines = parseLrc(synced)
if (lines.isNotEmpty()) return LyricsResult(lines, synced = true)
}
val plain = json.optString("plainLyrics").takeIf { it.isNotBlank() && it != "null" } ?: return null
return LyricsResult(plain.lines().map { LyricLine(0, it) }, synced = false)
}
/** `[01:23.45] words` - a line may carry several stamps when a chorus repeats. */
private fun parseLrc(text: String): List<LyricLine> {
val stamp = Regex("""\[(\d+):(\d+(?:\.\d+)?)]""")
return text.lines().flatMap { raw ->
val stamps = stamp.findAll(raw).toList()
val words = raw.replace(stamp, "").trim()
stamps.map {
val ms = ((it.groupValues[1].toLong() * 60 + it.groupValues[2].toDouble()) * 1000).toLong()
LyricLine(ms, words)
}
}.sortedBy { it.timeMs }
}
private fun get(url: String): String? {
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = 5_000
readTimeout = 8_000
setRequestProperty("User-Agent", "Gearslip (car media host)")
}
return try {
if (connection.responseCode != 200) null else connection.inputStream.bufferedReader().readText()
} finally {
connection.disconnect()
}
}
private fun enc(value: String) = URLEncoder.encode(value, "UTF-8")
}
@@ -0,0 +1,34 @@
package app.seb3thehacker.gearslip.media
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.util.LruCache
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.URL
/** Cover art arrives as a content:// or https:// URI; this fetches and remembers it. */
object MediaArt {
private val cache = LruCache<String, Bitmap>(48)
suspend fun load(context: Context, uri: Uri?): Bitmap? {
if (uri == null) return null
val key = uri.toString()
cache.get(key)?.let { return it }
return withContext(Dispatchers.IO) {
runCatching {
val stream = when (uri.scheme) {
"http", "https" -> URL(key).openConnection().apply {
connectTimeout = 5_000
readTimeout = 8_000
}.getInputStream()
else -> context.contentResolver.openInputStream(uri)
} ?: return@runCatching null
stream.use { BitmapFactory.decodeStream(it) }
}.getOrNull()?.also { cache.put(key, it) }
}
}
}
@@ -0,0 +1,57 @@
package app.seb3thehacker.gearslip.media
import app.seb3thehacker.gearslip.host.AndroidAuto
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.service.media.MediaBrowserService
/** A media app that has told the system it belongs in a car. */
class MediaApp(
val component: ComponentName,
val label: String,
/** Whose audio to capture: playback capture is scoped to one uid so nothing else leaks in. */
val uid: Int,
)
/**
* Finds media apps that opted in to Android Auto.
*
* An app is a car media app when it exports a MediaBrowserService *and* declares the
* `com.google.android.gms.car.application` meta-data pointing at a descriptor with
* `<uses name="media"/>`. The second half is what separates Deezer from a phone-only player
* that merely exposes a browser service to Bluetooth and Wear.
*/
object MediaCatalog {
private const val CAR_META = "com.google.android.gms.car.application"
fun installed(context: Context): List<MediaApp> {
val manager = context.packageManager
return manager.queryIntentServices(Intent(MediaBrowserService.SERVICE_INTERFACE), PackageManager.GET_META_DATA)
.asSequence()
.filter { it.serviceInfo.packageName != context.packageName }
.filterNot { AndroidAuto.matches(it.serviceInfo.packageName, it.loadLabel(manager).toString()) }
.filter { declaresCar(manager, it.serviceInfo) }
.map {
val info = it.serviceInfo
MediaApp(
component = ComponentName(info.packageName, info.name),
label = info.applicationInfo?.loadLabel(manager)?.toString() ?: info.packageName,
uid = info.applicationInfo?.uid ?: -1,
)
}
.distinctBy { it.component.packageName }
.sortedBy { it.label.lowercase() }
.toList()
}
private fun declaresCar(manager: PackageManager, service: android.content.pm.ServiceInfo): Boolean {
if (service.metaData?.containsKey(CAR_META) == true) return true
return runCatching {
manager.getApplicationInfo(service.packageName, PackageManager.GET_META_DATA)
.metaData?.containsKey(CAR_META) == true
}.getOrDefault(false)
}
}
@@ -0,0 +1,117 @@
package app.seb3thehacker.gearslip.mirror
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.media.projection.MediaProjection
import android.media.projection.MediaProjectionManager
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import app.seb3thehacker.gearslip.GearslipLog
/**
* Holds the screen-capture session for [PhoneMirror].
*
* This exists because of an ordering rule: since Android 10 a MediaProjection may only be
* obtained while a foreground service declaring `mediaProjection` is already running, so the
* consent result has to be carried into a service before it can be redeemed for a token.
* Its notification doubles as the driver's way out - stopping capture from the shade works
* even when the car screen is showing a mirrored app rather than anything of ours.
*/
class MirrorService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) {
stopSelf()
return START_NOT_STICKY
}
val resultCode = intent?.getIntExtra(EXTRA_RESULT_CODE, 0) ?: 0
val data = intent?.getParcelableExtra(EXTRA_RESULT_DATA, Intent::class.java)
if (data == null) {
GearslipLog.e("mirror service started without a consent result")
stopSelf()
return START_NOT_STICKY
}
startForeground(
NOTIFICATION_ID, notification(),
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION,
)
val media: MediaProjection? = try {
getSystemService(MediaProjectionManager::class.java).getMediaProjection(resultCode, data)
} catch (t: Throwable) {
GearslipLog.e("getMediaProjection failed", t)
null
}
if (media == null) {
stopSelf()
return START_NOT_STICKY
}
// The session owns the encoder Surface, so it performs the swap once a token exists.
Handler(Looper.getMainLooper()).post { PhoneMirror.onTokenReady?.invoke(media) }
return START_NOT_STICKY
}
override fun onDestroy() {
super.onDestroy()
Handler(Looper.getMainLooper()).post {
PhoneMirror.end()
PhoneMirror.onEnded?.invoke()
}
}
private fun notification(): Notification {
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(
NotificationChannel(CHANNEL, "Phone mirroring", NotificationManager.IMPORTANCE_LOW)
)
val stop = PendingIntent.getService(
this, 0,
Intent(this, MirrorService::class.java).setAction(ACTION_STOP),
PendingIntent.FLAG_IMMUTABLE,
)
return Notification.Builder(this, CHANNEL)
.setContentTitle("Mirroring to the car")
.setContentText("The car screen is showing this phone.")
.setSmallIcon(android.R.drawable.ic_menu_view)
.setOngoing(true)
.addAction(Notification.Action.Builder(null, "Stop", stop).build())
.build()
}
companion object {
private const val CHANNEL = "gearslip_mirror"
private const val NOTIFICATION_ID = 42
private const val ACTION_STOP = "app.seb3thehacker.gearslip.STOP_MIRROR"
private const val EXTRA_RESULT_CODE = "resultCode"
private const val EXTRA_RESULT_DATA = "resultData"
private var appContext: Context? = null
/** Redeems a screen-capture consent result for a running capture session. */
fun start(context: Context, resultCode: Int, data: Intent) {
appContext = context.applicationContext
context.startForegroundService(
Intent(context, MirrorService::class.java)
.putExtra(EXTRA_RESULT_CODE, resultCode)
.putExtra(EXTRA_RESULT_DATA, data)
)
}
fun stop() {
val context = appContext ?: return
context.startService(Intent(context, MirrorService::class.java).setAction(ACTION_STOP))
}
}
}
@@ -0,0 +1,85 @@
package app.seb3thehacker.gearslip.mirror
import app.seb3thehacker.gearslip.host.AndroidAuto
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.provider.Settings
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import app.seb3thehacker.gearslip.GearslipLog
/** An app installed on the phone, offered on the car screen as something to mirror. */
class PhoneApp(val packageName: String, val label: String, val icon: ImageBitmap?)
/**
* The launcher's view of the phone, shown in the car so an app can be started without picking
* the phone up. Needs the `<queries>` entry in the manifest - package visibility hides other
* apps from us otherwise, and the list comes back empty rather than failing.
*/
object PhoneApps {
private const val ICON_PX = 128
fun installed(context: Context): List<PhoneApp> {
val manager = context.packageManager
val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
return manager.queryIntentActivities(intent, 0)
.asSequence()
.filter { it.activityInfo.packageName != context.packageName }
.filterNot { AndroidAuto.matches(it.activityInfo.packageName, it.loadLabel(manager).toString()) }
.distinctBy { it.activityInfo.packageName }
.map {
PhoneApp(
packageName = it.activityInfo.packageName,
label = it.loadLabel(manager).toString(),
icon = runCatching { it.loadIcon(manager).toImageBitmap() }.getOrNull(),
)
}
.sortedBy { it.label.lowercase() }
.toList()
}
/**
* Starts an app on the phone's own screen, which is what the mirror is pointed at.
*
* Deliberately uses the application context: a composable in the car UI sees a context
* bound to the projected display, and launching from that would try to place the activity
* there - the exact thing the platform refuses.
*/
fun launch(context: Context, packageName: String) {
val app = context.applicationContext
val intent = app.packageManager.getLaunchIntentForPackage(packageName)
if (intent == null) {
GearslipLog.e("no launch intent for $packageName")
return
}
runCatching { app.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) }
.onSuccess { GearslipLog.i("launched $packageName on the phone") }
.onFailure { GearslipLog.e("launching $packageName failed", it) }
}
/** Opens the phone's accessibility settings, where the touch relay is switched on. */
fun openAccessibilitySettings(context: Context) {
val app = context.applicationContext
runCatching {
app.startActivity(
Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}.onFailure { GearslipLog.e("could not open accessibility settings", it) }
}
private fun Drawable.toImageBitmap(): ImageBitmap {
if (this is BitmapDrawable && bitmap != null) {
return bitmap.copy(Bitmap.Config.ARGB_8888, false).asImageBitmap()
}
val bitmap = Bitmap.createBitmap(ICON_PX, ICON_PX, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
setBounds(0, 0, canvas.width, canvas.height)
draw(canvas)
return bitmap.asImageBitmap()
}
}
@@ -0,0 +1,146 @@
package app.seb3thehacker.gearslip.mirror
import android.content.Context
import android.graphics.PointF
import android.hardware.display.DisplayManager
import android.hardware.display.VirtualDisplay
import android.media.projection.MediaProjection
import android.view.Surface
import android.view.WindowManager
import app.seb3thehacker.gearslip.GearslipLog
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Mirrors the phone's screen into the head unit's video stream.
*
* The platform will not let a normal app render another app onto a display it owns (an
* activity may only be placed on a virtual display whose owner shares its uid, and doing it
* for someone else's app needs INTERNAL_SYSTEM_WINDOW - see ScreenProjector.launchApp).
* Mirroring sidesteps that: the app runs where it always does, on the phone's own display,
* and MediaProjection copies that display into the encoder Surface we are already feeding to
* the car. Touch makes the return trip through [TouchRelayService].
*
* The MediaProjection token is produced by [MirrorService], because Android only hands one
* over while a foreground service of type mediaProjection is running.
*/
object PhoneMirror {
private val _active = MutableStateFlow(false)
/** True while the car is showing the phone rather than Gearslip's own car UI. */
val active: StateFlow<Boolean> = _active.asStateFlow()
private var display: VirtualDisplay? = null
private var media: MediaProjection? = null
private var frameWidth = 0
private var frameHeight = 0
/**
* Wired up by the session, which owns the encoder Surface and so is the only thing that
* can swap what draws into it. All three are invoked on the main thread.
*/
internal var onStartRequested: (() -> Unit)? = null
internal var onTokenReady: ((MediaProjection) -> Unit)? = null
internal var onEnded: (() -> Unit)? = null
/** Asks the phone to put up the screen-capture consent dialog. Safe to call from the car. */
fun requestStart() {
onStartRequested?.invoke()
}
fun requestStop() = MirrorService.stop()
private var pendingLaunch: String? = null
/**
* Shows [packageName] on the car screen.
*
* When mirroring has not started yet the app is remembered rather than launched, because
* consent has to be granted while Gearslip is still the foreground app - starting the
* other app first would push us into the background, where no dialog can be shown.
*/
fun open(context: Context, packageName: String) {
if (_active.value) {
PhoneApps.launch(context, packageName)
} else {
pendingLaunch = packageName
requestStart()
}
}
/** Called by the session once the mirror is live. */
fun launchPending(context: Context) {
val packageName = pendingLaunch ?: return
pendingLaunch = null
PhoneApps.launch(context, packageName)
}
/**
* Points [surface] at the phone's screen. The caller must have released whatever else was
* drawing into that Surface first - a Surface has one producer.
*/
fun begin(token: MediaProjection, surface: Surface, width: Int, height: Int, densityDpi: Int) {
end()
media = token
frameWidth = width
frameHeight = height
token.registerCallback(object : MediaProjection.Callback() {
override fun onStop() {
GearslipLog.w("media projection stopped (revoked or replaced)")
MirrorService.stop()
}
}, null)
display = try {
token.createVirtualDisplay(
"gearslip-mirror", width, height, densityDpi,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, surface, null, null,
)
} catch (t: Throwable) {
GearslipLog.e("mirror display failed", t)
null
}
_active.value = display != null
if (display != null) GearslipLog.i("mirroring the phone into ${width}x$height")
}
fun end() {
runCatching { display?.release() }
runCatching { media?.stop() }
display = null
media = null
_active.value = false
}
/**
* Maps a head-unit touch in projected coordinates onto the phone's screen.
*
* Mirroring letterboxes: the phone's display is scaled to fit the car frame with its
* aspect kept, so the live content occupies a centred sub-rectangle and everything outside
* it is padding that no window can receive. Metrics are read per call rather than cached
* because rotating the phone changes the shape of the mirror underneath us.
*
* Returns null for a touch that landed on the padding.
*/
fun mapToPhone(context: Context, x: Float, y: Float): PointF? {
if (frameWidth == 0 || frameHeight == 0) return null
val bounds = context.getSystemService(WindowManager::class.java)
?.currentWindowMetrics?.bounds ?: return null
val phoneW = bounds.width().toFloat()
val phoneH = bounds.height().toFloat()
if (phoneW <= 0f || phoneH <= 0f) return null
val scale = minOf(frameWidth / phoneW, frameHeight / phoneH)
val offsetX = (frameWidth - phoneW * scale) / 2f
val offsetY = (frameHeight - phoneH * scale) / 2f
val px = (x - offsetX) / scale
val py = (y - offsetY) / scale
if (px < 0f || py < 0f || px > phoneW || py > phoneH) return null
return PointF(px, py)
}
}
@@ -0,0 +1,139 @@
package app.seb3thehacker.gearslip.mirror
import android.accessibilityservice.AccessibilityService
import android.accessibilityservice.GestureDescription
import android.graphics.Path
import android.graphics.PointF
import android.view.accessibility.AccessibilityEvent
import app.seb3thehacker.gearslip.GearslipLog
/**
* Replays head-unit touches onto the phone while [PhoneMirror] is running.
*
* dispatchGesture is the only public API that lets one app inject input into another, which
* is why mirroring needs an accessibility service at all. Gearslip subscribes to no event
* types and never inspects a window, so nothing about the phone's screen is read here - the
* service is a one-way output.
*
* A head unit sends a stream of DOWN/MOVE/UP, but a gesture is dispatched as a whole path, so
* the two are bridged with continued strokes: each dispatch carries the movement since the
* last one, and the next is only queued once the previous completes. Moves that arrive while
* a segment is in flight collapse into the most recent point, which keeps the replayed path
* tracking the finger instead of falling behind it.
*/
class TouchRelayService : AccessibilityService() {
private var stroke: GestureDescription.StrokeDescription? = null
private var last: PointF? = null
/** A segment is in flight; further movement waits so strokes stay properly ordered. */
private var dispatching = false
/** Most recent unsent position, and whether the finger has already lifted. */
private var pending: PointF? = null
private var lifted = false
override fun onServiceConnected() {
instance = this
GearslipLog.i("touch relay connected")
}
override fun onDestroy() {
super.onDestroy()
instance = null
reset()
}
override fun onAccessibilityEvent(event: AccessibilityEvent?) = Unit
override fun onInterrupt() = reset()
fun down(x: Float, y: Float) {
reset()
val path = Path().apply { moveTo(x, y); lineTo(x, y) }
val start = GestureDescription.StrokeDescription(path, 0, SEGMENT_MS, true)
stroke = start
last = PointF(x, y)
send(start)
}
fun move(x: Float, y: Float) {
if (stroke == null) return
pending = PointF(x, y)
pump()
}
fun up(x: Float, y: Float) {
if (stroke == null) return
pending = PointF(x, y)
lifted = true
pump()
}
private fun pump() {
if (dispatching) return
val previous = stroke ?: return
val from = last ?: return
val to = pending
// Nothing new to send yet: hold the stroke open until movement or a lift arrives.
if (to == null && !lifted) return
val target = to ?: from
val path = Path().apply { moveTo(from.x, from.y); lineTo(target.x, target.y) }
val next = try {
previous.continueStroke(path, 0, SEGMENT_MS, !lifted)
} catch (t: Throwable) {
GearslipLog.e("continueStroke rejected", t)
reset()
return
}
stroke = next
last = target
pending = null
send(next)
}
private fun send(description: GestureDescription.StrokeDescription) {
val gesture = GestureDescription.Builder().addStroke(description).build()
dispatching = true
val ok = dispatchGesture(gesture, object : GestureResultCallback() {
override fun onCompleted(g: GestureDescription?) {
dispatching = false
if (lifted && pending == null) reset() else pump()
}
override fun onCancelled(g: GestureDescription?) {
dispatching = false
reset()
}
}, null)
if (!ok) {
GearslipLog.w("dispatchGesture refused - is another gesture in flight?")
dispatching = false
reset()
}
}
private fun reset() {
stroke = null
last = null
pending = null
lifted = false
dispatching = false
}
companion object {
/**
* How long each replayed segment is told to take. Short enough that the pointer keeps
* up with the finger, long enough that the framework does not treat a drag as a fling.
*/
private const val SEGMENT_MS = 32L
@Volatile
var instance: TouchRelayService? = null
private set
/** False until the user enables Gearslip under Settings > Accessibility. */
val enabled: Boolean get() = instance != null
}
}
@@ -0,0 +1,47 @@
package app.seb3thehacker.gearslip.notify
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.SystemClock
/**
* Whether the car is moving, judged from the phone's GPS speed.
*
* Used only to keep notification popups off the screen while driving. With no permission or no
* recent fix it answers "not moving", so a missing signal never hides anything for good.
*/
object CarMotion {
private const val MOVING_MS = 2.0f // about 7 km/h
private const val STALE_MS = 15_000L
private var listener: LocationListener? = null
private var speed = 0f
private var fixAt = 0L
val moving: Boolean
get() = SystemClock.elapsedRealtime() - fixAt < STALE_MS && speed > MOVING_MS
fun start(context: Context) {
if (listener != null) return
if (context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) return
val manager = context.getSystemService(LocationManager::class.java) ?: return
val l = object : LocationListener {
override fun onLocationChanged(location: Location) {
speed = if (location.hasSpeed()) location.speed else 0f
fixAt = SystemClock.elapsedRealtime()
}
}
listener = l
runCatching { manager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 2_000L, 0f, l) }
}
fun stop(context: Context) {
listener?.let { l -> context.getSystemService(LocationManager::class.java)?.removeUpdates(l) }
listener = null
}
}
@@ -0,0 +1,124 @@
package app.seb3thehacker.gearslip.notify
import android.app.Notification
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.os.Bundle
import app.seb3thehacker.gearslip.GearslipLog
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
/** How a notification can be answered: the action the app attached, and the field it fills. */
class ReplyTarget(val action: Notification.Action, val input: android.app.RemoteInput)
/**
* One notification as the car shows it. Only what the car UI needs is kept - the phone's
* Notification object stays with the listener, apart from the reply action.
*/
data class CarNotification(
/** Stable per conversation: an app re-posting an update reuses the key. */
val key: String,
/** Unique per post, so an update to an already-shown notification still counts as new. */
val id: Long,
val appLabel: String,
val title: String,
val text: String,
val postedAt: Long,
val icon: Bitmap?,
/** Null when the app offered no reply, or once the notification has left the phone. */
val reply: ReplyTarget?,
val replied: Boolean = false,
)
/**
* The car's notification centre: a short history, the one popup on screen, and an unread count.
*
* Held in memory only. Message contents are never written to disk, so a process restart clears
* the history - which is the safer failure for something shown on a car screen.
*/
object CarNotifications {
private const val MAX_HISTORY = 50
/** A reply's own echo (apps re-post the thread with "You: ...") shouldn't pop up again. */
private const val ECHO_WINDOW_MS = 10_000L
private val _history = MutableStateFlow<List<CarNotification>>(emptyList())
val history: StateFlow<List<CarNotification>> = _history.asStateFlow()
private val _popup = MutableStateFlow<CarNotification?>(null)
val popup: StateFlow<CarNotification?> = _popup.asStateFlow()
private val _unread = MutableStateFlow(0)
val unread: StateFlow<Int> = _unread.asStateFlow()
/** True while the system has bound the listener - i.e. the user has granted access. */
private val _listening = MutableStateFlow(false)
val listening: StateFlow<Boolean> = _listening.asStateFlow()
private var nextId = 0L
private var lastReplyKey: String? = null
private var lastReplyAt = 0L
fun setListening(on: Boolean) { _listening.value = on }
fun nextId(): Long = nextId++
/** [quiet] adds to the history without a popup or unread badge (used to seed on connect). */
fun post(notification: CarNotification, quiet: Boolean) {
_history.update { list ->
(listOf(notification) + list.filterNot { it.key == notification.key }).take(MAX_HISTORY)
}
val echo = notification.key == lastReplyKey &&
System.currentTimeMillis() - lastReplyAt < ECHO_WINDOW_MS
if (quiet || echo) return
// History still keeps it; only the popup is held back while driving.
if (CarMotion.moving) return
_popup.value = notification
}
/** The phone dismissed it. It stays in history, but its reply intent is no longer trustworthy. */
fun gone(key: String) {
_history.update { list -> list.map { if (it.key == key) it.copy(reply = null) else it } }
}
fun dismissPopup(id: Long) {
_popup.update { if (it?.id == id) null else it }
}
/** Called while the notification screen is showing: nothing is unread there. */
fun markRead() {
_unread.value = 0
_popup.value = null
}
fun clear() {
_history.value = emptyList()
_popup.value = null
_unread.value = 0
}
/** Fills the app's reply field and fires its action. Returns false if the app has withdrawn it. */
fun reply(context: Context, notification: CarNotification, text: String): Boolean {
val target = notification.reply ?: return false
val fill = Intent()
val results = Bundle().apply { putCharSequence(target.input.resultKey, text) }
android.app.RemoteInput.addResultsToIntent(arrayOf(target.input), fill, results)
return try {
target.action.actionIntent.send(context, 0, fill)
lastReplyKey = notification.key
lastReplyAt = System.currentTimeMillis()
_history.update { list ->
list.map { if (it.key == notification.key) it.copy(replied = true) else it }
}
true
} catch (e: PendingIntent.CanceledException) {
GearslipLog.w("notify: the reply to ${notification.appLabel} was withdrawn by the app")
false
}
}
}
@@ -0,0 +1,89 @@
package app.seb3thehacker.gearslip.notify
import android.app.Notification
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import androidx.core.graphics.drawable.toBitmap
/**
* Feeds the phone's notifications to the car.
*
* Reading other apps' notifications is gated by the user granting "Notification access" to
* Gearslip in the phone's settings - the same on-device consent as any notification mirror,
* and the only one needed. Until then the car UI says so and offers to open that page.
*/
class GearslipNotificationListener : NotificationListenerService() {
override fun onListenerConnected() {
CarNotifications.setListening(true)
// Seed the history with what is already showing, oldest first so the newest ends up on top.
runCatching { activeNotifications.orEmpty().sortedBy { it.postTime } }
.getOrDefault(emptyList())
.forEach { ingest(it, quiet = true) }
}
override fun onListenerDisconnected() = CarNotifications.setListening(false)
override fun onNotificationPosted(sbn: StatusBarNotification) = ingest(sbn, quiet = false)
override fun onNotificationRemoved(sbn: StatusBarNotification) = CarNotifications.gone(sbn.key)
private fun ingest(sbn: StatusBarNotification, quiet: Boolean) {
if (sbn.packageName == packageName) return
val n = sbn.notification
// Ongoing entries (media, navigation, downloads) and group headers are status, not news.
if (n.flags and (Notification.FLAG_ONGOING_EVENT or Notification.FLAG_GROUP_SUMMARY) != 0) return
if (n.category in QUIET_CATEGORIES) return
val extras = n.extras
val title = extras.getCharSequence(Notification.EXTRA_TITLE)?.toString().orEmpty()
val text = (extras.getCharSequence(Notification.EXTRA_BIG_TEXT)
?: extras.getCharSequence(Notification.EXTRA_TEXT))?.toString().orEmpty()
if (title.isBlank() && text.isBlank()) return
CarNotifications.post(
CarNotification(
key = sbn.key,
id = CarNotifications.nextId(),
appLabel = appLabel(sbn.packageName),
title = title,
text = text,
postedAt = sbn.postTime,
icon = icon(sbn),
reply = replyOf(n),
),
quiet,
)
}
private fun appLabel(packageName: String): String = runCatching {
packageManager.getApplicationLabel(packageManager.getApplicationInfo(packageName, 0)).toString()
}.getOrDefault(packageName.substringAfterLast('.'))
private fun icon(sbn: StatusBarNotification) = runCatching {
val drawable = sbn.notification.getLargeIcon()?.loadDrawable(this)
?: packageManager.getApplicationIcon(sbn.packageName)
drawable.toBitmap(ICON_PX, ICON_PX)
}.getOrNull()
/** Messaging apps often attach the reply only for wearables, so both places are searched. */
private fun replyOf(n: Notification): ReplyTarget? {
val actions = n.actions.orEmpty().toList() + Notification.WearableExtender(n).actions
for (action in actions) {
if (action.actionIntent == null) continue
val input = action.remoteInputs?.firstOrNull { it.allowFreeFormInput } ?: continue
return ReplyTarget(action, input)
}
return null
}
private companion object {
const val ICON_PX = 96
val QUIET_CATEGORIES = setOf(
Notification.CATEGORY_TRANSPORT,
Notification.CATEGORY_PROGRESS,
Notification.CATEGORY_SERVICE,
Notification.CATEGORY_SYSTEM,
)
}
}
@@ -0,0 +1,255 @@
package app.seb3thehacker.gearslip.ui
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.content.pm.ActivityInfo
import android.graphics.Bitmap
import android.graphics.PixelFormat
import android.hardware.HardwareBuffer
import android.media.ImageReader
import android.os.Handler
import android.os.Looper
import android.view.MotionEvent
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.input.pointer.pointerInteropFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.ScreenProjector
import app.seb3thehacker.gearslip.car.CarEnvironment
import app.seb3thehacker.gearslip.car.CarUi
/**
* Runs the real car UI on the bench and shows it on the phone.
*
* This is the same pipeline the car gets - a VirtualDisplay hosting a Presentation, driven by
* the same [ScreenProjector] and fed the same touches - with an ImageReader standing in for the
* H.264 encoder. That matters for anything composited rather than drawn by Compose, such as the
* SurfaceView a templated app draws its map into: if it appears here, it will reach the car.
*
* Debug affordance only. Nothing in the car session path goes through this screen.
*/
@Composable
fun CarPreviewScreen(onBack: () -> Unit) {
val context = LocalContext.current
val frame by CarEnvironment.frame.collectAsStateWithLifecycle()
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
var viewSize by remember { mutableStateOf(0 to 0) }
var fullscreen by remember { mutableStateOf(false) }
val width = frame.width
val height = frame.height
val density = frame.densityDpi
val projector = remember { ScreenProjector(context) }
DisposableEffect(width, height, density) {
CarEnvironment.start(context)
// COMPOSER_OVERLAY is what makes SurfaceFlinger willing to composite into this reader;
// CPU_READ_OFTEN is what makes the resulting buffer lockable, which is how the frame
// gets copied into a Bitmap. Without both, frames arrive but every copy fails.
val reader = ImageReader.newInstance(
width, height, PixelFormat.RGBA_8888, 3,
HardwareBuffer.USAGE_COMPOSER_OVERLAY or
HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE or
HardwareBuffer.USAGE_CPU_READ_OFTEN,
)
val handler = Handler(Looper.getMainLooper())
reader.setOnImageAvailableListener({ source ->
val image = runCatching { source.acquireLatestImage() }.getOrNull() ?: return@setOnImageAvailableListener
try {
bitmap = image.toBitmap(width, height)
} catch (t: Throwable) {
GearslipLog.e("preview: could not copy a frame", t)
} finally {
image.close()
}
}, handler)
projector.start(reader.surface, width, height, density) { CarUi() }
onDispose {
projector.stop()
reader.close()
bitmap = null
}
}
// The frame is drawn the same way in both layouts; only what surrounds it differs.
val frameContent: @Composable (Modifier) -> Unit = { modifier ->
Box(
modifier
.aspectRatio(width.toFloat() / height)
.background(Color.Black)
.onSizeChanged { viewSize = it.width to it.height }
.pointerInteropFilter { event ->
val (viewWidth, viewHeight) = viewSize
if (viewWidth > 0 && viewHeight > 0) {
projector.dispatchTouch(
event.actionMasked.toCarAction(),
event.x * width / viewWidth,
event.y * height / viewHeight,
)
}
true
},
contentAlignment = Alignment.Center,
) {
bitmap?.let {
Image(
it.asImageBitmap(),
contentDescription = "Car screen",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit,
)
} ?: Text("Waiting for a frame…", color = Color.White)
}
}
if (fullscreen) {
Fullscreen(landscape = width > height, onExit = { fullscreen = false }) {
// No fillMax here: left to the bounds alone, aspectRatio takes the whole width or the
// whole height, whichever the car's shape allows, and the rest stays black.
frameContent(Modifier)
}
return
}
Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding ->
Column(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) {
Text(
"Car preview",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(vertical = 12.dp),
)
Text(
"${width}x$height @ ${density}dpi - touches go to the car UI",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
frameContent(Modifier.padding(vertical = 12.dp).fillMaxWidth())
Row {
TextButton(onClick = onBack) { Text("Back") }
TextButton(onClick = { fullscreen = true }) { Text("Fullscreen") }
}
}
}
}
/**
* Fills the phone screen with [content], centred and letterboxed.
*
* A car screen is wider than it is tall, so a portrait phone would waste most of its height on
* one. While this is showing, the activity is turned to match and the system bars go away - both
* are undone on the way out, so the rest of the phone UI is unaffected.
*/
@Composable
private fun Fullscreen(
landscape: Boolean,
onExit: () -> Unit,
content: @Composable BoxScope.() -> Unit,
) {
val activity = LocalContext.current.activity()
DisposableEffect(activity, landscape) {
if (activity == null) return@DisposableEffect onDispose { }
val insets = WindowInsetsControllerCompat(activity.window, activity.window.decorView).apply {
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
hide(WindowInsetsCompat.Type.systemBars())
}
val previousOrientation = activity.requestedOrientation
if (landscape) activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
onDispose {
insets.show(WindowInsetsCompat.Type.systemBars())
activity.requestedOrientation = previousOrientation
}
}
BackHandler(onBack = onExit)
Box(
Modifier.fillMaxSize().background(Color.Black),
contentAlignment = Alignment.Center,
) {
content()
// The frame swallows every touch inside it, so leaving needs either the back gesture or
// a button of its own. This one sits in the corner the car UI leaves emptiest.
FilledTonalIconButton(
onClick = onExit,
modifier = Modifier.align(Alignment.BottomStart).padding(8.dp),
) {
Icon(Icons.Filled.Close, contentDescription = "Leave fullscreen")
}
}
}
/** Compose hands out a themed wrapper, so the activity is a few unwraps down. */
private tailrec fun Context.activity(): Activity? = when (this) {
is Activity -> this
is ContextWrapper -> baseContext.activity()
else -> null
}
/** Only the four actions [ScreenProjector.dispatchTouch] understands reach the car UI. */
private fun Int.toCarAction(): Int = when (this) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE,
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL,
-> this
else -> MotionEvent.ACTION_CANCEL
}
/**
* Copies one frame out of the reader. The reader's rows are padded to a hardware-friendly
* stride, so the copy is made at the padded width and then cropped back.
*/
private fun android.media.Image.toBitmap(width: Int, height: Int): Bitmap {
val plane = planes[0]
val pixelStride = plane.pixelStride
val rowPadding = plane.rowStride - pixelStride * width
val padded = Bitmap.createBitmap(
width + rowPadding / pixelStride, height, Bitmap.Config.ARGB_8888,
)
padded.copyPixelsFromBuffer(plane.buffer)
return if (padded.width == width) padded
else Bitmap.createBitmap(padded, 0, 0, width, height).also { padded.recycle() }
}
@@ -0,0 +1,44 @@
package app.seb3thehacker.gearslip.ui
import android.content.Context
import app.seb3thehacker.gearslip.CertProvider
import java.text.DateFormat
import java.util.Date
/** What the UI says about the certificate a connection would present. */
data class CertSummary(
val kind: CertProvider.Kind,
val headline: String,
val subject: String?,
val issuer: String?,
val validUntil: String?,
) {
companion object {
/** Blocking (parses a key store): call from a background dispatcher. */
fun read(context: Context): CertSummary {
val identity = CertProvider.loadSupplied(context)
?: return CertSummary(
CertProvider.Kind.SELF_SIGNED,
"None loaded - a self-signed one is generated, which real head units reject",
null, null, null,
)
val cert = identity.certificate
val name = friendlyName(cert.subjectX500Principal.name)
val prefix = if (identity.kind == CertProvider.Kind.IMPORTED) "Imported" else "Staged over adb"
return CertSummary(
identity.kind,
"$prefix: $name",
cert.subjectX500Principal.name,
cert.issuerX500Principal.name,
DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(cert.notAfter.time)),
)
}
private fun friendlyName(distinguishedName: String): String {
for (key in listOf("CN", "O")) {
Regex("(?:^|,)$key=([^,]+)").find(distinguishedName)?.let { return it.groupValues[1] }
}
return distinguishedName
}
}
}
@@ -0,0 +1,29 @@
package app.seb3thehacker.gearslip.ui
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
private enum class Screen { HOME, LOGS, SETTINGS, CAR_PREVIEW }
/** Three screens and a back stack of depth one: no navigation library needed. */
@Composable
fun GearslipApp(onDisconnect: () -> Unit) {
var screen by rememberSaveable { mutableStateOf(Screen.HOME) }
BackHandler(enabled = screen != Screen.HOME) { screen = Screen.HOME }
when (screen) {
Screen.HOME -> HomeScreen(
onOpenLogs = { screen = Screen.LOGS },
onOpenSettings = { screen = Screen.SETTINGS },
onOpenCarPreview = { screen = Screen.CAR_PREVIEW },
onDisconnect = onDisconnect,
)
Screen.LOGS -> LogsScreen(onBack = { screen = Screen.HOME })
Screen.SETTINGS -> SettingsScreen(onBack = { screen = Screen.HOME })
Screen.CAR_PREVIEW -> CarPreviewScreen(onBack = { screen = Screen.HOME })
}
}
@@ -0,0 +1,16 @@
package app.seb3thehacker.gearslip.ui
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
/** Material You: the palette comes from the wallpaper (minSdk 35, so dynamic color always exists). */
@Composable
fun GearslipTheme(content: @Composable () -> Unit) {
val context = LocalContext.current
val scheme = if (isSystemInDarkTheme()) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
MaterialTheme(colorScheme = scheme, content = content)
}
@@ -0,0 +1,172 @@
package app.seb3thehacker.gearslip.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.seb3thehacker.gearslip.AppSettings
import app.seb3thehacker.gearslip.SessionStatus
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Composable
fun HomeScreen(
onOpenLogs: () -> Unit,
onOpenSettings: () -> Unit,
onOpenCarPreview: () -> Unit,
onDisconnect: () -> Unit,
) {
val status by SessionStatus.state.collectAsStateWithLifecycle()
Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding ->
Column(
Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 24.dp),
) {
Column(
Modifier
.weight(1f)
.verticalScroll(rememberScrollState()),
) {
Spacer(Modifier.height(24.dp))
Text(
"Gearslip",
style = MaterialTheme.typography.displayLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground,
)
Spacer(Modifier.height(16.dp))
StatusCard(status)
Spacer(Modifier.height(24.dp))
SetupSummary()
Spacer(Modifier.height(16.dp))
}
// Actions sit at the bottom, where a thumb reaches them.
Column(
Modifier.padding(vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
val live = status.phase == SessionStatus.Phase.CONNECTING ||
status.phase == SessionStatus.Phase.PROJECTING
if (live) {
Button(onClick = onDisconnect, modifier = Modifier.fillMaxWidth().height(56.dp)) {
Text("Disconnect")
}
}
if (!live) {
FilledTonalButton(
onClick = onOpenCarPreview,
modifier = Modifier.fillMaxWidth().height(56.dp),
) {
Text("Car preview")
}
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
FilledTonalButton(onClick = onOpenLogs, modifier = Modifier.weight(1f).height(56.dp)) {
Text("Live logs")
}
OutlinedButton(onClick = onOpenSettings, modifier = Modifier.weight(1f).height(56.dp)) {
Text("Settings")
}
}
}
}
}
}
@Composable
private fun StatusCard(status: SessionStatus.Snapshot) {
val scheme = MaterialTheme.colorScheme
val (container, content) = when (status.phase) {
SessionStatus.Phase.PROJECTING -> scheme.primaryContainer to scheme.onPrimaryContainer
SessionStatus.Phase.CONNECTING -> scheme.secondaryContainer to scheme.onSecondaryContainer
SessionStatus.Phase.FAILED -> scheme.errorContainer to scheme.onErrorContainer
SessionStatus.Phase.IDLE, SessionStatus.Phase.DISCONNECTED ->
scheme.surfaceContainerHigh to scheme.onSurface
}
Surface(color = container, contentColor = content, shape = MaterialTheme.shapes.extraLarge) {
Row(
Modifier
.fillMaxWidth()
.padding(20.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
if (status.phase == SessionStatus.Phase.CONNECTING) {
CircularProgressIndicator(Modifier.size(28.dp), strokeWidth = 3.dp, color = content)
} else {
Surface(
modifier = Modifier.size(14.dp),
shape = MaterialTheme.shapes.extraLarge,
color = if (status.phase == SessionStatus.Phase.IDLE ||
status.phase == SessionStatus.Phase.DISCONNECTED
) content.copy(alpha = 0.4f) else content,
) {}
}
Column {
Text(status.headline, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(4.dp))
Text(status.detail, style = MaterialTheme.typography.bodyMedium)
}
}
}
}
/** A read-only recap of what a connection will use, so setup problems are visible before the car. */
@Composable
private fun SetupSummary() {
val context = LocalContext.current
val cert by produceState<CertSummary?>(null) {
value = withContext(Dispatchers.Default) { CertSummary.read(context) }
}
val startup = AppSettings.startupUrl(context)
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
SummaryRow("Certificate", cert?.headline ?: "Checking…")
SummaryRow("Car screen shows", startup.ifEmpty { "Built-in test page" })
}
}
@Composable
private fun SummaryRow(label: String, value: String) {
Column {
Text(label, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
value,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
@@ -0,0 +1,141 @@
package app.seb3thehacker.gearslip.ui
import android.content.Intent
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.seb3thehacker.gearslip.GearslipLog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LogsScreen(onBack: () -> Unit) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val version by GearslipLog.version.collectAsStateWithLifecycle()
val lines = remember(version) { GearslipLog.snapshot() }
val listState = rememberLazyListState()
val atEnd by remember {
derivedStateOf {
val info = listState.layoutInfo
val last = info.visibleItemsInfo.lastOrNull()
last == null || last.index >= info.totalItemsCount - 1
}
}
// Live view: stay pinned to the newest line, but let go the moment the user scrolls up to
// read something, and pick back up when they return to the bottom.
var follow by remember { mutableStateOf(true) }
LaunchedEffect(listState) {
snapshotFlow { listState.isScrollInProgress to atEnd }.collect { (scrolling, end) ->
if (scrolling) follow = end
}
}
LaunchedEffect(lines.size, version) {
if (follow && lines.isNotEmpty()) listState.scrollToItem(lines.size - 1)
}
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
topBar = {
TopAppBar(
title = { Text("Live logs") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
TextButton(onClick = {
scope.launch {
val text = withContext(Dispatchers.IO) { GearslipLog.fullText() }
val send = Intent(Intent.ACTION_SEND)
.setType("text/plain")
.putExtra(Intent.EXTRA_SUBJECT, "Gearslip log")
.putExtra(Intent.EXTRA_TEXT, text)
context.startActivity(Intent.createChooser(send, "Share log"))
}
}) { Text("Share") }
TextButton(onClick = { GearslipLog.clearLive() }) { Text("Clear") }
},
)
},
floatingActionButton = {
if (!follow && lines.isNotEmpty()) {
ExtendedFloatingActionButton(
onClick = {
follow = true
scope.launch { listState.scrollToItem(lines.size - 1) }
},
) { Text("Jump to latest") }
}
},
) { padding ->
if (lines.isEmpty()) {
Text(
"Nothing logged yet. Lines appear here live while a session runs.",
modifier = Modifier.padding(padding).padding(24.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
SelectionContainer {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
) {
itemsIndexed(lines) { _, line ->
Text(
line,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
lineHeight = 15.sp,
color = colorFor(line),
)
}
}
}
}
}
}
/** Lines look like "HH:mm:ss.SSS L message"; the level letter sits at index 13. */
@Composable
private fun colorFor(line: String): Color = when (line.getOrNull(13)) {
'E' -> MaterialTheme.colorScheme.error
'W' -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.onSurface
}
@@ -0,0 +1,224 @@
package app.seb3thehacker.gearslip.ui
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.AppSettings
import app.seb3thehacker.gearslip.CertProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(onBack: () -> Unit) {
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
topBar = {
TopAppBar(
title = { Text("Settings") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
)
},
) { padding ->
Column(
Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 24.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(32.dp),
) {
Spacer(Modifier.height(0.dp))
StartupSection()
CertificateSection()
Spacer(Modifier.height(16.dp))
}
}
}
@Composable
private fun SectionTitle(text: String) {
Text(
text,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
}
@Composable
private fun StartupSection() {
val context = LocalContext.current
var url by remember { mutableStateOf(AppSettings.startupUrl(context)) }
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
SectionTitle("Startup URL")
OutlinedTextField(
value = url,
onValueChange = {
url = it
AppSettings.setStartupUrl(context, it)
},
modifier = Modifier.fillMaxWidth(),
label = { Text("URL") },
placeholder = { Text("https://example.com") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
supportingText = {
Text("Shown on the car screen. Leave empty for the built-in test page. Applies on the next connection.")
},
)
}
}
@Composable
private fun CertificateSection() {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var refresh by remember { mutableIntStateOf(0) }
val summary by produceState<CertSummary?>(null, refresh) {
value = withContext(Dispatchers.Default) { CertSummary.read(context) }
}
var pendingUri by remember { mutableStateOf<Uri?>(null) }
var notice by remember { mutableStateOf<String?>(null) }
val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri != null) pendingUri = uri
}
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
SectionTitle("Certificate")
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.large) {
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
val s = summary
if (s == null) {
Text("Checking…", style = MaterialTheme.typography.bodyMedium)
} else {
Text(s.headline, style = MaterialTheme.typography.bodyLarge)
s.subject?.let { Detail("Subject", it) }
s.issuer?.let { Detail("Issuer", it) }
s.validUntil?.let { Detail("Valid until", it) }
}
}
}
Button(onClick = { picker.launch(arrayOf("*/*")) }, modifier = Modifier.fillMaxWidth().height(52.dp)) {
Text("Import certificate (.p12)")
}
if (summary?.kind == CertProvider.Kind.IMPORTED) {
OutlinedButton(
onClick = {
CertProvider.removeImported(context)
notice = "Removed the imported certificate."
refresh++
},
modifier = Modifier.fillMaxWidth().height(52.dp),
) { Text("Remove imported certificate") }
}
notice?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) }
Text(
"An imported certificate takes priority over one staged over adb. Takes effect on the next connection.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
pendingUri?.let { uri ->
PasswordDialog(
onDismiss = { pendingUri = null },
onConfirm = { password, reportError ->
scope.launch {
val result = withContext(Dispatchers.IO) {
runCatching { CertProvider.importFrom(context, uri, password) }
}
result.onSuccess {
pendingUri = null
notice = "Certificate imported."
refresh++
}.onFailure {
reportError("Could not import: wrong password, or not a PKCS#12 file with a private key.")
}
}
},
)
}
}
@Composable
private fun Detail(label: String, value: String) {
Column {
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, style = MaterialTheme.typography.bodyMedium)
}
}
@Composable
private fun PasswordDialog(onDismiss: () -> Unit, onConfirm: (String, (String) -> Unit) -> Unit) {
var password by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Certificate password") },
text = {
OutlinedTextField(
value = password,
onValueChange = { password = it; error = null },
singleLine = true,
label = { Text("Password") },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
isError = error != null,
supportingText = { Text(error ?: "Leave empty if the file has none.") },
)
},
confirmButton = { TextButton(onClick = { onConfirm(password) { error = it } }) { Text("Import") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:pathData="M0,0h108v108h-108z"
android:fillColor="#0c0c0d"/>
<path
android:pathData="M54,2 A52,52 0 1,1 53.99,2 Z"
android:fillColor="#131415"/>
</vector>
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M 66.69,49.48 L 80.91,52.61 L 82.31,42.81 L 66.92,47.91 Z">
<aapt:attr name="android:fillColor">
<gradient
android:type="linear"
android:startX="66.80"
android:startY="48.70"
android:endX="81.61"
android:endY="47.71">
<item android:offset="0" android:color="#00E5484D"/>
<item android:offset="1" android:color="#E5484D"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M 54.00,36.18 L 58.64,30.70 L 67.20,34.24 L 66.60,41.40 L 71.82,54.00 L 77.30,58.64 L 73.76,67.20 L 66.60,66.60 L 66.60,66.60 L 67.20,73.76 L 58.64,77.30 L 54.00,71.82 L 54.00,71.82 L 49.36,77.30 L 40.80,73.76 L 41.40,66.60 L 41.40,66.60 L 34.24,67.20 L 30.70,58.64 L 36.18,54.00 L 36.18,54.00 L 30.70,49.36 L 34.24,40.80 L 41.40,41.40 L 41.40,41.40 L 40.80,34.24 L 49.36,30.70 L 54.00,36.18 Z"
android:fillColor="#C9CDD2"/>
<path
android:pathData="M 78.61,40.70 L 85.31,43.29 L 84.74,52.55 L 77.78,54.31 Z"
android:fillColor="#E5484D"/>
<path
android:pathData="M54,46.08 A7.92,7.92 0 1,1 53.99,46.08 Z"
android:fillColor="#0c0c0d"/>
</vector>
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:pathData="M 66.69,49.48 L 80.91,52.61 L 82.31,42.81 L 66.92,47.91 Z"
android:fillColor="#FF000000"
android:fillAlpha="0.35"/>
<path
android:pathData="M 54.00,36.18 L 58.64,30.70 L 67.20,34.24 L 66.60,41.40 L 71.82,54.00 L 77.30,58.64 L 73.76,67.20 L 66.60,66.60 L 66.60,66.60 L 67.20,73.76 L 58.64,77.30 L 54.00,71.82 L 54.00,71.82 L 49.36,77.30 L 40.80,73.76 L 41.40,66.60 L 41.40,66.60 L 34.24,67.20 L 30.70,58.64 L 36.18,54.00 L 36.18,54.00 L 30.70,49.36 L 34.24,40.80 L 41.40,41.40 L 41.40,41.40 L 40.80,34.24 L 49.36,30.70 L 54.00,36.18 Z"
android:fillColor="#FF000000"/>
<path
android:pathData="M 78.61,40.70 L 85.31,43.29 L 84.74,52.55 L 77.78,54.31 Z"
android:fillColor="#FF000000"/>
<path
android:pathData="M54,46.08 A7.92,7.92 0 1,1 53.99,46.08 Z"
android:fillColor="#00000000"/>
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
</adaptive-icon>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="touch_relay_description">Lets Gearslip replay touches from the car screen onto the phone while mirroring. Gearslip does not read screen content.</string>
</resources>
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--
Material You: DynamicColors.DayNight derives the whole scheme from the device
wallpaper on Android 12+, and falls back to Material3's static baseline scheme on
older versions or when DynamicColors.applyToActivityIfAvailable() is skipped.
-->
<style name="Theme.Gearslip" parent="Theme.Material3.DynamicColors.DayNight">
<item name="android:windowNoTitle">true</item>
<item name="windowActionBar">false</item>
</style>
</resources>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Must match the strings the head unit sends during the AOAP switch. aasdk pins
src/USB/AccessoryModeQueryFactory.cpp:43-51 as manufacturer "Android", model "Android Auto" -
confirmed against the real 2018 Uconnect. Google's own desktop-head-unit reference tool
reports a different model string ("Android Open Automotive Protocol", confirmed via logcat
on 2026-09-17), so both are listed here. `version` is deliberately omitted: head units differ
on what they send, and a mismatch fails the filter silently.
-->
<resources>
<usb-accessory manufacturer="Android" model="Android Auto" />
<usb-accessory manufacturer="Android" model="Android Open Automotive Protocol" />
</resources>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Gearslip never reads screen content: it only needs canPerformGestures, which is what lets
dispatchGesture replay a head-unit touch onto whatever app is mirrored. No event types are
requested, so the service is notified of nothing.
-->
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityFeedbackType="feedbackGeneric"
android:canPerformGestures="true"
android:description="@string/touch_relay_description"
android:notificationTimeout="0" />
@@ -0,0 +1,306 @@
package app.seb3thehacker.gearslip
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Runs the two riskiest pieces - the hand-written X.509 encoder and the SSLEngine wiring -
* on the local JVM, so a defect surfaces here rather than as a mystery failure in a car.
*
* The JVM uses SunJSSE where the phone uses Conscrypt, so this validates the DER and the
* engine logic, not the exact provider behaviour. The on-device "Self-test" button remains
* the authoritative bench check.
*/
class CryptoTest {
@Test
fun `generates a parseable self-signed certificate`() {
val identity = SelfSignedCert.generate(commonName = "TestCN", organisation = "TestO")
val cert = identity.certificate
// X500Principal.getName() is RFC 2253, which lists RDNs in reverse order.
assertEquals("O=TestO,CN=TestCN", cert.subjectX500Principal.name)
assertEquals(cert.subjectX500Principal, cert.issuerX500Principal)
assertEquals(3, cert.version)
assertEquals("SHA256withRSA", cert.sigAlgName)
assertTrue("serial must be positive", cert.serialNumber.signum() > 0)
cert.checkValidity()
cert.verify(cert.publicKey)
val key = identity.keyStore.getKey(SelfSignedCert.ALIAS, SelfSignedCert.PASSWORD)
assertEquals("RSA", key.algorithm)
}
@Test
fun `completes a TLS handshake as the server and round-trips data`() {
val ss = SelfSignedCert.generate()
val identity = CertProvider.Identity(
ss.keyStore, ss.certificate, SelfSignedCert.PASSWORD, "test",
)
assertTrue(TlsSelfTest.run(identity))
}
@Test
fun `frames round-trip through the parser`() {
val payload = ByteArray(300) { it.toByte() }
val parser = Frames.Parser()
val frame = Frames.build(Frames.CHANNEL_CONTROL, encrypted = false, payload = payload)
// Split mid-frame: a bulk transfer boundary must not break parsing.
parser.append(frame, 7)
assertEquals(null, parser.next())
parser.append(frame.copyOfRange(7, frame.size), frame.size - 7)
val parsed = parser.next()!!
assertEquals(Frames.CHANNEL_CONTROL, parsed.channel)
assertEquals(Frames.TYPE_BULK, parsed.frameType)
assertEquals(false, parsed.encrypted)
assertTrue(payload.contentEquals(parsed.payload))
}
@Test
fun `reads negative AuthResponse status values`() {
// STATUS_CERTIFICATE_ERROR = -2 encodes as a 10-byte varint.
val encoded = Protobuf.varintField(1, -2L)
assertEquals(11, encoded.size)
assertEquals(-2, Protobuf.readInt32Field(encoded, 1))
assertEquals(0, Protobuf.readInt32Field(Protobuf.varintField(1, 0L), 1))
assertEquals(-3, Protobuf.readInt32Field(Protobuf.varintField(1, -3L), 1))
}
// --- Phase A: service discovery parsing ---------------------------------------------
private fun lenField(number: Int, body: ByteArray): ByteArray =
Protobuf.varint(((number shl 3) or 2).toLong()) +
Protobuf.varint(body.size.toLong()) + body
/**
* Rebuilds the exact shape the 2018 Uconnect sent (docs/spike/car-run-2026-09-16-run2.log)
* and checks we read it back correctly: video on channel 1, H264_BP, 1280x720 and 800x480
* both at 30fps.
*/
@Test
fun `extracts the video service as the Uconnect described it`() {
val config720 = Protobuf.varintField(1, 2) + Protobuf.varintField(2, 2) +
Protobuf.varintField(3, 0) + Protobuf.varintField(4, 12) +
Protobuf.varintField(5, 240) + Protobuf.varintField(6, 0) +
Protobuf.varintField(8, 10000)
val config480 = Protobuf.varintField(1, 1) + Protobuf.varintField(2, 2) +
Protobuf.varintField(3, 0) + Protobuf.varintField(4, 30) +
Protobuf.varintField(5, 160) + Protobuf.varintField(6, 0) +
Protobuf.varintField(8, 10000)
val mediaSink = Protobuf.varintField(1, 3) + // available_type = H264_BP
lenField(4, config720) + lenField(4, config480) // video_configs
val videoService = Protobuf.varintField(1, 1) + lenField(3, mediaSink)
// An audio sink (no video_configs) must not be mistaken for the video service.
val audioSink = Protobuf.varintField(1, 1) + lenField(3, Protobuf.varintField(1, 48000))
val audioService = Protobuf.varintField(1, 5) + lenField(3, audioSink)
val response = lenField(1, audioService) + lenField(1, videoService)
val video = ServiceDiscovery.findVideoService(response)!!
assertEquals(1, video.serviceId)
assertEquals("VIDEO_H264_BP", video.codecName)
assertEquals(2, video.configs.size)
assertEquals("1280x720", video.configs[0].resolutionName)
assertEquals("30fps", video.configs[0].frameRateName)
assertEquals(240, video.configs[0].density)
assertEquals("800x480", video.configs[1].resolutionName)
assertEquals("30fps", video.configs[1].frameRateName)
}
@Test
fun `media message ids above 32767 survive the 2-byte encoding`() {
// SETUP = 32768 sets the high bit; a signed-byte slip here would corrupt every
// video-channel message.
val id = 32768
val hi = ((id shr 8) and 0xFF).toByte()
val lo = (id and 0xFF).toByte()
val decoded = ((hi.toInt() and 0xFF) shl 8) or (lo.toInt() and 0xFF)
assertEquals(32768, decoded)
}
/**
* Regression: channel-open messages must carry MessageType::CONTROL even on a service
* channel. Sending them as SPECIFIC made the 2018 Uconnect drop the USB link ~160ms
* later with no protocol-level error at all - invisible in code, fatal on the wire.
*/
@Test
fun `channel-open frames carry the CONTROL message type bit`() {
val control = Frames.build(1, encrypted = false, payload = byteArrayOf(0, 7),
messageType = Frames.MESSAGE_CONTROL)
assertEquals(Frames.MESSAGE_CONTROL, control[1].toInt() and Frames.MESSAGE_CONTROL)
val specific = Frames.build(1, encrypted = false, payload = byteArrayOf(0x80.toByte(), 0))
assertEquals(0, specific[1].toInt() and Frames.MESSAGE_CONTROL)
val parser = Frames.Parser()
parser.append(control, control.size)
assertEquals(Frames.MESSAGE_CONTROL, parser.next()!!.messageType)
parser.append(specific, specific.size)
assertEquals(0, parser.next()!!.messageType)
}
@Test
fun `maps resolution enums to pixel sizes and prefers the smallest`() {
val small = ServiceDiscovery.VideoConfig(resolution = 1, frameRate = 2, density = 160)
val large = ServiceDiscovery.VideoConfig(resolution = 2, frameRate = 2, density = 240)
assertEquals(800 to 480, small.pixelSize())
assertEquals(1280 to 720, large.pixelSize())
assertTrue(small.pixelCount() < large.pixelCount())
}
/** The Data payload is an 8-byte big-endian timestamp followed by the access unit. */
@Test
fun `video frame timestamp is 8 bytes big-endian`() {
val pts = 0x0102030405060708L
val stamp = ByteArray(8)
for (i in 0 until 8) stamp[i] = ((pts shr ((7 - i) * 8)) and 0xFF).toByte()
assertEquals(0x01.toByte(), stamp[0])
assertEquals(0x08.toByte(), stamp[7])
var rebuilt = 0L
for (b in stamp) rebuilt = (rebuilt shl 8) or (b.toLong() and 0xFF)
assertEquals(pts, rebuilt)
}
/** Matches the Uconnect's input service: id 2, touchscreen 1258x708. */
@Test
fun `extracts the input service and touchscreen size`() {
val touchScreen = Protobuf.varintField(1, 1258) + Protobuf.varintField(2, 708) +
Protobuf.varintField(3, 1)
val inputSource = lenField(1, Protobuf.varintField(1, 42)) + // keycodes, ignored
lenField(2, touchScreen)
val inputService = Protobuf.varintField(1, 2) + lenField(4, inputSource)
// A video service must not be mistaken for the input service.
val videoService = Protobuf.varintField(1, 1) +
lenField(3, Protobuf.varintField(1, 3) + lenField(4, Protobuf.varintField(1, 1)))
val response = lenField(1, videoService) + lenField(1, inputService)
val input = ServiceDiscovery.findInputService(response)!!
assertEquals(2, input.serviceId)
assertEquals(1258, input.touchWidth)
assertEquals(708, input.touchHeight)
}
/**
* Regression: touch arrives already in projected video coordinates, NOT in the head
* unit's advertised touchscreen space. An earlier version scaled by video/touchscreen,
* which compressed the right-hand third of the screen away - a tap on a right-edge
* button landed on the middle one (confirmed on the Uconnect: 800x480 projected, panel
* advertised 1258x708, raw x never exceeded 791).
*
* Also pins the action mapping: Android Auto's PointerAction values equal Android's
* MotionEvent actions, so no translation table is needed.
*/
@Test
fun `touch coordinates are used as-is and clamped to the video bounds`() {
fun clamp(value: Int, limit: Int?): Float = when {
limit == null -> value.toFloat()
value < 0 -> 0f
value >= limit -> (limit - 1).toFloat()
else -> value.toFloat()
}
// A right-edge tap must stay at the right edge, not be scaled inward.
assertEquals(791f, clamp(791, 800))
assertEquals(216f, clamp(216, 800))
assertEquals(0f, clamp(-5, 800))
assertEquals(799f, clamp(1258, 800))
assertEquals(479f, clamp(708, 480))
assertEquals(351f, clamp(351, 480))
assertEquals(0, android.view.MotionEvent.ACTION_DOWN)
assertEquals(1, android.view.MotionEvent.ACTION_UP)
assertEquals(2, android.view.MotionEvent.ACTION_MOVE)
}
/**
* Regression: a video frame over 16 KB used to throw out of Frames.build and kill the
* encoder loop, freezing the head unit picture. Anything busier than a test card exceeds
* that routinely, so large messages must split into FIRST/MIDDLE/LAST and reassemble.
*/
@Test
fun `oversized messages split and reassemble`() {
val payload = ByteArray(Frames.MAX_FRAME_PAYLOAD * 2 + 500) { (it % 251).toByte() }
val frames = mutableListOf<ByteArray>()
var offset = 0
while (offset < payload.size) {
val size = minOf(Frames.MAX_FRAME_PAYLOAD, payload.size - offset)
val chunk = payload.copyOfRange(offset, offset + size)
val frameType = when {
offset == 0 -> Frames.TYPE_FIRST
offset + size >= payload.size -> Frames.TYPE_LAST
else -> Frames.TYPE_MIDDLE
}
frames += Frames.build(
7, encrypted = false, payload = chunk, messageType = Frames.MESSAGE_SPECIFIC,
frameType = frameType,
totalSize = if (frameType == Frames.TYPE_FIRST) payload.size else null,
)
offset += size
}
assertEquals(3, frames.size)
// The FIRST frame carries the 4-byte total length; the others use the short header.
assertEquals(4 + 4 + Frames.MAX_FRAME_PAYLOAD, frames[0].size)
assertEquals(4 + Frames.MAX_FRAME_PAYLOAD, frames[1].size)
val parser = Frames.Parser()
val assembler = Frames.Assembler()
var rebuilt: ByteArray? = null
for (frame in frames) {
parser.append(frame, frame.size)
val parsed = parser.next()!!
assembler.offer(parsed, parsed.payload)?.let { rebuilt = it }
}
assertTrue(payload.contentEquals(rebuilt!!))
}
/**
* Regression: a single resync request was not enough. The requested keyframe is itself
* just another frame, so it can be dropped by the same flow-control check that provoked
* the request, and the old logic (request once, wait for a keyframe to clear the flag)
* never asked again - leaving the picture permanently corrupted for the rest of the
* session. Confirmed on the road: docs/spike/phase-c-stuck-resync-2026-09-17.log shows
* dropped=27 in one burst, then 0.0 fps for over 100 seconds with no recovery.
*
* Model of the fixed decision: ask again on every drop, not just the first, and only
* stop asking once a keyframe actually goes out (not merely once requested).
*/
@Test
fun `resync keeps retrying if the requested keyframe is itself dropped`() {
var requests = 0
var resyncRequested = false
fun onDrop() {
resyncRequested = true
requests++ // old, buggy behaviour would gate this behind `if (!resyncRequested)`
}
fun onSent(keyFrame: Boolean) {
if (keyFrame) resyncRequested = false
}
// Burst: drop, drop (this "keyframe attempt" also dropped), drop again, then finally
// a keyframe gets through once the window clears.
onDrop()
assertEquals(1, requests)
onDrop() // the resync keyframe we asked for got dropped too
assertEquals(2, requests) // must ask again - this is what the old code failed to do
onDrop()
assertEquals(3, requests)
onSent(keyFrame = true) // window finally clears, a keyframe gets out
assertTrue(!resyncRequested)
// A later drop must be able to trigger a fresh request cycle.
onDrop()
assertEquals(4, requests)
}
}
@@ -0,0 +1,41 @@
package app.seb3thehacker.gearslip
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class VehicleProfileTest {
private val challenger = ServiceDiscovery.HeadUnitInfo(
headUnitName = "Uconnect", carModel = "Dodge Challenger", carYear = "18",
headUnitMake = "Delphi", headUnitModel = "VP2_R Uconnect 7",
)
private fun profile(name: String, vararg match: Pair<String, String>) =
VehicleProfile(name, match.toMap(), resolution = null, insets = Insets.NONE)
@Test
fun matchesWhenEveryKeyEqualsIgnoringCase() {
val p = profile("challenger", "carModel" to "dodge challenger", "headUnitName" to "UCONNECT")
assertEquals(p, VehicleProfiles.find(listOf(p), challenger))
}
@Test
fun aDifferentCarDoesNotMatch() {
val p = profile("challenger", "carModel" to "Dodge Challenger")
val honda = ServiceDiscovery.HeadUnitInfo("Honda", "Accord", "25", "", "")
assertNull(VehicleProfiles.find(listOf(p), honda))
}
@Test
fun aProfileWithNoMatchKeysNeverMatchesEverything() {
assertNull(VehicleProfiles.find(listOf(profile("wildcard")), challenger))
}
@Test
fun firstMatchingProfileWins() {
val specific = profile("specific", "carYear" to "18")
val loose = profile("loose", "headUnitName" to "Uconnect")
assertEquals(specific, VehicleProfiles.find(listOf(specific, loose), challenger))
}
}
+24
View File
@@ -0,0 +1,24 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
android.dependency.useConstraints=false
+13
View File
@@ -0,0 +1,13 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/56a19bc915b9ba2eb62ba7554c61b919/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/398ffe3949748bfb1d5636f023d228fd/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/56a19bc915b9ba2eb62ba7554c61b919/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/398ffe3949748bfb1d5636f023d228fd/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/e99bae143b75f9a10ead10248f02055e/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/04e088f8677de3b384108493cc9481d0/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/56a19bc915b9ba2eb62ba7554c61b919/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/398ffe3949748bfb1d5636f023d228fd/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/e55dccbfe27cb97945148c61a39c89c5/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/dbd05c4936d573642f94cd149e1356c8/redirect
toolchainVendor=JETBRAINS
toolchainVersion=21
+58
View File
@@ -0,0 +1,58 @@
[versions]
agp = "9.1.1"
kotlin = "2.4.0"
coreKtx = "1.19.0"
junit = "4.13.2"
junitVersion = "1.3.0"
espressoCore = "3.7.0"
lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.13.0"
composeBom = "2026.05.01"
carApp = "1.7.0"
appcompat = "1.7.1"
material = "1.14.0"
constraintlayout = "2.2.1"
zxing = "3.5.4"
okhttp = "5.4.0"
kotlinxSerializationJson = "1.11.0"
ossLicenses = "17.5.1"
webkit = "1.16.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-car-app = { group = "androidx.car.app", name = "app", version.ref = "carApp" }
androidx-car-app-testing = { group = "androidx.car.app", name = "app-testing", version.ref = "carApp" }
# Only for the ICarHardwareHost AIDL stubs, which the host half has to implement but which
# live in the projected artifact rather than the core one.
androidx-car-app-projected = { group = "androidx.car.app", name = "app-projected", version.ref = "carApp" }
androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityCompose" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
google-material = { group = "com.google.android.material", name = "material", version.ref = "material" }
zxing-core = { group = "com.google.zxing", name = "core", version.ref = "zxing" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
google-oss-licenses = { group = "com.google.android.gms", name = "play-services-oss-licenses", version.ref = "ossLicenses" }
androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
Binary file not shown.
+6
View File
@@ -0,0 +1,6 @@
#Sun Sep 28 18:17:20 JST 2025
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"
Vendored
+89
View File
@@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+28
View File
@@ -0,0 +1,28 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
maven("https://jitpack.io")
}
}
rootProject.name = "Gearslip"
include(":gearslip")