9 Commits
Author SHA1 Message Date
Seb3thehacker 5f9701ccff Release 0.0.05-alpha: guided setup, downloadable certificate, car phone app, media rework
- First run now asks for permissions one at a time, each on its own screen with an Allow or
  Skip, instead of firing every system dialog at once from a cold start
- The head unit certificate can be downloaded straight from the opencardev/aasdk repository
  with one tap, on top of the existing adb-staged and file-import paths; Settings shows which
  source is active and lets an imported or downloaded certificate be removed
- A phone app on the car screen: a keypad and a searchable contacts list, with a confirmation
  card before every call
- The web browser draws its own on-screen keyboard for a page's text fields, so a search box or
  address bar can be typed into from the car screen
- Media player rework: a dedicated Now Playing tab with a bigger, screen-size-aware layout, an
  Up Next tab showing the app's real queue, and Browse/Lyrics tabs that share the same rail
- Waze and Google Messages are marked broken instead of pretending to work; Google's own dialer,
  Assistant, and Android Auto entries are hidden from the car launcher entirely; Aardvark and
  Vivi Music are marked tested and working
- A help screen replaces the old wall of setup text on the home screen
- The display-calibration screen is gone - it did its job
2026-09-26 16:53:28 -07:00
Seb3thehacker 043dc11251 Add phone call handling on the car screen
Incoming calls now show the caller (name via contacts, else number) on
the car UI with answer/decline, using a call screening service so the
car sees a call before it rings. Also adds pinnable nav bar shortcuts,
lowers minSdk to 31 for wider device support, and bumps to 0.0.04-pre.
2026-09-26 11:43:09 -07:00
Seb3thehackerandClaude Sonnet 5 48be745659 README: explain the screen-share indicator for audio
Gearslip captures playback, not the screen, but Android's consent
dialog and persistent indicator look identical either way. Note why
the indicator appears, and correct the adjacent no-adb claim to
mention the two optional audio commands as the sole exception.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 00:18:39 -07:00
Seb3thehackerandClaude Sonnet 5 7e4b4d6b99 Send media audio to the car without a per-drive prompt
The head unit offers a media sink, but Android answers Setup with an
empty reply instead of a Config message. AudioLink now falls back to
the advertised sink after 2.5s. AudioCaptureService captures by audio
usage, not by app, so switching media apps costs nothing further and
one grant covers a whole drive.

A one-time adb command (PROJECT_MEDIA) removes the consent dialog
itself; the setup screen now includes it as a copyable step, next to
the existing mic-permission step.

Capture counts as a screen share, so Android 15 hides notification
contents while it runs. ScreenShareGuard lifts that protection for
the length of the capture, with a second adb grant
(WRITE_SECURE_SETTINGS), and restores it afterward, including after a
crash.

The phone keeps playing what it captures, so AudioCaptureService also
mutes STREAM_MUSIC for the drive and restores it on every exit path.

Adds a debug bench (BenchAudioSink, CarPreviewActivity --ez audio
true) for testing the audio path without a car, and a unit test that
rebuilds the real head unit's ServiceDiscoveryResponse from a logged
session to confirm the media and guidance sinks are found correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 00:11:59 -07:00
Seb3thehacker a8b49a3672 Release v0.0.03-pre: weather, template cards, media fallbacks, setup steps
- Weather from Open-Meteo with a place name, a forecast screen and a nav bar icon; a fresh
  location fix is requested when the last one is stale
- Map controls are solid, one size, and fade out after four seconds; pinch to zoom works
  because every finger now reaches the app
- Map templates draw their content in a floating card; toggle and radio rows show their state,
  the Back and Pan actions work, and details text and buttons are larger
- Home player gains Lyrics and Minimise buttons; minimised, it lives in the nav bar
- Media apps that hide their library show what they are and can still be controlled; apps that
  refuse Gearslip as a host say why
- Debug mode switch, session report in the log, and log sharing as a file attachment
- Launcher, phone apps, weather and calendar load at startup
- Web app opens DuckDuckGo by default
- Home screen lists the setup steps; the launcher marks apps tested and working
2026-09-21 01:14:49 -07:00
Seb3thehacker 94f8f23a56 updated broken links 2026-09-20 14:27:43 -07:00
Seb3thehacker e1732f8365 initial standards implemented + new docs like how to build apps 2026-09-20 13:40:32 -07:00
Seb3thehacker cb895f0658 Release v0.0.02-pre: car dashboard, nav bar rework, cached launcher
- Nav bar is icon-only and shorter, with a now-playing pill on non-home screens
- Clock opens a calendar and weather dashboard (adds READ_CALENDAR)
- Web page URL is now set from the car's Settings screen
- Launcher app scan and weather are cached
- Home player gains skip buttons and a larger cover
2026-09-20 03:00:25 -07:00
Seb3thehacker 5b7b9499ca Gearslip: phone-side Android Auto client 2026-09-20 02:09:47 -07:00
123 changed files with 17869 additions and 6 deletions

No files matched your search

+42
View File
@@ -0,0 +1,42 @@
name: Release
on:
push:
tags:
- "v*"
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- name: Write signing key and properties
run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > gearslip-release.jks
cat > keystore.properties <<EOF
storeFile=gearslip-release.jks
storePassword=${{ secrets.KEYSTORE_STORE_PASSWORD }}
keyAlias=${{ secrets.KEYSTORE_KEY_ALIAS }}
keyPassword=${{ secrets.KEYSTORE_KEY_PASSWORD }}
EOF
- name: Build release APK
run: ./gradlew :gearslip:assembleRelease
- name: Locate APK
id: apk
run: |
apk=$(find gearslip/build/outputs/apk/release -name "*.apk" | head -n1)
echo "path=$apk" >> "$GITHUB_OUTPUT"
- name: Create release
uses: softprops/action-gh-release@v2
with:
files: ${{ steps.apk.outputs.path }}
generate_release_notes: true
+27
View File
@@ -0,0 +1,27 @@
*.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
# The release signing key and its passwords. Losing these breaks app updates; leaking them
# lets anyone sign an APK as Gearslip.
*.jks
*.keystore
keystore.properties
# Local investigation script; not meant for the public repo.
/docs/cert-sweep.sh
archive/
CLAUDE.md
*.pdf
extraction/
.claude/
+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.
+63
View File
@@ -0,0 +1,63 @@
<img src="docs/icon/gearslip-icon.png" alt="Gearslip icon" width="96" height="96">
# Gearslip
Bring any app to your car screen.
## What Gearslip is
Gearslip is an Android Auto client for the phone. It speaks the Android Auto protocol
directly to a car head unit over USB, with no Google software in the path. Gearslip acts
as a Car App Library host: an app that ships a car interface hands Gearslip a template,
and Gearslip renders that template natively on the car screen. Nothing is mirrored from
the phone. For apps without a car interface, Gearslip offers a separate screen-sharing
mode.
The author reverse-engineered the protocol for interoperability, so that a client other
than Google's own can talk to a car head unit.
## Status
- **Templated apps work.** Gearslip renders all 16 template types that the Car App
Library defines, and it draws its own keyboard for search and sign-in. It has been
tested against several real navigation and media apps.
- **Media playback works.** Gearslip lets you browse and control car-enabled media apps
from the car screen, with lyrics and playback state, over the same host connection.
- **Audio reaches the car by capturing playback, not by mirroring the screen.** Android
requires a screen-share style consent dialog and indicator for this, even though
Gearslip never reads the screen; that grant is the only way a non-privileged app can
tap another app's audio. The consent normally repeats every drive; the app's own setup
steps include two optional adb commands that reduce it to a one-time grant.
- **A real head unit needs a certificate that Google controls.** A head unit accepts only
a phone identity that chains to Google's Automotive Link certificate authority, and
Google alone issues those certificates. Gearslip does not ship one. Without it,
Gearslip runs and renders correctly against a debug harness, but a real car rejects the
connection. A leaked certificate exists that older head units accept; newer firmware
and Google's reference tooling reject it.
- **Gearslip needs no adb, root, or Shizuku.** Every feature relies on an ordinary
Android permission or an on-device consent dialog. The two adb commands mentioned
above, for audio, are the sole exception: both are optional, and both only remove a
dialog that the driver would otherwise see once per drive.
## Building
```
./gradlew :gearslip:assembleDebug
```
The APK lands at `build/Gearslip-debug.apk`.
A release build needs a signing key. Copy `keystore.properties.example` to
`keystore.properties`, fill in your own values, and run
`./gradlew :gearslip:assembleRelease`. Git ignores both the properties file and the
keystore. Without them, the debug build still works.
## Writing apps for Gearslip
[docs/BUILDING_APPS.md](docs/BUILDING_APPS.md) specifies how to write apps that Gearslip
can render: templated apps, media apps, and the Gearslip app protocol.
## License
Gearslip is licensed under AGPL-3.0; see [LICENSE](LICENSE). The author offers a separate
commercial license 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
}
+421
View File
@@ -0,0 +1,421 @@
# Building apps for Gearslip
Gearslip renders three kinds of app on a car screen. This page specifies all three.
| Kind | What it is | Status |
| --- | --- | --- |
| [Templated apps](#templated-apps) | An AndroidX Car App Library app. Gearslip hosts it. | Works today |
| [Media apps](#media-apps) | A `MediaBrowserService` that opted in to Android Auto. | Works today |
| [Gearslip apps](#gearslip-apps) | A native Gearslip screen, described over binder. | Specified here; not yet implemented |
Write a templated app when your app fits the Car App Library's templates, because it runs today
and it also runs on Android Auto. Write a media app when you ship audio. Write a Gearslip app
when you want a screen the Car App Library cannot describe.
Gearslip's package name is `app.seb3thehacker.gearslip`. It declares
`android.car.permission.TEMPLATE_RENDERER`.
---
## Templated apps
A templated app exports a `CarAppService`, describes each screen as a `Template`, and lets the
host draw it. Gearslip implements the host half of that protocol, so an existing Android Auto app
needs no Gearslip-specific code. Build it against the AndroidX Car App Library as you would for
any other host.
### Letting Gearslip in
Your app decides which hosts may render it, through the `HostValidator` your `CarAppService`
returns.
Gearslip declares `android.car.permission.TEMPLATE_RENDERER`, and the library's own
`HostValidator` accepts any host holding it. If you build your validator with
`HostValidator.Builder` and no allowlist, Gearslip already passes, and you need nothing below.
Add an allowlist entry when you wrote a custom validator, or when you want to name Gearslip
explicitly:
```xml
<!-- res/values/arrays.xml -->
<array name="hosts_allowlist">
<item>4cc56ebdd13a54d260eca9b7e2b2ae6b30e2f225bfda8f508f0709edf084948b,app.seb3thehacker.gearslip</item>
</array>
```
```java
@Override
public HostValidator createHostValidator() {
return new HostValidator.Builder(getApplicationContext())
.addAllowedHosts(R.array.hosts_allowlist)
.build();
}
```
That digest belongs to Gearslip's release signing certificate. A Gearslip you built yourself
carries your own debug key and a different digest, so read the fingerprint off the APK you
actually installed when an allowlist entry fails to match.
Write each entry as the digest first, then a comma, then the package name. The digest is the
SHA-256 of the host's signing certificate as 64 lowercase hex characters, with no colons and no
`0x` prefix. The builder lowercases the entry and strips spaces, but it keeps colons, so convert
`keytool` output before pasting it:
```
keytool -list -v -keystore <keystore> -alias <alias> \
| grep 'SHA256:' | cut -d' ' -f3 | tr -d ':' | tr 'A-Z' 'a-z'
```
Reading the installed APK is more reliable, because it fingerprints the key that actually signed
the build, and `apksigner` already prints the required format:
```
apksigner verify --print-certs Gearslip.apk | grep 'SHA-256 digest'
```
During development, `HostValidator.ALLOW_ALL_HOSTS_VALIDATOR` accepts every host. Ship a real
validator instead.
An app that refuses a host returns no binder at all. Gearslip reports that as
`the app refused to bind - host not accepted`. Your own side logs the reason under the tag
`CarApp.Val`, including the exact `addAllowedHost` call to add, so read logcat there first.
### Discovery
Gearslip finds templated apps by querying `androidx.car.app.CarAppService`, then re-querying with
each known category to recover which ones your intent filter matched. Declare the categories you
belong to, or Gearslip files you under `app`:
```xml
<service android:name=".MyCarAppService" android:exported="true">
<intent-filter>
<action android:name="androidx.car.app.CarAppService" />
<category android:name="androidx.car.app.category.NAVIGATION" />
</intent-filter>
</service>
```
Gearslip recognises the `NAVIGATION`, `POI`, `PARKING`, `CHARGING`, `MESSAGING`, `CALLING`,
`SETTINGS`, `IOT` and `WEATHER` categories. Navigation apps start automatically on the home
screen when the driver used them last.
### The handshake
Gearslip offers car API level 8. It negotiates down to
`min(8, yourLatestCarAppApiLevel)` and refuses the app when that falls below your
`minCarAppApiLevel`, rather than drawing it wrongly. Keep your minimum at 8 or below.
### Templates Gearslip renders
Gearslip renders all sixteen templates. Ten describe their own content:
`ListTemplate`, `GridTemplate`, `PaneTemplate`, `MessageTemplate`, `LongMessageTemplate`,
`SearchTemplate`, `SignInTemplate`, `TabTemplate`, `SectionedItemTemplate`,
`MediaPlaybackTemplate`.
Six draw a map underneath and take chrome over the top:
`NavigationTemplate`, `MapWithContentTemplate`, `MapTemplate`, `PlaceListNavigationTemplate`,
`RoutePreviewNavigationTemplate`, `PlaceListMapTemplate`.
`SearchTemplate` and `SignInTemplate` need text. A phone keyboard cannot appear on the car's
virtual display, so Gearslip draws its own and feeds the result back through the template's
listener.
### Content limits
Gearslip reports these limits through the constraint service, and expects you to honour them:
| Limit | Value |
| --- | --- |
| Grid items | 12 |
| Pane rows | 4 |
| Route list rows | 3 |
| Anything else | 12 |
Gearslip enables app-driven refresh, so you may invalidate your own template without the driver
touching the screen.
### Drawing a map
A navigation app draws its own pixels. Gearslip lends it a real `Surface` taken from a
`SurfaceView` inside the car's virtual display, so your map reaches the head unit through the
existing video pipe. Gearslip never reads those pixels back.
Gearslip then reports which part of that surface the driver can see. Treat the visible area as
the region safe for your own controls, and the stable area as the region that never gets
covered. Gearslip keeps its chrome to a top bar, so both areas are the surface minus that bar.
Your app receives four gestures, never raw touches: `onClick`, `onScroll`, `onFling` and
`onScale`. Coordinates are surface pixels. Scroll distances follow `GestureDetector`'s
convention, which reports the previous position minus the current one.
### Car hardware
A phone knows nothing about a car's sensors, so Gearslip answers every car-hardware request with
"unsupported". It answers rather than ignores: an app whose sensor manager waits for a reply
hangs when the host stays silent. Design for a host that reports no vehicle data at all.
Gearslip accepts the suggestions an app pushes and discards them, because no screen shows them
yet. Your app will not fail for offering them.
### Day and night
Gearslip decides whether it is dark outside and pushes that to your app as a configuration
change, with `UI_MODE_NIGHT_YES` or `UI_MODE_NIGHT_NO`. Take your map style from the
configuration the host reports. The configuration also carries the car's density, screen size and
orientation, and always sets `UI_MODE_TYPE_CAR`.
---
## Media apps
Gearslip draws the media UI itself: browsing, artwork, lyrics, transport controls and a
now-playing pill in the nav bar. Your app supplies a browse tree and a media session.
Gearslip lists a media app when it exports a `MediaBrowserService` **and** declares the Android
Auto meta-data. Both halves are required, because the service alone also appears on phone-only
players that expose a browser to Bluetooth and Wear:
```xml
<application>
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
</application>
```
```xml
<!-- res/xml/automotive_app_desc.xml -->
<automotiveApp>
<uses name="media" />
</automotiveApp>
```
Publish playback state, metadata, duration and artwork through your `MediaSession`, and support
play, pause, next, previous and seek. Gearslip reconnects to the app the driver used last and
can start it playing on connection.
---
## Gearslip apps
This section specifies protocol version 1. Gearslip does not yet implement it.
A Gearslip app describes a screen and lets Gearslip render it in Compose, using the car's theme,
the driver's chosen scale and the current day or night state. Your app draws nothing and receives
no surface. Everything the driver sees comes from the model below, which means your screen
matches the rest of the car UI and cannot be made unreadable.
Write a Gearslip app to reach the parts of the car UI the Car App Library does not cover. Write a
templated app if you need to draw a map, because version 1 lends no surface.
### Discovery
Export a service with the Gearslip action and declare the protocol version it speaks:
```xml
<service
android:name=".GearslipService"
android:exported="true">
<intent-filter>
<action android:name="app.seb3thehacker.gearslip.APP" />
</intent-filter>
<meta-data
android:name="app.seb3thehacker.gearslip.version"
android:value="1" />
</service>
```
Gearslip scans for these services once per car session and shows each one as a tile in the
launcher, under your application's own label and icon. To override either on the car screen, add
`app.seb3thehacker.gearslip.label` or `app.seb3thehacker.gearslip.icon`.
Gearslip binds to any app that declares this service. It asks the driver for nothing, exactly as
a head unit asks nothing before running an Android Auto app.
Gearslip binds your service when the driver opens your tile and unbinds it when they leave. Keep
no state that a rebind cannot rebuild.
### The interface
Copy these three files into `src/main/aidl/app/seb3thehacker/gearslip/api/`. A published artifact
will replace them.
```aidl
// IGearslipApp.aidl
package app.seb3thehacker.gearslip.api;
import app.seb3thehacker.gearslip.api.IGearslipHost;
import app.seb3thehacker.gearslip.api.IScreenCallback;
interface IGearslipApp {
/** Return the highest version both sides speak. Gearslip passes the version it offers. */
int negotiate(int hostVersion);
void onCreate(IGearslipHost host);
void onStart();
void onStop();
/** Describe the current screen. Answer through the callback, on any thread. */
void getScreen(IScreenCallback callback);
void onAction(String actionId);
void onTextInput(String fieldId, String text);
}
```
```aidl
// IGearslipHost.aidl
package app.seb3thehacker.gearslip.api;
interface IGearslipHost {
/** Tell Gearslip the screen changed. It calls getScreen again. */
void invalidate();
/** Open the car keyboard. The result arrives at onTextInput. */
void requestTextInput(String fieldId, String prompt, String initialText);
String getEnvironment();
String getNowPlaying();
/** One of play, pause, toggle, next, previous. */
void mediaCommand(String command);
/** Leave the app and return to the launcher. */
void finish();
}
```
```aidl
// IScreenCallback.aidl
package app.seb3thehacker.gearslip.api;
interface IScreenCallback {
void onScreen(String screenJson);
void onError(String message);
}
```
### Versioning
Gearslip calls `negotiate` first, passing the version it offers. Return the highest version you
also speak. Gearslip uses that version for the rest of the connection and drops the app when the
answer exceeds what it offered. This mirrors how Gearslip negotiates car API levels with
templated apps, and it lets either side move first.
Version 1 is the only version. Reject anything you do not recognise by returning 1.
### Lifecycle
1. Gearslip binds your service and calls `negotiate`.
2. Gearslip calls `onCreate`, handing you the host interface. Keep it.
3. Gearslip calls `onStart`, then `getScreen`.
4. You answer through `onScreen`. Gearslip renders it.
5. The driver taps something. Gearslip calls `onAction` with that element's id.
6. Your screen changes, so you call `invalidate`. Gearslip calls `getScreen` again.
7. The driver leaves. Gearslip calls `onStop` and unbinds.
Every call arrives on a binder thread. Answer `getScreen` promptly; Gearslip shows the previous
screen until you do. Gearslip coalesces repeated `invalidate` calls.
### The screen model
Answer `getScreen` with a JSON object.
```json
{
"protocol": 1,
"type": "list",
"title": "Stations",
"items": [
{ "id": "r6", "title": "BBC Radio 6", "subtitle": "Music", "icon": "play" },
{ "id": "r4", "title": "BBC Radio 4", "subtitle": "Speech", "icon": "play" }
],
"actions": [
{ "id": "refresh", "title": "Refresh", "icon": "refresh", "primary": true }
]
}
```
`type` takes one of four values:
| Type | Renders as | Uses |
| --- | --- | --- |
| `list` | A vertical list of rows | `items` |
| `grid` | A grid of tiles | `items` |
| `pane` | A short detail pane beside its actions | `items`, `actions` |
| `message` | A single message and its actions | `text`, `actions` |
An item takes `id`, `title`, and optionally `subtitle`, `icon` and `enabled`. Gearslip calls
`onAction` with the item's `id` when the driver taps it. An item with `"type": "input"` renders
as a text field carrying `value`; tapping it opens the car keyboard, and the result arrives at
`onTextInput` under the item's id.
An action takes `id`, `title`, and optionally `icon` and `primary`. Gearslip renders at most two
actions and emphasises the primary one.
`icon` names a built-in glyph: `play`, `pause`, `next`, `previous`, `search`, `settings`, `home`,
`apps`, `notifications`, `info`, `share`, `place`, `favorite`, `add`, `remove`, `refresh`,
`check`, `error`. Version 1 carries no images. Gearslip drops an unknown name and renders the
row without a glyph.
Gearslip truncates any list past the limits in [Content limits](#content-limits) and shortens
long strings. Send a screen that fits, rather than one Gearslip has to cut.
### Reading the car
`getEnvironment` returns the car's current state:
```json
{
"protocol": 1,
"darkOutside": true,
"moving": false,
"frameWidth": 800,
"frameHeight": 480,
"densityDpi": 160,
"batteryPercent": 82,
"charging": true,
"vehicle": "Uconnect"
}
```
`darkOutside` reports whether Gearslip believes it is dark, from the light sensor or the clock.
`moving` reports whether the phone's GPS shows the car moving. Both change during a session, so
read them each time you build a screen rather than caching them.
Treat `moving` as a comfort signal, not a safety guarantee. It reads false whenever the fix is
stale or missing, which includes tunnels and car parks.
### Controlling media
`getNowPlaying` returns what the car is playing, whichever app owns it:
```json
{ "title": "Sleepwalk", "artist": "Santo & Johnny", "playing": true, "durationMs": 142000 }
```
`mediaCommand` sends one of `play`, `pause`, `toggle`, `next` or `previous` to that app. Both
work regardless of which app is playing, so use them to put transport controls on your own
screen. Ship audio through a media app instead of driving these.
### Text input
A phone keyboard cannot appear on the car's virtual display, so Gearslip draws its own. Call
`requestTextInput` with a field id, a prompt and any existing text. Gearslip opens the keyboard
and calls `onTextInput` as the driver types, so treat each call as the current contents rather
than a final answer.
### What version 1 leaves out
Version 1 lends no surface, so no Gearslip app draws its own pixels. It carries no images beyond
the built-in glyphs. It places apps only in the launcher; contributing a dashboard card or a
home-screen pane comes later. Anything that needs its own rendering belongs in a templated app.
---
## Further reading
- [README.md](../README.md) - what Gearslip is and how to build it.
- [Android for Cars App Library](https://developer.android.com/training/cars/apps) - the
reference for templated apps.
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
+114
View File
@@ -0,0 +1,114 @@
import java.util.Properties
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)
}
// The release signing key, read from an untracked keystore.properties beside this file or at the
// repository root. Third-party apps allowlist Gearslip by the SHA-256 of this certificate, so the
// key must stay the same for the life of the project - and must never be committed.
val keystoreProperties = Properties().apply {
listOf(
rootProject.file("keystore.properties"),
project.file("keystore.properties"),
).firstOrNull { it.exists() }?.inputStream()?.use { load(it) }
}
android {
namespace = "app.seb3thehacker.gearslip"
compileSdk = 37
defaultConfig {
applicationId = "app.seb3thehacker.gearslip"
minSdk = 31
targetSdk = 37
versionCode = 5
versionName = "0.0.05-alpha"
}
signingConfigs {
// Declared only when the properties file is present, so a clone without the key still
// builds debug; assembleRelease then falls back to unsigned rather than failing here.
if (keystoreProperties.getProperty("storeFile") != null) {
create("release") {
storeFile = rootProject.file(keystoreProperties.getProperty("storeFile"))
storePassword = keystoreProperties.getProperty("storePassword")
keyAlias = keystoreProperties.getProperty("keyAlias")
keyPassword = keystoreProperties.getProperty("keyPassword")
}
}
}
buildTypes {
release {
signingConfig = signingConfigs.findByName("release")
}
}
// 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,62 @@
package app.seb3thehacker.gearslip
import app.seb3thehacker.gearslip.audio.CarAudio
import kotlin.math.abs
/**
* Debug-only stand-in for the head unit's media audio sink.
*
* The car end of the audio path can only be exercised in a car, but everything before it - the
* setting, the consent dialog, the capture service, and whether the media app allows itself to be
* heard at all - is phone-side and can be tested at a desk. This sink takes the place of
* [AudioLink] so that path runs to completion, and reports what arrived.
*
* adb shell am start -n app.seb3thehacker.gearslip/.CarPreviewActivity \
* --ei w 800 --ei h 480 --ez audio true --ei snaps 10
*
* A healthy run logs a rising byte count and a peak well above zero. A byte count that rises with
* a peak of zero means the app refused to be captured.
*/
class BenchAudioSink(override val sampleRate: Int = 48_000, override val channels: Int = 2) : CarAudio.Link {
private var chunks = 0
private var bytes = 0L
private var peak = 0
private var reportedAt = 0L
override fun start() {
chunks = 0
bytes = 0
peak = 0
reportedAt = System.currentTimeMillis()
GearslipLog.i("bench sink: started - pretending to be a ${sampleRate}Hz x$channels car sink")
}
override fun stop() {
GearslipLog.i("bench sink: stopped after $chunks chunks / $bytes bytes")
}
override fun write(pcm: ByteArray, size: Int) {
chunks++
bytes += size
for (i in 0 until size - 1 step 2) {
val sample = (pcm[i].toInt() and 0xFF) or (pcm[i + 1].toInt() shl 8)
peak = maxOf(peak, abs(sample.toShort().toInt()))
}
val now = System.currentTimeMillis()
if (now - reportedAt < REPORT_MS) return
reportedAt = now
val seconds = bytes / (sampleRate.toDouble() * channels * 2)
GearslipLog.i(
"bench sink: %d chunks / %d bytes (%.1fs of audio), peak %.3f%s".format(
chunks, bytes, seconds, peak / 32768f,
if (peak == 0) " - SILENT, the app is not letting itself be captured" else "",
),
)
peak = 0
}
companion object {
private const val REPORT_MS = 2_000L
}
}
@@ -0,0 +1,171 @@
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 androidx.activity.result.contract.ActivityResultContracts
import app.seb3thehacker.gearslip.audio.AudioCaptureService
import app.seb3thehacker.gearslip.audio.CarAudio
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
private val audioPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) askAudioCapture() else CarAudio.denied()
}
private val audioConsent = registerForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult(),
) { result ->
val data = result.data
if (result.resultCode == RESULT_OK && data != null) {
AudioCaptureService.start(this, result.resultCode, data)
} else {
GearslipLog.w("preview: audio capture consent declined")
CarAudio.denied()
}
}
private fun askAudioCapture() {
val manager = getSystemService(android.media.projection.MediaProjectionManager::class.java)
runCatching { audioConsent.launch(manager.createScreenCaptureIntent()) }
.onFailure { GearslipLog.e("preview: could not ask for audio capture", it); CarAudio.denied() }
}
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() }
// Stand in for the car's media sink, so the phone half of the audio path runs for real.
if (intent.getBooleanExtra("audio", false)) {
CarAudio.onConsentRequested = {
runOnUiThread {
if (checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
) {
askAudioCapture()
} else {
audioPermission.launch(android.Manifest.permission.RECORD_AUDIO)
}
}
}
CarAudio.attach(BenchAudioSink())
}
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++
}
// A two-finger pinch: "cx,cy,startGap,endGap" in projected pixels, after "pinchAfter" ms.
intent.getStringExtra("pinch")?.let { spec ->
val (cx, cy, gap0, gap1) = spec.split(",").map { it.toFloat() }
val pair = { gap: Float -> listOf(TouchPoint(3, cx - gap / 2, cy), TouchPoint(7, cx + gap / 2, cy)) }
val after = intent.getIntExtra("pinchAfter", 9000).toLong()
main.postDelayed({
snap()
p.dispatchTouch(MotionEvent.ACTION_DOWN, 0, listOf(pair(gap0)[0]))
p.dispatchTouch(MotionEvent.ACTION_POINTER_DOWN, 1, pair(gap0))
val steps = 14
for (i in 1..steps) main.postDelayed({
p.dispatchTouch(MotionEvent.ACTION_MOVE, 0, pair(gap0 + (gap1 - gap0) * i / steps))
}, 40L * i)
main.postDelayed({
p.dispatchTouch(MotionEvent.ACTION_POINTER_UP, 1, pair(gap1))
p.dispatchTouch(MotionEvent.ACTION_UP, 0, listOf(pair(gap1)[0]))
}, 40L * (steps + 1))
main.postDelayed({ snap() }, 40L * (steps + 1) + 2000)
}, after)
}
// 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({
val point = listOf(TouchPoint(0, x, y))
p.dispatchTouch(MotionEvent.ACTION_DOWN, 0, point)
main.postDelayed({ p.dispatchTouch(MotionEvent.ACTION_UP, 0, point) }, 80)
}, 2500L * (i + 1))
main.postDelayed({ snap() }, 2500L * (i + 1) + 1500)
}
}, 2000)
}
override fun onDestroy() {
super.onDestroy()
projector?.stop()
reader?.close()
CarAudio.onConsentRequested = null
CarAudio.release()
CarEnvironment.stop()
}
}
+197
View File
@@ -0,0 +1,197 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- 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" />
<!-- The dashboard opened from the car clock: today's agenda and current weather. -->
<uses-permission android:name="android.permission.READ_CALENDAR" />
<!-- 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" />
<!--
Playback capture copies a stream rather than moving it, so the phone keeps playing the
music it is sending to the car. Silencing the phone's own speaker for the drive needs this.
-->
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!--
Audio capture counts as a screen share, and Android hides notifications during one. The
protection can be switched off for the drive, but only with this, granted once over adb
(pm grant). Without it Gearslip works the same and notifications stay hidden.
-->
<uses-permission
android:name="android.permission.WRITE_SECURE_SETTINGS"
tools:ignore="ProtectedPermissions" />
<!--
Phone calls on the car screen. READ_PHONE_STATE and READ_CALL_LOG together are what a
call's own state (ringing, in progress) and the incoming number need; READ_CONTACTS
turns that number into a name; ANSWER_PHONE_CALLS lets the driver pick up from here.
Declining a call, and the caller ID itself, need Gearslip set as the phone's caller ID
and spam app too - an on-device consent, not a permission declared here.
-->
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.READ_CALL_LOG" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
<!-- Placing a call from the car screen's dial pad or contacts list needs this. -->
<uses-permission android:name="android.permission.CALL_PHONE" />
<!--
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>
<!-- Named directly: current builds have no launcher-category entry point for the MAIN/
LAUNCHER query below to find, so the setup guide's Android Auto check needs this to see
whether the package is installed at all. -->
<package android:name="com.google.android.projection.gearhead" />
<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">
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.logshare"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/share_paths" />
</provider>
<!--
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>
<!--
Sees incoming calls before they ring, so the car screen can show the caller and
decline it. Inert until the driver sets Gearslip as the phone's caller ID and spam
app, under Settings > Apps > Default apps - the same consent a call-blocking app asks
for, not a privileged one.
-->
<service
android:name=".call.GearslipCallScreening"
android:exported="true"
android:label="Gearslip calls"
android:permission="android.permission.BIND_SCREENING_SERVICE">
<intent-filter>
<action android:name="android.telecom.CallScreeningService" />
</intent-filter>
</service>
</application>
</manifest>
@@ -0,0 +1,68 @@
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 const val KEY_SEEN_COMPAT_WARNING = "seen_compat_warning"
private const val KEY_SKIPPED_CERT_SETUP = "skipped_cert_setup"
private const val KEY_DEBUG_MODE = "debug_mode"
private const val KEY_SEEN_PERMISSIONS_SETUP = "seen_permissions_setup"
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()
}
/** Whether the newer-car compatibility warning has already been shown and dismissed once. */
fun hasSeenCompatWarning(context: Context): Boolean =
prefs(context).getBoolean(KEY_SEEN_COMPAT_WARNING, false)
fun setSeenCompatWarning(context: Context) {
prefs(context).edit().putBoolean(KEY_SEEN_COMPAT_WARNING, true).apply()
}
/** Whether the certificate setup screen has been deliberately skipped. */
fun hasSkippedCertSetup(context: Context): Boolean =
prefs(context).getBoolean(KEY_SKIPPED_CERT_SETUP, false)
fun setSkippedCertSetup(context: Context) {
prefs(context).edit().putBoolean(KEY_SKIPPED_CERT_SETUP, true).apply()
}
/** Whether the one-by-one permissions wizard has already run once. */
fun hasSeenPermissionsSetup(context: Context): Boolean =
prefs(context).getBoolean(KEY_SEEN_PERMISSIONS_SETUP, false)
fun setSeenPermissionsSetup(context: Context) {
prefs(context).edit().putBoolean(KEY_SEEN_PERMISSIONS_SETUP, true).apply()
}
/**
* Debug mode: full protocol tracing in the log, and the developer screens (live logs, car
* preview) on the home screen. On by default while the project is pre-release.
*/
fun debugMode(context: Context): Boolean =
prefs(context).getBoolean(KEY_DEBUG_MODE, true)
fun setDebugMode(context: Context, value: Boolean) {
prefs(context).edit().putBoolean(KEY_DEBUG_MODE, value).apply()
GearslipLog.debug = value
}
}
@@ -0,0 +1,52 @@
package app.seb3thehacker.gearslip
import java.io.ByteArrayOutputStream
import java.net.HttpURLConnection
import java.net.URL
/**
* Fetches the publicly available head unit certificate and key from the opencardev/aasdk
* repository on GitHub. The app ships no certificates; it downloads them only when the
* user chooses to, the same way a browser would.
*/
object CertDownloader {
private val log = GearslipLog.tagged("DOWNL")
private const val BASE = "https://raw.githubusercontent.com/opencardev/aasdk/main/cert"
private const val CRT = "$BASE/headunit.crt"
private const val KEY = "$BASE/headunit.key"
private const val CONNECT_TIMEOUT_MS = 10_000
private const val READ_TIMEOUT_MS = 15_000
class Downloaded(val certificatePem: String, val privateKeyPem: String)
/** Fetches both files; throws with a readable message on any failure. */
fun fetch(): Downloaded {
log.i("fetching certificate from $BASE")
val crt = fetch(CRT, "certificate")
val key = fetch(KEY, "private key")
log.i("downloaded certificate (${crt.length} bytes) and key (${key.length} bytes)")
return Downloaded(crt, key)
}
private fun fetch(url: String, label: String): String {
val body = try {
val conn = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = CONNECT_TIMEOUT_MS
readTimeout = READ_TIMEOUT_MS
}
conn.inputStream.use { input ->
val out = ByteArrayOutputStream()
val buf = ByteArray(4096)
var n: Int
while (input.read(buf).also { n = it } != -1) out.write(buf, 0, n)
out.toString(Charsets.UTF_8)
}
} catch (e: Exception) {
throw RuntimeException("could not download the $label: ${e.message}", e)
}
if (body.isBlank()) throw RuntimeException("the $label file is empty")
return body
}
}
@@ -0,0 +1,218 @@
package app.seb3thehacker.gearslip
import android.content.Context
import android.net.Uri
import java.io.ByteArrayOutputStream
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. **Downloaded** from the public opencardev/aasdk repository. Fetched only when the
* user asks; the app never ships or hosts the certificate.
* 3. **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.
* 4. A freshly generated self-signed cert, which a real head unit rejects (see
* SPIKE_FINDINGS.md).
*/
object CertProvider {
private val log = GearslipLog.tagged("CERT")
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, DOWNLOADED, 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 { log.e("could not load the imported certificate - trying the next source", it) }
}
val downloaded = downloadedFile(context, P12_NAME)
if (downloaded.isFile) {
runCatching {
return loadP12(downloaded, DEFAULT_PASSWORD, "downloaded certificate", Kind.DOWNLOADED)
}.onFailure { log.e("could not load the downloaded 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 { log.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)
log.i("imported a certificate: ${identity.certificate.subjectX500Principal}")
return identity
} finally {
staging.delete()
}
}
fun hasImported(context: Context): Boolean = importedFile(context, P12_NAME).isFile
/** Whether a downloaded certificate is stored (may or may not also be imported). */
fun hasDownloaded(context: Context): Boolean = downloadedFile(context, P12_NAME).isFile
/** Drops the imported identity; the next source in line (downloaded, adb-staged, then self-signed) takes over. */
fun removeImported(context: Context) {
importedFile(context, P12_NAME).delete()
importedFile(context, PASS_NAME).delete()
log.i("removed the imported certificate")
}
/** Drops the downloaded identity. */
fun removeDownloaded(context: Context) {
downloadedFile(context, P12_NAME).delete()
downloadedFile(context, PASS_NAME).delete()
log.i("removed the downloaded certificate")
}
/** Whether any certificate is available (imported, downloaded, or adb-staged). */
fun hasAnyCert(context: Context): Boolean =
hasImported(context) || hasDownloaded(context) || hasAdbStaged(context)
private fun hasAdbStaged(context: Context): Boolean =
context.getExternalFilesDir(null)?.let { File(it, P12_NAME).isFile } == true
/**
* Validates raw PKCS#12 bytes with [password] and, if good, writes them as the imported
* identity. Used when the app assembles a certificate in memory (e.g. from a download)
* rather than importing from a file picker.
*/
fun importFromBytes(context: Context, p12Bytes: ByteArray, password: String): Identity {
val staging = File(context.cacheDir, "import-$P12_NAME")
try {
staging.writeBytes(p12Bytes)
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)
log.i("imported a certificate from bytes: ${identity.certificate.subjectX500Principal}")
return identity
} finally {
staging.delete()
}
}
/**
* Downloads the publicly available head unit certificate from the opencardev/aasdk
* repository, converts it from PEM to PKCS#12, and stores it ready for use.
* Runs synchronously (caller is responsible for threading). Throws on any failure.
*/
fun downloadAndImport(context: Context): Identity {
val download = CertDownloader.fetch()
val result = PemKeyParser.build(download.certificatePem, download.privateKeyPem)
val target = downloadedFile(context, P12_NAME)
target.parentFile?.mkdirs()
val p12Password = DEFAULT_PASSWORD
val keyStore = result.keyStore
val p12Bytes = ByteArrayOutputStream().use { stream ->
keyStore.store(stream, p12Password.toCharArray())
stream.toByteArray()
}
target.writeBytes(p12Bytes)
downloadedFile(context, PASS_NAME).writeText(p12Password)
log.i("downloaded and stored certificate: ${result.certificate.subjectX500Principal}")
return Identity(keyStore, result.certificate, p12Password.toCharArray(),
"downloaded certificate (opencardev/aasdk)", Kind.DOWNLOADED)
}
// 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 downloadedFile(context: Context, name: String) =
File(File(context.filesDir, "$IMPORT_DIR-downloaded"), 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,97 @@
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 {
private val log = GearslipLog.tagged("VIDEO")
fun run(width: Int = 800, height: Int = 480, frameRate: Int = 30): Boolean {
log.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 -> {
log.e("encoder self-test FAILED: no CODEC_CONFIG (SPS/PPS) produced")
false
}
frames == 0 -> {
log.e("encoder self-test FAILED: no frames produced")
false
}
!sawKeyFrame -> {
log.e("encoder self-test FAILED: no keyframe - the car needs one first")
false
}
!sawResyncKeyFrame -> {
log.e(
"encoder self-test FAILED: requestSyncFrame() produced no keyframe - " +
"the resync-after-drop fix would silently do nothing on this device",
)
false
}
else -> {
log.i(
"SPS/PPS = ${csd.size} bytes, $frames frames, keyframe present, " +
"requestSyncFrame() confirmed working",
)
log.hex(" csd", csd, limit = 48)
log.i("=== encoder self-test PASSED ===")
true
}
}
} catch (t: Throwable) {
runCatching { source.stop() }
log.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,329 @@
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.car.Prefetch
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()
}
}
}
/** Setting Gearslip as the phone's caller ID and spam app - what lets it decline a ringing call. */
private val callScreeningRole = registerForActivityResult(StartActivityForResult()) { result ->
if (result.resultCode != RESULT_OK) GearslipLog.w("calls: call screening role declined")
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Location, calendar, the phone/contacts group, and the microphone are asked for one at
// a time by PermissionsSetupScreen instead of here, each only after the driver taps
// Allow on a screen explaining what it is for. Firing them all from onCreate, unprompted,
// is both a bad first impression and unreliable: Android does not consistently show more
// than one system permission dialog fired back-to-back, which was silently dropping some.
enableEdgeToEdge()
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
GearslipLog.init(this)
GearslipLog.installCrashHandler()
GearslipLog.i("Gearslip ready")
CarSettings.init(this)
// A capture that died without tidying up would leave notifications exposed to every
// later screen share. Nothing is capturing if the service is not running, so undo it.
if (!app.seb3thehacker.gearslip.audio.AudioCaptureService.running) {
app.seb3thehacker.gearslip.audio.ScreenShareGuard.restore(this)
}
Prefetch.warm(this)
CarEnvironment.setPhoneTheme(resources.configuration)
setContent {
GearslipTheme {
GearslipApp(onDisconnect = ::closeAccessory, onRequestCallScreening = ::requestCallScreeningRole)
}
}
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)
}
// Throttled inside Prefetch, so this only does work if the last run was a while ago.
override fun onStart() {
super.onStart()
Prefetch.warm(this)
}
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()
}
/** Launches the system dialog that offers Gearslip the call-screening role, if it isn't held already. */
private fun requestCallScreeningRole() {
val roleManager = getSystemService(android.app.role.RoleManager::class.java)
if (roleManager == null || !roleManager.isRoleAvailable(android.app.role.RoleManager.ROLE_CALL_SCREENING)) {
GearslipLog.w("calls: call screening role unavailable on this phone")
return
}
if (roleManager.isRoleHeld(android.app.role.RoleManager.ROLE_CALL_SCREENING)) return
runCatching {
callScreeningRole.launch(roleManager.createRequestRoleIntent(android.app.role.RoleManager.ROLE_CALL_SCREENING))
}.onFailure { GearslipLog.e("could not ask for the call screening role", it) }
}
// --- 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()
SessionReport.print()
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, actionIndex: Int, points: List<TouchPoint>) {
runOnUiThread {
// Mirroring drives one phone touch at a time, so it follows the first finger only.
if (PhoneMirror.active.value) points.firstOrNull()?.let { relayTouch(action, it.x, it.y) }
else screenProjector?.dispatchTouch(action, actionIndex, points)
}
}
override fun onProjectionStopped() {
runOnUiThread {
PhoneMirror.requestStop()
screenProjector?.stop()
screenProjector = null
surface = null
CarEnvironment.stop()
}
}
}
@@ -0,0 +1,199 @@
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
/**
* Set from [AppSettings.debugMode]. Off drops the noisy trace output (hex dumps, and
* logcat copies of info lines); warnings, errors and the file copy are always kept, so a
* crash report is never empty.
*/
@Volatile var debug = true
// 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) {
debug = AppSettings.debugMode(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}")
SessionReport.init(context)
i("debug mode: $debug")
}
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")
}
/**
* The current session's log, with the session report on top, copied to the cache dir for
* sharing as an attachment (a whole log can exceed what an Intent may carry as text).
*/
fun shareableFile(context: Context): android.net.Uri {
val dir = File(context.cacheDir, "shared").apply { mkdirs() }
dir.listFiles()?.forEach { it.delete() }
val out = File(dir, "gearslip-log.txt")
out.writeText(SessionReport.render() + "\n\n" + fullText())
return androidx.core.content.FileProvider.getUriForFile(
context, "${context.packageName}.logshare", out,
)
}
fun i(message: String, tag: String = "APP") = write("I", message, tag)
fun w(message: String, tag: String = "APP") = write("W", message, tag)
/** Logs the message, then the exception with its cause chain and top stack frames. */
fun e(message: String, t: Throwable? = null, tag: String = "APP") {
write("E", if (t == null) message else "$message: ${rootCause(t)}", tag)
if (t != null) {
Log.e(TAG, message, t)
stackTrace(t).lines().forEach { write("E", " $it", tag) }
}
}
/** A logger that stamps every line with [tag], so the log can be scanned by subsystem. */
fun tagged(tag: String) = Tagged(tag)
class Tagged(private val tag: String) {
fun i(message: String) = GearslipLog.i(message, tag)
fun w(message: String) = GearslipLog.w(message, tag)
fun e(message: String, t: Throwable? = null) = GearslipLog.e(message, t, tag)
fun hex(label: String, data: ByteArray, limit: Int = 256) = GearslipLog.hex(label, data, limit, tag)
fun verdict(headline: String, detail: String) = GearslipLog.verdict(headline, detail)
fun flush() = GearslipLog.flush()
}
/** "SSLHandshakeException: Received fatal alert: bad_certificate" - the deepest cause wins. */
fun rootCause(t: Throwable): String {
var cause: Throwable = t
while (cause.cause != null && cause.cause !== cause) cause = cause.cause!!
val top = "${t.javaClass.simpleName}: ${t.message}"
return if (cause === t) top else "$top <- ${cause.javaClass.simpleName}: ${cause.message}"
}
private fun stackTrace(t: Throwable, frames: Int = 12): String {
val out = StringBuilder()
var cause: Throwable? = t
var depth = 0
while (cause != null && depth < 4) {
if (depth > 0) out.append("caused by ${cause.javaClass.name}: ${cause.message}\n")
cause.stackTrace.take(frames).forEach { out.append("at $it\n") }
cause = cause.cause?.takeIf { it !== cause }
depth++
}
return out.toString().trimEnd()
}
/**
* Records an uncaught exception in the log file before the process dies, then lets the
* system's own handler run. Without it the file ends mid-sentence and the reason is lost.
*/
fun installCrashHandler() {
val previous = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
runCatching {
SessionReport.fail(SessionReport.Category.CRASH, "uncaught in thread ${thread.name}", throwable)
e("FATAL uncaught exception in thread ${thread.name}", throwable)
SessionReport.print()
flush()
}
previous?.uncaughtException(thread, throwable)
}
}
/** 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, tag: String = "APP") {
if (!debug) return
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", tag)
}
private fun write(level: String, message: String, tag: String = "APP") {
when (level) {
"W" -> Log.w(TAG, message)
"E" -> Log.e(TAG, message)
else -> if (debug) Log.i(TAG, message)
}
val line = "${stamp.format(Date())} $level ${tag.padEnd(5)} $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,837 @@
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 val log = GearslipLog.tagged("PROTO")
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 audioMessagesSeen = 0
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() {
SessionReport.begin()
log.i("--- session starting ---")
SessionStatus.connecting("Connected to the head unit")
log.i("device=${Build.MANUFACTURER} ${Build.MODEL} android=${Build.VERSION.RELEASE} (sdk ${Build.VERSION.SDK_INT})")
val identity = identityProvider()
val cert = identity.certificate
log.i("phone certificate source: ${identity.source}")
SessionReport.certificate(identity.source.toString(), cert.issuerX500Principal.toString())
log.i(" subject = ${cert.subjectX500Principal}")
log.i(" issuer = ${cert.issuerX500Principal}")
log.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) {
log.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) {
log.e("transport failed in state $state", t)
SessionReport.fail(SessionReport.Category.USB, "transport failed", t, state.name)
reportDisconnect()
} finally {
running = false
log.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) {
log.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) {
log.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) {
// The audio channel is the one still being reverse-engineered, and its first few
// replies are what tell us why. Raw bytes for those, then quiet.
if (audioMessagesSeen < AUDIO_TRACE_MESSAGES) {
audioMessagesSeen++
log.i(
"audio frame #$audioMessagesSeen: channel=${frame.channel} " +
"encrypted=${frame.encrypted} raw=${frame.payload.size}B decrypted=${message.size}B",
)
log.hex(" audio raw", message, limit = 64)
}
audioLink?.onMessage(messageId, body)
} else {
log.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 -> log.i("unhandled control message id=$messageId (${body.size} bytes) - continuing")
}
}
private fun onVersionRequest(body: ByteArray) {
if (body.size < 4) {
log.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)
log.i("<- VersionRequest: head unit speaks $major.$minor")
SessionReport.protocolVersion(major, minor)
// PROTOCOL-DOWNGRADE EXPERIMENT. The 2018 Uconnect speaks 1.3 and ACCEPTS the JVC cert;
// the 2025 unit speaks 4.3 and rejects it at AuthComplete (-3). If the 4.3 identity check
// is tied to the *negotiated* protocol version rather than baked into the firmware,
// claiming an older version makes the newer unit skip the check entirely. CLAIM_VERSION
// = null restores the previous echo-the-head-unit behaviour.
val claim = CLAIM_VERSION ?: (major to minor)
val response = byteArrayOf(
((claim.first shr 8) and 0xFF).toByte(), (claim.first and 0xFF).toByte(),
((claim.second shr 8) and 0xFF).toByte(), (claim.second and 0xFF).toByte(),
0, 0, // STATUS_SUCCESS
)
send(MSG_VERSION_RESPONSE, response, encrypted = false)
log.i(
"-> VersionResponse: ${claim.first}.${claim.second} status=0 (STATUS_SUCCESS)" +
if (CLAIM_VERSION != null) " [FORCED; head unit offered $major.$minor]" else "",
)
state = State.TLS_HANDSHAKE
SessionStatus.connecting("Securing the link")
log.i("awaiting ClientHello - the head unit is the TLS client, we are the server")
}
private fun onHandshake(body: ByteArray) {
log.i("<- EncapsulatedSSL (${body.size} bytes)")
val replies = try {
tls.pumpHandshake(body)
} catch (t: Throwable) {
log.e("TLS handshake threw", t)
SessionReport.fail(SessionReport.Category.TLS, "handshake threw", t, state.name)
log.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
SessionReport.print()
return
}
replies.forEach {
send(MSG_ENCAPSULATED_SSL, it, encrypted = false)
log.i("-> EncapsulatedSSL (${it.size} bytes)")
}
if (tls.handshakeComplete && state == State.TLS_HANDSHAKE) {
state = State.WAIT_AUTH
log.i("awaiting AuthComplete - this carries the certificate verdict")
}
}
private fun onAuthComplete(body: ByteArray) {
val status = Protobuf.readInt32Field(body, 1)
log.i("<- AuthComplete: status=$status")
status?.let { SessionReport.auth(it) }
when (status) {
0 -> {
log.i("STATUS_SUCCESS - certificate accepted; confirming with service discovery")
state = State.WAIT_SDR
SessionStatus.connecting("Certificate accepted - discovering services")
sendServiceDiscoveryRequest()
}
-2 -> {
SessionReport.fail(SessionReport.Category.CERTIFICATE, "AuthComplete status -2 (certificate error)", state.name)
log.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 -> {
SessionReport.fail(SessionReport.Category.AUTH, "AuthComplete status -3 (authentication failure)", state.name)
log.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 -> {
SessionReport.fail(SessionReport.Category.AUTH, "AuthComplete status $status (unexpected)", state.name)
log.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)
log.i("-> ServiceDiscoveryRequest (encrypted)")
}
private fun onServiceDiscoveryResponse(body: ByteArray) {
log.i("<- ServiceDiscoveryResponse (${body.size} bytes, decrypted successfully)")
log.i("head unit describes itself as:\n" + Protobuf.describe(body))
log.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)
log.i("head unit: $info")
SessionReport.headUnit(info.toString())
vehicleProfile = runCatching { vehicleProfileFor(info) }.getOrNull()
val profile = vehicleProfile
SessionReport.profile(profile?.name)
if (profile != null) {
log.i("vehicle profile matched: \"${profile.name}\" resolution=${profile.resolution} insets=${profile.insets}")
} else {
log.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) {
log.w("no video service in the discovery response - cannot start Phase A")
SessionReport.fail(SessionReport.Category.VIDEO, "head unit advertised no video service", state.name)
return
}
videoChannelId = video.serviceId
videoConfigs = video.configs
log.i("video service: channel=${video.serviceId} codec=${video.codecName}")
video.configs.forEachIndexed { index, config ->
log.i(" config[$index] = $config")
}
if (video.codecType != CODEC_H264_BP) {
log.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) {
log.w("no input service advertised - projection will be output-only")
} else {
inputChannelId = input.serviceId
touchWidth = input.touchWidth
touchHeight = input.touchHeight
log.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 ->
log.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 {
log.w("no media audio sink advertised - media apps will play on the phone only")
SessionReport.audio(
"no MEDIA sink; head unit offered ${sinks.joinToString { it.streamName }.ifEmpty { "none" }}",
)
return
}
val first = media.configs.firstOrNull()
SessionReport.audio(
"MEDIA sink on channel ${media.serviceId}, ${media.codecName}, " +
"${first?.sampleRate ?: 48_000}Hz/${first?.bits ?: 16}bit/x${first?.channels ?: 2}",
)
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,
)
log.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 ->
log.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) {
log.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
val size = videoConfigs.getOrNull(selectedConfigIndex ?: 0)?.pixelSize()
val points = pointers.mapIndexedNotNull { index, pointer ->
val fields = Wire.fields(pointer)
val rawX = Wire.varint(fields, 1)?.toInt() ?: return@mapIndexedNotNull null
val rawY = Wire.varint(fields, 2)?.toInt() ?: return@mapIndexedNotNull null
val id = Wire.varint(fields, 3)?.toInt() ?: index
TouchPoint(id, clamp(rawX, size?.first), clamp(rawY, size?.second))
}
if (points.isEmpty()) return
val actionIndex = (Wire.varint(touchFields, 2)?.toInt() ?: 0).coerceIn(0, points.size - 1)
if (points.size > 1 && touchesSeen < 3) {
log.i("multi-touch: ${points.size} pointers, action=$action index=$actionIndex")
}
touchesSeen++
if (touchesSeen <= 10 || action == ACTION_DOWN || action == ACTION_UP) {
val at = points[actionIndex]
log.i("<- Touch action=$action at (${at.x.toInt()},${at.y.toInt()}) pointers=${points.size}")
}
projection?.onTouch(action, actionIndex, points)
}
else -> log.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)
log.i("<- ChannelOpenResponse: status=$status")
if (status != 0) {
log.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)
log.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 }
log.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()
log.i("selected config index $selectedConfigIndex from offered $offered")
SessionReport.video(
"${videoConfigs.getOrNull(selectedConfigIndex ?: -1)?.resolutionName ?: "?"} " +
"(head unit offered ${offered.size}), max_unacked=$maxUnacked",
)
// 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)
log.i("-> VideoFocusRequest(mode=PROJECTED)")
}
MSG_VIDEO_FOCUS_NOTIFICATION -> {
val fields = Wire.fields(body)
log.i("<- VideoFocusNotification: mode=${Wire.varint(fields, 1)}")
// The head unit repeats this while it waits for frames; only act once.
if (!videoStarted) {
videoStarted = true
log.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) {
log.i("<- MediaAck(ack=$acked) inFlight=$inFlight after $framesSent frames")
}
}
else -> log.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) {
log.e("no video config at index $index - cannot start the encoder")
return
}
val (width, height) = config.pixelSize() ?: run {
log.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)
log.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)
log.i("-> CodecConfig (${csd.size} bytes SPS/PPS)")
log.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 -> log.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) {
log.w("no ack for ${since}ms with $inFlight in flight - resetting the window")
inFlight = 0
} else {
dropped++
if (dropped % 30 == 1) {
log.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) {
log.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) log.w("video send failed, stopping the encoder: ${e.message}")
running = false
videoSource?.stop()
return
}
inFlight++
framesSent++
if (framesSent <= 3 || keyFrame && framesSent % 30 == 0) {
log.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)
log.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.WAIT_VERSION -> SessionReport.fail(SessionReport.Category.NO_HANDSHAKE, "no VersionRequest arrived", state.name)
State.TLS_HANDSHAKE -> SessionReport.fail(SessionReport.Category.TLS, "head unit dropped the link during TLS", state.name)
State.WAIT_AUTH -> SessionReport.fail(SessionReport.Category.AUTH, "dropped after TLS, before AuthComplete", state.name)
State.WAIT_SDR -> SessionReport.fail(SessionReport.Category.SERVICE_DISCOVERY, "accepted, then dropped before service discovery", state.name)
State.DONE -> {}
}
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 -> log.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 -> log.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 -> log.verdict(
"INCONCLUSIVE (dropped after TLS, before AuthComplete)",
"TLS completed but the unit disconnected before stating a verdict. Re-run.",
)
State.WAIT_SDR -> log.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 -> log.i("connection closed after the run completed")
}
SessionReport.print()
}
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) log.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
SessionReport.stream("${"%.1f".format(fps)} fps, sent=$sent dropped=$dropped acked=$acksSeen")
log.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 {
/**
* Force the protocol version Gearslip claims in VersionResponse, as (major, minor), or
* null to echo the head unit. 1.3 is what the 2018 Uconnect (which accepts the JVC cert)
* speaks. This is the 2025 identity-check downgrade experiment: see onVersionRequest.
*/
val CLAIM_VERSION: Pair<Int, Int>? = 1 to 3
// 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 AUDIO_TRACE_MESSAGES = 6
const val PERIODIC_KEYFRAME_MS = 5_000L
}
}
@@ -0,0 +1,125 @@
package app.seb3thehacker.gearslip
import java.io.ByteArrayInputStream
import java.math.BigInteger
import java.security.KeyFactory
import java.security.KeyStore
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.security.spec.RSAPrivateCrtKeySpec
import java.security.spec.RSAPublicKeySpec
/**
* Converts a PEM-encoded X.509 certificate and PEM-encoded RSA PKCS#1 private key into a
* PKCS#12 [KeyStore], using only standard JCA APIs — no BouncyCastle.
*
* The DER parsing of the PKCS#1 key is done by hand (matching the same approach used in
* [SelfSignedCert] for writing). Android's Conscrypt [KeyFactory] only accepts PKCS#8,
* so we translate PKCS#1 → [RSAPrivateCrtKeySpec] ourselves.
*/
object PemKeyParser {
private const val CERT_HEADER = "-----BEGIN CERTIFICATE-----"
private const val CERT_FOOTER = "-----END CERTIFICATE-----"
private const val KEY_HEADER = "-----BEGIN RSA PRIVATE KEY-----"
private const val KEY_FOOTER = "-----END RSA PRIVATE KEY-----"
class Result(val keyStore: KeyStore, val certificate: X509Certificate)
/**
* Parses PEM strings for the certificate and private key, verifies they match, and
* returns a PKCS#12 [KeyStore] ready for [CertProvider] to consume.
*/
fun build(certificatePem: String, privateKeyPem: String, alias: String = "phone"): Result {
val cert = parseCertificate(certificatePem)
val keySpec = parsePrivateKey(privateKeyPem)
val kf = KeyFactory.getInstance("RSA")
val pubSpec = RSAPublicKeySpec(keySpec.spec.modulus, keySpec.spec.publicExponent)
require(kf.generatePublic(pubSpec) == cert.publicKey) {
"the certificate's public key does not match the private key"
}
val keyStore = KeyStore.getInstance("PKCS12").apply {
load(null, null)
setKeyEntry(alias, keySpec.getPrivate(), CharArray(0), arrayOf(cert))
}
return Result(keyStore, cert)
}
/** Strips PEM headers/footers and decodes the base64 body. */
fun parseCertificate(pem: String): X509Certificate {
val der = pemToDer(pem, CERT_HEADER, CERT_FOOTER)
return CertificateFactory.getInstance("X.509")
.generateCertificate(ByteArrayInputStream(der)) as X509Certificate
}
/** Parses an RSA PKCS#1 private key (ASN.1 DER) into a key spec. */
fun parsePrivateKey(pem: String): KeySpec {
val der = pemToDer(pem, KEY_HEADER, KEY_FOOTER)
val integers = readPkcs1Sequence(der)
require(integers.size == 9) { "PKCS#1 RSA key needs 9 integers, got ${integers.size}" }
val spec = RSAPrivateCrtKeySpec(integers[1], integers[2], integers[3], integers[4], integers[5], integers[6], integers[7], integers[8])
val privateKey = KeyFactory.getInstance("RSA").generatePrivate(spec)
return KeySpec(spec, privateKey)
}
/** Holds both the spec and the constructed key for matching against a certificate's public key. */
class KeySpec(val spec: RSAPrivateCrtKeySpec, private val privateKey: java.security.PrivateKey) {
fun getPrivate() = privateKey
}
/** Strips headers and decodes base64. */
private fun pemToDer(pem: String, header: String, footer: String): ByteArray {
val start = pem.indexOf(header)
val end = pem.indexOf(footer)
if (start == -1 || end == -1 || end <= start) {
throw IllegalArgumentException("not a valid PEM $header block")
}
val b64 = pem.substring(start + header.length, end)
.replace(Regex("\\s+"), "")
return java.util.Base64.getDecoder().decode(b64)
}
/**
* Parses the inner SEQUENCE of an RSA PKCS#1 DER blob into a list of [BigInteger]s.
* Throws if the ASN.1 structure is wrong, so caller gets a clear error rather than a
* silent wrong key.
*/
private fun readPkcs1Sequence(der: ByteArray): List<BigInteger> {
var pos = 0
fun readTag(): Int {
require(pos < der.size) { "unexpected end of DER at tag" }
return der[pos++].toInt() and 0xFF
}
fun readLength(): Int {
require(pos < der.size) { "unexpected end of DER at length" }
val b0 = der[pos++].toInt() and 0xFF
if (b0 < 0x80) return b0
val numBytes = b0 and 0x7F
require(pos + numBytes <= der.size) { "truncated long-form length" }
var result = 0
repeat(numBytes) {
result = (result shl 8) or (der[pos++].toInt() and 0xFF)
}
return result
}
val outerTag = readTag()
require(outerTag == 0x30) { "expected SEQUENCE (0x30), got 0x${outerTag.toString(16)}" }
val outerLen = readLength()
require(pos + outerLen <= der.size) { "SEQUENCE length $outerLen exceeds remaining data" }
val integers = mutableListOf<BigInteger>()
while (pos < der.size) {
val tag = readTag()
require(tag == 0x02) { "expected INTEGER (0x02), got 0x${tag.toString(16)} at offset ${pos - 1}" }
val len = readLength()
require(pos + len <= der.size) { "INTEGER length $len exceeds remaining data" }
integers.add(BigInteger(der.copyOfRange(pos, pos + len)))
pos += len
}
require(integers.size == 9) { "PKCS#1 RSA key needs 9 integers, got ${integers.size}" }
return integers
}
}
@@ -0,0 +1,223 @@
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 log = GearslipLog.tagged("TLS")
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 -> {
log.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
log.i("TLS handshake COMPLETE - protocol=${session.protocol} cipher=${session.cipherSuite}")
SessionReport.tls(session.protocol, session.cipherSuite)
try {
val peers = session.peerCertificates
log.i("head unit presented ${peers.size} certificate(s):")
peers.forEachIndexed { index, certificate ->
if (certificate is X509Certificate) {
log.i(" [$index] subject=${certificate.subjectX500Principal}")
log.i(" issuer =${certificate.issuerX500Principal}")
log.i(" serial =${certificate.serialNumber} valid=${certificate.notBefore}..${certificate.notAfter}")
}
}
} catch (_: SSLPeerUnverifiedException) {
log.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() {
private val log = GearslipLog.tagged("TLS")
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {
log.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,67 @@
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 [DEFAULT_URL].
*/
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 DEFAULT_URL
}
/** What the Web app opens until a page is chosen in Settings. */
const val DEFAULT_URL = "https://duckduckgo.com"
/** 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,30 @@
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). [points] is every
* finger currently down, and [actionIndex] says which of them [action] is about, so a pinch
* arrives as it would on the phone's own screen.
*/
fun onTouch(action: Int, actionIndex: Int, points: List<TouchPoint>)
fun onProjectionStopped()
}
/** One finger on the car's screen: a stable [id] for as long as it stays down, and where it is. */
class TouchPoint(val id: Int, val x: Float, val y: Float)
@@ -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,211 @@
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; [points] are in projected coordinates and
* hold every finger down, with [actionIndex] naming the one the action is about. Two fingers
* arrive as one gesture, so pinch and two-finger drags work as they do on the phone itself.
*/
fun dispatchTouch(action: Int, actionIndex: Int, points: List<TouchPoint>) {
val target = root ?: return
if (points.isEmpty()) return
val now = SystemClock.uptimeMillis()
if (action == MotionEvent.ACTION_DOWN || downTime == 0L) downTime = now
val properties = Array(points.size) { i ->
MotionEvent.PointerProperties().apply {
id = points[i].id
toolType = MotionEvent.TOOL_TYPE_FINGER
}
}
val coords = Array(points.size) { i ->
MotionEvent.PointerCoords().apply {
x = points[i].x
y = points[i].y
pressure = 1f
size = 1f
}
}
// A second finger going down or up carries which finger it was in the action itself.
val encoded = when (action) {
MotionEvent.ACTION_POINTER_DOWN, MotionEvent.ACTION_POINTER_UP ->
action or (actionIndex shl MotionEvent.ACTION_POINTER_INDEX_SHIFT)
else -> action
}
val event = MotionEvent.obtain(
downTime, now, encoded, points.size, properties, coords,
0, 0, 1f, 1f, 0, 0, android.view.InputDevice.SOURCE_TOUCHSCREEN, 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,132 @@
package app.seb3thehacker.gearslip
import android.content.Context
import android.os.Build
/**
* The facts a bug report needs, gathered as a session runs and printed as one readable block
* when it ends. The log holds the full trace; this holds the answer to "what went wrong,
* on what car, on what phone" so nobody has to read the trace to find out.
*
* Nothing here identifies a person: no serial numbers, accounts, locations or accessory
* serials. The head unit and vehicle strings are what the head unit itself announces.
*/
object SessionReport {
/** Where a session failed, coarse enough to count and group reports by. */
enum class Category(val label: String) {
NONE("none"),
USB("USB transport"),
NO_HANDSHAKE("head unit never started talking"),
TLS("TLS handshake failed"),
CERTIFICATE("certificate rejected by the head unit"),
AUTH("authentication failed"),
SERVICE_DISCOVERY("service discovery failed"),
VIDEO("video stream failed"),
CRASH("app crash"),
UNKNOWN("unclassified"),
}
private var appVersion = "?"
private var appCode = 0L
@Volatile private var startedAt = 0L
@Volatile var category = Category.NONE; private set
@Volatile private var failure = ""
@Volatile private var protocolVersion = ""
@Volatile private var certSource = ""
@Volatile private var certIssuer = ""
@Volatile private var tlsProtocol = ""
@Volatile private var tlsCipher = ""
@Volatile private var authStatus: Int? = null
@Volatile private var headUnit = ""
@Volatile private var profile = ""
@Volatile private var video = ""
@Volatile private var audio = ""
@Volatile private var lastStream = ""
@Volatile private var lastState = ""
private var printed = false
fun init(context: Context) {
val info = runCatching { context.packageManager.getPackageInfo(context.packageName, 0) }.getOrNull()
appVersion = info?.versionName ?: "?"
appCode = info?.longVersionCode ?: 0
}
fun begin() {
startedAt = System.currentTimeMillis()
category = Category.NONE
failure = ""; protocolVersion = ""; certSource = ""; certIssuer = ""
tlsProtocol = ""; tlsCipher = ""; authStatus = null
headUnit = ""; profile = ""; video = ""; lastStream = ""; lastState = ""
printed = false
}
fun protocolVersion(major: Int, minor: Int) { protocolVersion = "$major.$minor" }
fun certificate(source: String, issuer: String) { certSource = source; certIssuer = issuer }
fun tls(protocol: String, cipher: String) { tlsProtocol = protocol; tlsCipher = cipher }
fun auth(status: Int) { authStatus = status }
fun headUnit(info: String) { headUnit = info }
fun profile(name: String?) { profile = name ?: "none matched" }
fun video(summary: String) { video = summary }
fun audio(summary: String) { audio = summary }
fun stream(summary: String) { lastStream = summary }
/** Records the first failure only: later ones are usually fallout from it. */
fun fail(category: Category, detail: String, state: String = "") {
if (this.category != Category.NONE) return
this.category = category
failure = detail
lastState = state
}
fun fail(category: Category, detail: String, t: Throwable, state: String = "") =
fail(category, "$detail (${GearslipLog.rootCause(t)})", state)
/** The report as plain text, for the log file and for anything that gets sent. */
fun render(): String {
val seconds = if (startedAt == 0L) 0 else (System.currentTimeMillis() - startedAt) / 1000
fun row(name: String, value: String) = if (value.isEmpty()) "" else " ${name.padEnd(16)}$value\n"
return buildString {
append("=".repeat(60)).append('\n')
append("SESSION REPORT\n")
append(row("result", if (category == Category.NONE) "ok / no failure recorded" else "FAILED - ${category.label}"))
append(row("detail", failure))
append(row("failed in state", lastState))
append(row("duration", "${seconds}s"))
append("\n")
append(row("gearslip", "$appVersion (code $appCode)"))
append(row("phone", "${Build.MANUFACTURER} ${Build.MODEL}"))
append(row("android", "${Build.VERSION.RELEASE} (sdk ${Build.VERSION.SDK_INT}, patch ${Build.VERSION.SECURITY_PATCH})"))
append("\n")
append(row("head unit", headUnit))
append(row("AA protocol", protocolVersion))
append(row("profile", profile))
append("\n")
append(row("phone cert", certSource))
append(row("cert issuer", certIssuer))
append(row("TLS", listOf(tlsProtocol, tlsCipher).filter { it.isNotEmpty() }.joinToString(" / ")))
append(row("auth status", authStatus?.let { "$it (${authName(it)})" } ?: ""))
append("\n")
append(row("video", video))
append(row("audio", audio))
append(row("last stream stat", lastStream))
append("=".repeat(60))
}
}
/** Prints the report into the log, once per session. */
fun print() {
if (printed || startedAt == 0L) return
printed = true
render().lines().forEach { GearslipLog.i(it) }
GearslipLog.flush()
}
private fun authName(status: Int) = when (status) {
0 -> "success"
-2 -> "certificate error"
-3 -> "authentication failure"
else -> "unknown"
}
}
@@ -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,150 @@
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 {
private val log = GearslipLog.tagged("TLS")
fun run(identity: CertProvider.Identity): Boolean {
log.i("=== TLS self-test starting ===")
log.i("cert source: ${identity.source}")
log.i(" subject = ${identity.certificate.subjectX500Principal}")
log.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) {
log.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)) {
log.e("self-test FAILED: round trip mismatch (${decrypted.size} bytes back)")
return false
}
log.i("=== TLS self-test PASSED - certificate, key manager and engine are sound ===")
true
} catch (t: Throwable) {
log.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,203 @@
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,
) {
private val log = GearslipLog.tagged("VIDEO")
/**
* 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
log.i("encoder started: ${width}x$height @ ${frameRate}fps, H.264 Baseline, mode=$mode")
worker = Thread {
runCatching { pump(encoder) }
.onFailure { if (running) log.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 { log.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,259 @@
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 media playback: notification pings, calls and alarms stay on the phone.
* The grant lasts as long as this service does, which is as long as the car is connected, so the
* driver answers the consent dialog once per drive rather than once per app.
*/
class AudioCaptureService : Service() {
@Volatile private var reading = false
private var projection: MediaProjection? = null
private var thread: Thread? = null
private var mutedPhone = false
/**
* Silence the phone's own speaker while the car is being fed.
*
* Playback capture copies the stream instead of moving it, so without this the music plays in
* two places at once. Muting the stream does not affect what is captured - the tap sits before
* the volume the speaker gets - so the car still receives full-level audio.
*/
private fun mutePhone() {
if (mutedPhone) return
runCatching {
getSystemService(android.media.AudioManager::class.java)
.adjustStreamVolume(android.media.AudioManager.STREAM_MUSIC, android.media.AudioManager.ADJUST_MUTE, 0)
mutedPhone = true
GearslipLog.i("audio: muted the phone's speaker for the drive")
}.onFailure { GearslipLog.w("audio: could not mute the phone: ${it.message}") }
}
private fun unmutePhone() {
if (!mutedPhone) return
mutedPhone = false
runCatching {
getSystemService(android.media.AudioManager::class.java)
.adjustStreamVolume(android.media.AudioManager.STREAM_MUSIC, android.media.AudioManager.ADJUST_UNMUTE, 0)
GearslipLog.i("audio: gave the phone's speaker back")
}.onFailure { GearslipLog.w("audio: could not unmute the phone: ${it.message}") }
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
running = true
}
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)
ScreenShareGuard.lift(this)
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 record = try {
// Matched by usage, not by app. A uid filter would have to be rebuilt every time the
// driver changed media app, and rebuilding means a fresh consent dialog - so the
// filter is what the sound is for, and any app playing media reaches the car.
val config = AudioPlaybackCaptureConfiguration.Builder(token)
.addMatchingUsage(AudioAttributes.USAGE_MEDIA)
.addMatchingUsage(AudioAttributes.USAGE_GAME)
.addMatchingUsage(AudioAttributes.USAGE_UNKNOWN)
.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()
val startedAt = heardAt
var everHeard = false
var warnedSilent = false
try {
record.startRecording()
GearslipLog.i("audio: capturing media playback at ${rate}Hz x$channels")
mutePhone()
CarAudio.captureStarted()
while (reading) {
val n = record.read(buffer, 0, buffer.size)
if (n <= 0) {
GearslipLog.e("audio: AudioRecord.read returned $n - capture has stopped")
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()))
}
if (peak > 0) everHeard = true
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
}
// Capture never fails for an app that opts out; it just hands back digital
// silence. Say so, because the symptom is identical to a broken car link.
if (!everHeard && !warnedSilent && now - startedAt >= SILENCE_MS) {
warnedSilent = true
GearslipLog.w(
"audio: ${SILENCE_MS / 1000}s of capture and every sample is zero. " +
"Either nothing is playing, or the app does not allow its audio " +
"to be captured.",
)
}
CarAudio.deliver(buffer, n, level)
}
} catch (t: Throwable) {
GearslipLog.e("audio: capture loop ended", t)
} finally {
runCatching { record.stop() }
record.release()
unmutePhone()
CarAudio.captureStopped()
}
}, "gearslip-audio").apply { isDaemon = true; start() }
}
override fun onDestroy() {
super.onDestroy()
reading = false
runCatching { thread?.join(500) }
// Belt and braces: a phone left muted after the drive is a bug the driver blames on their
// phone, not on Gearslip, so unmute on every way out of here.
unmutePhone()
runCatching { projection?.stop() }
projection = null
ScreenShareGuard.restore(this)
running = false
}
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 const val SILENCE_MS = 5_000L
private var appContext: Context? = null
/** True between onCreate and onDestroy; lets the activity tell a live capture from a crashed one. */
@Volatile var running = false
private set
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,247 @@
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 focusGranted = false
@Volatile private var focusRequested = false
@Volatile private var inFlight = 0
// The Uconnect asks for 4 on its video channel; assume the same until its Config says otherwise.
@Volatile private var maxUnacked = 4
@Volatile private var lastAckAt = 0L
private var chunks = 0
private var bytes = 0L
private var dropped = 0
@Volatile private var configTimer: Thread? = null
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) {
GearslipLog.i("audio: <- ChannelOpenResponse (media audio): status=$status")
if (status != 0) {
GearslipLog.w("audio: the head unit refused the media audio channel (status $status)")
return
}
// AVChannelSetupRequest { required uint32 config_index = 1 }. This field is an index into
// the sink's own audio_configs, not a codec id - sending the codec type asks for a config
// the sink does not have, and it answers with a status that never reaches READY.
sendOnChannel(MSG_SETUP, Protobuf.varintField(1, 0L))
GearslipLog.i("audio: -> Setup(config_index=0) on channel ${service.serviceId}, codec ${service.codecName}")
armConfigFallback()
}
/**
* The 2018 Uconnect answers our Setup with a two-byte message we cannot identify (id 255, no
* body) rather than the AVChannelSetupResponse the video channel gets, so the sink never
* reaches READY and no audio is ever sent.
*
* Service discovery already told us everything that message would have: the sink is PCM at
* 48000Hz stereo and it has exactly one configuration. So rather than wait forever for a
* message that is not coming, assume the advertised configuration and carry on. If that guess
* is wrong the head unit simply will not ack, which the write path already reports.
*/
private fun armConfigFallback() {
val timer = Thread({
try {
Thread.sleep(CONFIG_WAIT_MS)
} catch (e: InterruptedException) {
return@Thread
}
if (ready) return@Thread
GearslipLog.w(
"audio: no usable Config after ${CONFIG_WAIT_MS}ms - falling back to the sink the " +
"head unit advertised (${sampleRate}Hz x$channels, config_index=0, max_unacked=$maxUnacked)",
)
configIndex = 0
ready = true
CarAudio.attach(this)
}, "gearslip-audio-config").apply { isDaemon = true }
configTimer = timer
timer.start()
}
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) {
configTimer?.interrupt()
ready = true
GearslipLog.i("audio: sink ready - ${sampleRate}Hz x$channels ${config?.bits}bit")
CarAudio.attach(this)
} else {
GearslipLog.w(
"audio: the sink is not ready (status=$status, 1=WAIT 2=READY) - " +
"nothing will be sent until it is",
)
}
}
MSG_ACK -> {
val acked = Wire.varint(Wire.fields(body), 2)?.toInt() ?: 1
inFlight = maxOf(0, inFlight - maxOf(1, acked))
lastAckAt = System.currentTimeMillis()
}
// Whatever the head unit answers Setup with, we need its bytes to identify it.
else -> {
GearslipLog.i("audio: <- unhandled message id=$messageId (${body.size} bytes)")
GearslipLog.hex(" audio msg", body, limit = 64)
}
}
}
/**
* The head unit's answer to our AudioFocusRequest. Until it grants focus the sink is wired to
* whatever else the car is playing - the radio, usually - and anything we send is discarded,
* so [start] waits here rather than streaming into nothing.
*/
fun onFocus(state: Int) {
val name = FOCUS_STATES[state] ?: "unknown($state)"
GearslipLog.i("audio: <- AudioFocusResponse state=$name")
focusGranted = state == FOCUS_STATE_GAIN || state == FOCUS_STATE_GAIN_TRANSIENT
if (focusGranted) startStream() else stop()
}
override fun start() {
if (!ready || started) return
if (!focusRequested) {
focusRequested = true
sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_GAIN.toLong()))
GearslipLog.i("audio: -> AudioFocusRequest(GAIN); waiting for the head unit to hand the sink over")
}
// Some head units grant the sink silently and never answer. Do not let that be the end of
// it: start anyway, and let the acks say whether anything is being taken.
startStream()
}
private fun startStream() {
if (!ready || started) return
sendOnChannel(
MSG_START,
Protobuf.varintField(1, SESSION_ID.toLong()) + Protobuf.varintField(2, configIndex.toLong()),
)
inFlight = 0
lastAckAt = System.currentTimeMillis()
started = true
GearslipLog.i("audio: -> Start(session=$SESSION_ID, config=$configIndex) - streaming ${sampleRate}Hz x$channels")
}
override fun stop() {
if (!started) return
started = false
runCatching {
sendOnChannel(MSG_STOP, Protobuf.varintField(1, SESSION_ID.toLong()))
if (focusRequested) sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_RELEASE.toLong()))
}
focusRequested = false
focusGranted = false
GearslipLog.i("audio: -> Stop, AudioFocusRequest(RELEASE) after $chunks chunks / $bytes bytes")
chunks = 0
bytes = 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) {
GearslipLog.w("audio: no ack for ${STALL_MS}ms with $inFlight in flight - reopening the window")
inFlight = 0
} else {
dropped++
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) {
GearslipLog.e("audio: the link died mid-stream", e)
started = false
return
}
inFlight++
chunks++
bytes += size
// The first chunk proves the whole path; after that, an occasional line is enough to show
// the car is still taking audio and to catch a window that has quietly closed.
if (chunks == 1 || chunks % CHUNK_LOG_EVERY == 0) {
GearslipLog.i(
"audio: -> $chunks chunks / $bytes bytes, inFlight=$inFlight/$maxUnacked, " +
"dropped=$dropped, focus=${if (focusGranted) "granted" else "not granted"}",
)
}
}
fun close() {
configTimer?.interrupt()
configTimer = null
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 FOCUS_STATE_GAIN = 1
private const val FOCUS_STATE_GAIN_TRANSIENT = 2
private const val SESSION_ID = 2
private const val STALL_MS = 2_000L
private const val CHUNK_LOG_EVERY = 200 // about every four seconds
private const val CONFIG_WAIT_MS = 2_500L
private val FOCUS_STATES = mapOf(
1 to "GAIN", 2 to "GAIN_TRANSIENT", 3 to "LOSS", 4 to "LOSS_TRANSIENT",
)
}
}
@@ -0,0 +1,118 @@
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 the
* phone is playing, and the car's audio channel, which is where it has to end up.
*
* Capture belongs to the car session, not to any one app. Android grants it through a consent
* dialog that cannot be answered in advance or remembered, so Gearslip asks once, when a head unit
* first offers a sink to play through, and then holds that grant for as long as the car is
* connected. Switching between media apps, or starting playback from the phone, costs nothing
* further: the capture is by audio usage rather than by app, so whatever the phone plays as media
* goes to the car.
*/
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
/** Whether the driver has asked for the car to be fed at all; see CarSettings.pipeAudio. */
@Volatile private var wanted = false
@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
GearslipLog.i("audio: the car offered a sink at ${link.sampleRate}Hz x${link.channels}")
when {
_capture.value == Capture.ON -> link.start()
wanted -> ask()
}
}
fun detach(link: Link) {
if (this.link === link) this.link = null
}
/**
* The car wants sound. Safe to call as often as the session likes: the consent dialog appears
* at most once, and only once there is a sink to play through - asking on the bench, where the
* sound has nowhere to go, would put a system dialog in front of the driver for nothing.
*/
fun request() {
wanted = true
when (_capture.value) {
Capture.ON, Capture.ASKING, Capture.DENIED -> return
Capture.OFF -> if (link == null) {
GearslipLog.i("audio: waiting for the car to offer an audio sink")
} else {
ask()
}
}
}
private fun ask() {
_capture.value = Capture.ASKING
val request = onConsentRequested
if (request == null) {
GearslipLog.w("audio: nothing on the phone can ask for capture consent")
_capture.value = Capture.DENIED
} else {
request()
}
}
/** The car went away. Everything is torn down; the next connection asks again. */
fun release() {
wanted = false
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,64 @@
package app.seb3thehacker.gearslip.audio
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.provider.Settings
import app.seb3thehacker.gearslip.GearslipLog
/**
* Keeps the phone's notifications readable while audio is being sent to the car.
*
* Playback capture hangs off a MediaProjection, and since Android 15 any full-screen projection
* makes SystemUI hide notification contents, on the assumption that someone is watching the
* screen. Nobody is: the audio projection never makes a display. The one exemption an ordinary app
* can reach is the developer setting that switches the protection off, so Gearslip turns it off for
* the length of the capture and back on afterwards.
*
* Writing it needs WRITE_SECURE_SETTINGS, granted once over adb. Without the grant this does
* nothing and notifications stay hidden while music plays, which is the platform's default.
*
* Phone mirroring deliberately does not use this: there the screen really is being shown.
*/
internal object ScreenShareGuard {
/** Settings.Global.DISABLE_SCREEN_SHARE_PROTECTIONS_FOR_APPS_AND_NOTIFICATIONS, hidden API. */
private const val SETTING = "disable_screen_share_protections_for_apps_and_notifications"
private const val PREFS = "gearslip_screen_share_guard"
/** Set while Gearslip has the protection switched off, so a crash can be undone at next launch. */
private const val KEY_LIFTED = "lifted"
fun canLift(context: Context): Boolean =
context.checkSelfPermission(Manifest.permission.WRITE_SECURE_SETTINGS) ==
PackageManager.PERMISSION_GRANTED
/** Call before the projection starts: the system decides what to hide at that moment. */
fun lift(context: Context) {
if (!canLift(context)) {
GearslipLog.i("audio: notifications will be hidden while capturing (no WRITE_SECURE_SETTINGS)")
return
}
val resolver = context.contentResolver
// Already off, whether by the driver in Developer options or by us: leave it as found.
if (Settings.Global.getInt(resolver, SETTING, 0) != 0) return
runCatching {
prefs(context).edit().putBoolean(KEY_LIFTED, true).commit()
Settings.Global.putInt(resolver, SETTING, 1)
GearslipLog.i("audio: screen share protection off for the drive, notifications stay visible")
}.onFailure { GearslipLog.w("audio: could not lift screen share protection: ${it.message}") }
}
/** Put the protection back, if Gearslip is the one that took it away. */
fun restore(context: Context) {
if (!prefs(context).getBoolean(KEY_LIFTED, false)) return
runCatching {
Settings.Global.putInt(context.contentResolver, SETTING, 0)
prefs(context).edit().remove(KEY_LIFTED).commit()
GearslipLog.i("audio: screen share protection back on")
}.onFailure { GearslipLog.w("audio: could not restore screen share protection: ${it.message}") }
}
private fun prefs(context: Context) =
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
}
@@ -0,0 +1,135 @@
package app.seb3thehacker.gearslip.call
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.provider.ContactsContract
import android.telecom.TelecomManager
import android.telephony.TelephonyCallback
import android.telephony.TelephonyManager
import app.seb3thehacker.gearslip.GearslipLog
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/** A caller as the car shows it: whatever the contacts app knows, or just the number. */
data class Caller(val number: String, val name: String?) {
val label: String get() = name ?: number.ifBlank { "Unknown number" }
}
sealed interface CallUiState {
data object None : CallUiState
data class Ringing(val caller: Caller, val canDecline: Boolean) : CallUiState
data class Active(val caller: Caller?) : CallUiState
}
/**
* Tracks the phone's call state for the car screen, and answers or declines through the two
* doors a sideloaded app can use: [android.telecom.TelecomManager.acceptRingingCall] to answer,
* and [GearslipCallScreening]'s response to decline one that is still ringing.
*
* Ending a call already answered needs MODIFY_PHONE_STATE, a privileged permission Gearslip
* cannot hold, so a call taken here is finished the way it would be without Gearslip at all -
* from the phone itself, a paired headset, or the car's own Bluetooth if it has any. Call audio
* stays on the phone's normal voice path too; nothing here sends it over the USB link, which
* only ever carries media playback.
*/
object CarCalls {
private val _state = MutableStateFlow<CallUiState>(CallUiState.None)
val state: StateFlow<CallUiState> = _state.asStateFlow()
private var telephony: TelephonyManager? = null
private var watcher: TelephonyCallback? = null
private var pendingRespond: ((allow: Boolean) -> Unit)? = null
private var ringingCaller: Caller? = null
fun start(context: Context) {
if (watcher != null) return
if (context.checkSelfPermission(Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) return
val manager = context.getSystemService(TelephonyManager::class.java) ?: return
val callback = object : TelephonyCallback(), TelephonyCallback.CallStateListener {
override fun onCallStateChanged(state: Int) = onPhoneState(state)
}
runCatching { manager.registerTelephonyCallback(context.mainExecutor, callback) }
.onSuccess { telephony = manager; watcher = callback }
.onFailure { GearslipLog.w("calls: could not watch call state: ${it.message}") }
}
fun stop() {
val callback = watcher ?: return
runCatching { telephony?.unregisterTelephonyCallback(callback) }
telephony = null
watcher = null
pendingRespond = null
ringingCaller = null
_state.value = CallUiState.None
}
private fun onPhoneState(state: Int) {
when (state) {
TelephonyManager.CALL_STATE_IDLE -> {
pendingRespond = null
ringingCaller = null
_state.value = CallUiState.None
}
TelephonyManager.CALL_STATE_OFFHOOK -> {
pendingRespond = null
_state.value = CallUiState.Active(ringingCaller)
}
TelephonyManager.CALL_STATE_RINGING -> {
// The number itself only ever arrives through the screening service; if this
// fires first, the ringing card appears once incomingCall() catches up.
ringingCaller?.let { _state.value = CallUiState.Ringing(it, pendingRespond != null) }
}
}
}
/** Called by [GearslipCallScreening] the moment a call comes in - the only place with a number. */
fun incomingCall(context: Context, number: String?, respond: (allow: Boolean) -> Unit) {
val caller = Caller(number.orEmpty(), number?.let { lookUpName(context, it) })
ringingCaller = caller
pendingRespond = respond
_state.value = CallUiState.Ringing(caller, canDecline = true)
}
/** The screening window closed by itself - the call rings normally now, and can't be declined from here. */
fun screeningExpired() {
pendingRespond = null
val caller = ringingCaller ?: return
if (_state.value is CallUiState.Ringing) _state.value = CallUiState.Ringing(caller, canDecline = false)
}
fun decline() {
val respond = pendingRespond ?: return
pendingRespond = null
runCatching { respond(false) }.onFailure { GearslipLog.w("calls: decline failed: ${it.message}") }
}
fun answer(context: Context) {
if (context.checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) != PackageManager.PERMISSION_GRANTED) {
GearslipLog.w("calls: answer needs ANSWER_PHONE_CALLS, which was never granted")
return
}
val telecom = context.getSystemService(TelecomManager::class.java) ?: return
runCatching { telecom.acceptRingingCall() }
.onFailure { GearslipLog.w("calls: answer failed: ${it.message}") }
}
private fun lookUpName(context: Context, number: String): String? {
if (context.checkSelfPermission(Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) return null
return runCatching {
val uri = Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI, Uri.encode(number))
context.contentResolver.query(uri, arrayOf(ContactsContract.PhoneLookup.DISPLAY_NAME), null, null, null)
?.use { c -> if (c.moveToFirst()) c.getString(0) else null }
}.getOrNull()
}
}
/** Posts to the main thread after a delay - used to give up on a screening response Gearslip never sent. */
internal fun mainThreadDelay(ms: Long, action: () -> Unit) {
Handler(Looper.getMainLooper()).postDelayed(action, ms)
}
@@ -0,0 +1,53 @@
package app.seb3thehacker.gearslip.call
import android.telecom.Call
import android.telecom.CallScreeningService
import app.seb3thehacker.gearslip.GearslipLog
/**
* Sees every incoming call before it rings, so the car screen can show who is calling and
* decline it in the same window a spam-blocker would use. Inert until the driver sets Gearslip
* as the phone's caller ID and spam app - the same on-device consent Truecaller-style apps ask
* for, not a privileged permission.
*
* The system gives an app a few seconds to answer before it lets the call ring on its own, so a
* response is sent either when the driver taps a button or when that window is about to close,
* whichever comes first. Answering a call that has already passed this point still works, since
* [CarCalls.answer] goes through the ordinary ringing-call API rather than through screening.
*/
class GearslipCallScreening : CallScreeningService() {
override fun onScreenCall(callDetails: Call.Details) {
val number = callDetails.handle?.schemeSpecificPart
var responded = false
val respond: (Boolean) -> Unit = { allow ->
if (!responded) {
responded = true
runCatching {
respondToCall(
callDetails,
CallResponse.Builder()
.setDisallowCall(!allow)
.setRejectCall(!allow)
.setSkipNotification(false)
.build(),
)
}.onFailure { GearslipLog.w("calls: screening response failed: ${it.message}") }
}
}
CarCalls.incomingCall(applicationContext, number, respond)
// Answer for the system if nobody has by the time its own patience runs out, so a call
// Gearslip is slow to show never gets silently blocked.
mainThreadDelay(SCREENING_WINDOW_MS) {
respond(true)
CarCalls.screeningExpired()
}
}
private companion object {
const val SCREENING_WINDOW_MS = 3_500L
}
}
@@ -0,0 +1,132 @@
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.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.material.icons.Icons
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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.unit.dp
import app.seb3thehacker.gearslip.call.CallUiState
import app.seb3thehacker.gearslip.call.CarCalls
/**
* Sits above whatever the car is showing while a call is ringing or under way. Ringing takes the
* whole screen, because nothing else matters more right now; an active call is a thin band along
* the top, since from here the driver can only see who it is, not end it - see [CarCalls].
*/
@Composable
fun CallOverlay(modifier: Modifier = Modifier) {
val state by CarCalls.state.collectAsState()
val context = LocalContext.current.applicationContext
when (val s = state) {
// Covers the box regardless of where the caller aligned it, so the alignment below is
// only ever felt by the active-call banner, which is the one that isn't full-screen.
is CallUiState.Ringing -> IncomingCall(s, onAnswer = { CarCalls.answer(context) }, onDecline = CarCalls::decline)
is CallUiState.Active -> ActiveCallBanner(s, modifier)
CallUiState.None -> {}
}
}
@Composable
private fun IncomingCall(s: CallUiState.Ringing, onAnswer: () -> Unit, onDecline: () -> Unit) {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surfaceContainerLowest) {
Column(
Modifier.fillMaxSize().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
Icons.Filled.Call,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(16.dp))
Text(
"Incoming call",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
Text(
s.caller.label,
style = MaterialTheme.typography.displaySmall,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(56.dp))
Row(horizontalArrangement = Arrangement.spacedBy(48.dp)) {
CallButton(Icons.Filled.Close, "Decline", MaterialTheme.colorScheme.error, enabled = s.canDecline, onClick = onDecline)
CallButton(Icons.Filled.Call, "Answer", MaterialTheme.colorScheme.primary, enabled = true, onClick = onAnswer)
}
if (!s.canDecline) {
Spacer(Modifier.height(20.dp))
Text(
"Already ringing out loud - answer here, or let it ring.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun CallButton(icon: ImageVector, label: String, color: Color, enabled: Boolean, onClick: () -> Unit) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
FilledIconButton(
onClick = onClick,
enabled = enabled,
modifier = Modifier.size(72.dp),
colors = IconButtonDefaults.filledIconButtonColors(containerColor = color, contentColor = Color.White),
) { Icon(icon, contentDescription = label, modifier = Modifier.size(30.dp)) }
Spacer(Modifier.height(8.dp))
Text(label, style = MaterialTheme.typography.labelLarge)
}
}
@Composable
private fun ActiveCallBanner(s: CallUiState.Active, modifier: Modifier = Modifier) {
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = modifier.fillMaxWidth(),
) {
Row(
Modifier.padding(horizontal = 20.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.Center,
) {
Icon(Icons.Filled.Call, contentDescription = null, modifier = Modifier.size(20.dp))
Spacer(Modifier.height(0.dp))
Text(
" On a call" + (s.caller?.let { " with ${it.label}" } ?: ""),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
)
}
}
}
@@ -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 = 18.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.Call
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("dialer", "Phone", Icons.Filled.Call) {
PhoneDialerScreen()
},
CarApp("settings", "Settings", Icons.Filled.Settings) {
CarSettingsScreen()
},
)
fun find(id: String): CarApp? = all.firstOrNull { it.id == id }
}
@@ -0,0 +1,208 @@
package app.seb3thehacker.gearslip.car
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.database.Cursor
import android.location.LocationManager
import android.provider.CalendarContract
import androidx.compose.foundation.layout.Arrangement
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.rememberScrollState
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.getValue
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.produceState
import androidx.compose.foundation.clickable
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 app.seb3thehacker.gearslip.GearslipLog
import java.net.HttpURLConnection
import java.net.URL
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
/**
* Opened from the nav bar clock: the week's calendar and the current weather, at a glance. Both
* need permissions granted on the phone (READ_CALENDAR, location) - if either was declined
* this just shows an empty state instead of asking again from inside the car UI.
*/
@Composable
fun CarDashboardScreen() {
val context = LocalContext.current.applicationContext
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
Text("Today", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
WeatherCard(context)
AgendaCard(context)
}
}
@Composable
private fun WeatherCard(context: Context) {
val state by Weather.state.collectAsState()
val navigator = LocalCarNavigator.current
LaunchedEffect(Unit) { Weather.refresh(context) }
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = MaterialTheme.shapes.extraLarge,
modifier = Modifier.clickable { navigator.weather() },
) {
Row(
Modifier.fillMaxWidth().padding(20.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
when (val s = state) {
WeatherState.Loading -> Text("Getting weather…", style = MaterialTheme.typography.bodyLarge)
is WeatherState.Unavailable -> Text(
s.reason, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
is WeatherState.Ready -> {
val w = s.data
WeatherIcon(w.code, w.isDay, size = 44.dp)
Column {
Text(
"${w.temp}${w.tempUnit} · ${Weather.describe(w.code)}",
style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold,
)
Text(w.place, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
}
}
@Composable
private fun AgendaCard(context: Context) {
val events by produceState<List<AgendaEvent>?>(cachedAgenda) {
value = withContext(Dispatchers.IO) { fetchAgenda(context) }.also { cachedAgenda = it }
}
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Coming up", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
val list = events
when {
list == null -> Text("Loading calendar…", style = MaterialTheme.typography.bodyMedium)
list.isEmpty() -> Text(
"Nothing in the next week.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
else -> list.forEach { EventRow(it) }
}
}
}
@Composable
private fun EventRow(event: AgendaEvent) {
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.large) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
eventWhen(event),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
)
Text(
event.title,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
/** "Today" events show just a time; later days get a weekday so a week's list stays readable. */
private fun eventWhen(event: AgendaEvent): String {
val zone = if (event.allDay) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
val start = Calendar.getInstance(zone).apply { timeInMillis = event.startMillis }
val today = Calendar.getInstance()
val sameDay = start.get(Calendar.YEAR) == today.get(Calendar.YEAR) &&
start.get(Calendar.DAY_OF_YEAR) == today.get(Calendar.DAY_OF_YEAR)
val time = if (event.allDay) "All day" else DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(event.startMillis))
if (sameDay || (event.allDay && start.before(today))) return time
return SimpleDateFormat("EEE", Locale.getDefault()).apply { timeZone = zone }.format(Date(event.startMillis)) + " " + time
}
private data class AgendaEvent(val title: String, val startMillis: Long, val allDay: Boolean)
@Volatile private var cachedAgenda: List<AgendaEvent>? = null
/** Loads the weather and the agenda ahead of time, so the dashboard opens with both in place. */
fun warmDashboard(context: Context) {
Weather.refresh(context, force = true)
cachedAgenda = fetchAgenda(context)
}
/**
* Empty list means no permission or nothing scheduled. Looks from the start of today so an
* event already under way still shows, a week ahead so tomorrow's does too, and skips ones
* that have finished.
*/
private fun fetchAgenda(context: Context): List<AgendaEvent> {
if (context.checkSelfPermission(Manifest.permission.READ_CALENDAR) != PackageManager.PERMISSION_GRANTED) {
return emptyList()
}
val now = System.currentTimeMillis()
val startOfToday = Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, 0); set(Calendar.MINUTE, 0); set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0)
}.timeInMillis
val end = startOfToday + 7 * 24 * 60 * 60 * 1000L
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon()
.appendPath(startOfToday.toString())
.appendPath(end.toString())
.build()
val events = mutableListOf<AgendaEvent>()
val projection = arrayOf(
CalendarContract.Instances.TITLE, CalendarContract.Instances.BEGIN,
CalendarContract.Instances.END, CalendarContract.Instances.ALL_DAY,
)
runCatching {
context.contentResolver.query(uri, projection, null, null, "${CalendarContract.Instances.BEGIN} ASC")
?.use { cursor: Cursor ->
while (cursor.moveToNext() && events.size < 10) {
val allDay = cursor.getInt(3) == 1
// All-day events are stored in UTC midnight, so "ended" means the day is over, not the instant.
if (!allDay && cursor.getLong(2) < now) continue
events.add(AgendaEvent(cursor.getString(0) ?: "(untitled)", cursor.getLong(1), allDay))
}
}
}.onFailure { GearslipLog.w("calendar query failed: ${it.message}") }
return events
}
@@ -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,150 @@
package app.seb3thehacker.gearslip.car
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.Close
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.getValue
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 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.NowPlaying
import kotlinx.coroutines.launch
/**
* The home screen: the map on the left, lyrics on the right when asked for.
*
* The map is whatever navigation app was used last, brought back on its own. Playback itself is
* controlled from the nav bar's pill everywhere, including here - this column only opens when the
* pill's lyrics button is on, and closes itself back to a full-width map once nothing is playing.
*/
@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 navigator = LocalCarNavigator.current
val showLyrics = phase == CarMedia.Phase.READY && now.isActive && navigator.lyricsOpen
Row(Modifier.fillMaxSize().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(
Modifier
.weight(if (showLyrics) 2.1f else 1f)
.fillMaxHeight()
.clip(RoundedCornerShape(EMBEDDED_RADIUS)),
) {
MapPane(navStatus, frame)
}
if (showLyrics) {
LyricsColumn(now, 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
val scope = androidx.compose.runtime.rememberCoroutineScope()
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 -> {
// IDLE with a last app on record means the driver stopped it themselves - it will
// not come back on its own, so the screen must offer a way to bring it back rather
// than sit on a "starting" message that never resolves.
Text(
if (hasLast) "Map stopped." else "No map app chosen yet.",
style = MaterialTheme.typography.titleMedium,
)
if (hasLast) {
TextButton(onClick = { scope.launch { CarServices.reconnectNav(frame) } }) { Text("Reopen") }
} else {
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") }
}
}
}
}
/**
* Lyrics for whatever the nav bar's pill is playing, beside the map. Transport controls live in
* the pill itself now, not here - this column has exactly one job.
*/
@Composable
private fun LyricsColumn(now: NowPlaying, modifier: Modifier) {
val navigator = LocalCarNavigator.current
Surface(
modifier = modifier,
shape = RoundedCornerShape(EMBEDDED_RADIUS),
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Column(Modifier.fillMaxSize().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
now.title.ifEmpty { "Lyrics" },
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
IconButton(onClick = navigator::toggleLyrics, modifier = Modifier.size(36.dp)) {
Icon(Icons.Filled.Close, "Hide lyrics", Modifier.size(20.dp))
}
}
Spacer(Modifier.height(4.dp))
LyricsPanel(now, Modifier.weight(1f).fillMaxWidth())
}
}
}
@@ -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,252 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.Image
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
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.Call
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 android.content.Context
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.ui.graphics.Color
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.background
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import app.seb3thehacker.gearslip.host.KnownApps
import app.seb3thehacker.gearslip.host.CarAppCatalog
import app.seb3thehacker.gearslip.host.TemplateApp
import app.seb3thehacker.gearslip.media.MediaApp
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,
/** Tested from start to finish with Gearslip; drawn with a check mark. */
val verified: Boolean = false,
/** Known not to work; drawn with a red X instead of being left off the launcher. */
val broken: Boolean = false,
val pinned: Boolean = false,
/** Null for the built-in tiles (Web, Screen sharing, Settings) - nothing to pin them as. */
val onLongClick: (() -> Unit)? = null,
val onClick: () -> Unit,
)
/** A launcher app's data, kept apart from [Tile] so it can be cached without capturing click state. */
internal class Entry(
val label: String,
val icon: ImageBitmap?,
val template: TemplateApp? = null,
val media: MediaApp? = null,
) {
/** What a pin or a nav-bar shortcut remembers this entry as; null for nothing pinnable. */
val componentId: String? get() = (template?.component ?: media?.component)?.flattenToString()
}
/**
* The installed car and media apps with their icons. Scanning the package manager and decoding
* icons is slow, so [Prefetch] does it ahead of time and again at the start of each car session,
* which is when new installs are picked up.
*/
internal object LauncherCache {
@Volatile var entries: List<Entry>? = null
/** Returns the scan if there is one; [force] rescans. Synchronized so two callers share one scan. */
@Synchronized
fun load(context: Context, force: Boolean = false): List<Entry> {
if (!force) entries?.let { return it }
fun iconOf(pkg: String) = runCatching {
context.packageManager.getApplicationIcon(pkg).toBitmap(128, 128).asImageBitmap()
}.getOrNull()
val nav = CarAppCatalog.installed(context).map { Entry(it.label, iconOf(it.component.packageName), template = it) }
val media = MediaCatalog.installed(context).map { Entry(it.label, iconOf(it.component.packageName), media = it) }
return (nav + media).sortedBy { it.label.lowercase() }.also { entries = it }
}
}
/** Rescans the car and media apps now, replacing the cached list; the launcher keeps showing the old one meanwhile. */
fun warmLauncherApps(context: Context) { LauncherCache.load(context, force = true) }
/**
* Connects or opens [entry] - what tapping its tile does, shared with the nav bar's pinned
* shortcuts and its open-app icon so there is exactly one place that knows how to launch either
* kind of app.
*/
internal fun launchEntry(entry: Entry, navigator: CarNavigator, frame: CarEnvironment.Frame) {
entry.template?.let { CarServices.connectNav(it, frame); navigator.home() }
entry.media?.let { CarServices.openMedia(it); navigator.media() }
}
/** 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()
// Painted from the cache at once; scanned only if [Prefetch] has not got there first.
val installed by produceState(initialValue = LauncherCache.entries ?: emptyList(), context) {
value = LauncherCache.entries ?: withContext(Dispatchers.IO) { LauncherCache.load(context) }
}
val pinned by CarSettings.pinnedApps.collectAsState()
val tiles = listOf(
Tile("Web", glyph = Icons.Filled.Search) { navigator.open("web") },
Tile("Screen sharing", glyph = Icons.Filled.Share) { navigator.open("phone") },
Tile("Phone", glyph = Icons.Filled.Call) { navigator.open("dialer") },
) + installed.map { entry ->
val pkg = entry.template?.component?.packageName ?: entry.media?.component?.packageName
val id = entry.componentId
Tile(
entry.label, entry.icon,
verified = pkg != null && KnownApps.works(pkg),
broken = pkg != null && KnownApps.isBroken(pkg),
pinned = id != null && id in pinned,
onLongClick = id?.let { { CarSettings.togglePin(it) } },
) { launchEntry(entry, navigator, frame) }
} + 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) }
if (tiles.any { it.verified }) {
item(span = { GridItemSpan(maxLineSpan) }) { VerifiedLegend() }
}
}
}
/** Says what the check mark on a tile means, once, under the grid. */
@Composable
private fun VerifiedLegend() {
Row(
Modifier.fillMaxWidth().padding(top = 4.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
VerifiedBadge(16.dp)
Spacer(Modifier.width(8.dp))
Text(
"Tested and working with Gearslip",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
/** A green disc with a white check: the same mark on every tile and in the legend. */
@Composable
private fun VerifiedBadge(size: androidx.compose.ui.unit.Dp, modifier: Modifier = Modifier) {
Box(
modifier.size(size).background(Color(0xFF43A047), CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Filled.Check, contentDescription = "Tested and working", tint = Color.White, modifier = Modifier.size(size * 0.66f))
}
}
/** A red disc with a white X: marks a tile known not to work with Gearslip yet. */
@Composable
private fun BrokenBadge(size: androidx.compose.ui.unit.Dp, modifier: Modifier = Modifier) {
Box(
modifier.size(size).background(Color(0xFFD32F2F), CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Filled.Close, contentDescription = "Doesn't work with Gearslip", tint = Color.White, modifier = Modifier.size(size * 0.66f))
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun AppTile(tile: Tile) {
val shape = MaterialTheme.shapes.extraLarge
Surface(
modifier = Modifier
.height(120.dp)
.let { if (tile.pinned) it.border(2.dp, MaterialTheme.colorScheme.primary, shape) else it }
// combinedClickable, not Surface's own onClick, so a long press can mean something
// different from a tap - pinning is deliberately the same gesture everywhere apps
// are shown (here and, once pinned, the shortcut itself on the nav bar).
.combinedClickable(onClick = tile.onClick, onLongClick = tile.onLongClick),
shape = shape,
color = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
) {
Box(Modifier.fillMaxSize()) {
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,
)
}
if (tile.verified) {
VerifiedBadge(24.dp, Modifier.align(Alignment.TopEnd).padding(8.dp))
}
if (tile.broken) {
BrokenBadge(24.dp, Modifier.align(Alignment.TopEnd).padding(8.dp))
}
}
}
}
@@ -0,0 +1,109 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
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
/** Opened from the clock: the day's calendar and the weather, nothing that needs a keyboard. */
data object Dashboard : CarScreen
/** Opened from the weather chip or the dashboard: now, the next hours and the week. */
data object Weather : 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
}
/** One step on the breadcrumb trail: the screen it shows and the word for it in the trail. */
data class Crumb(val screen: CarScreen, val label: String)
/**
* Where the car UI is, and how it got there. A car touchscreen has no hardware back button, so
* every screen that isn't reached from [home] is pushed onto a trail (like a browser's history,
* or a file manager's breadcrumb bar) rather than replacing the last one outright - that's what
* lets the breadcrumb bar show "Home > Apps > Settings" and jump straight back to any of them,
* and lets [back] undo exactly one step instead of always dumping you back at Home. An open app
* can claim a back step first (a browser going back a page) through [backInterceptor].
*/
class CarNavigator(startId: String) {
private val stack = mutableStateListOf(
Crumb(
if (CarApps.find(startId) != null) CarScreen.App(startId) else CarScreen.Home,
"Home",
),
)
/** The whole trail, root first. Size 1 means "at Home" - the breadcrumb bar hides then. */
val trail: List<Crumb> get() = stack
val current: CarScreen get() = stack.last().screen
var backInterceptor: (() -> Boolean)? = null
/** Pushes a new step, unless it's the same screen already on top - tapping the button for
* where you already are shouldn't grow the trail. */
private fun push(screen: CarScreen, label: String) {
if (stack.last().screen == screen) return
stack.add(Crumb(screen, label))
}
/** The one way back to a clean slate: resets the whole trail to just Home. */
fun home() {
stack.clear()
stack.add(Crumb(CarScreen.Home, "Home"))
}
/**
* The nav bar's now-playing pill is the player everywhere; this is only whether the home
* screen is additionally showing lyrics for it in the column beside the map. Toggled by the
* pill's own lyrics button, so it means the same thing wherever the pill is visible, even
* though only the home screen actually has the room to show it.
*/
var lyricsOpen by mutableStateOf(false)
private set
fun toggleLyrics() { lyricsOpen = !lyricsOpen }
fun media() { push(CarScreen.Media, "Media") }
fun apps() { push(CarScreen.Apps, "Apps") }
fun settings() { push(CarScreen.Settings, "Settings") }
fun dashboard() { push(CarScreen.Dashboard, "Dashboard") }
fun weather() { push(CarScreen.Weather, "Weather") }
fun notifications(replyTo: String? = null) { push(CarScreen.Notifications(replyTo), "Notifications") }
fun open(id: String) { push(CarScreen.App(id), CarApps.find(id)?.label ?: id) }
/** Jumps straight to a step already on the trail, dropping everything after it - what
* tapping a breadcrumb does. Index 0 (Home) is always on the trail, so this always succeeds. */
fun jumpTo(index: Int) {
while (stack.size > index + 1) stack.removeAt(stack.lastIndex)
}
/** One step back, same as tapping the crumb before the current one. */
fun back() {
if (backInterceptor?.invoke() == true) return
if (stack.size > 1) stack.removeAt(stack.lastIndex)
}
}
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,122 @@
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)
app.seb3thehacker.gearslip.call.CarCalls.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()
}
/** Undoes [stopNav]'s hold so the last map app comes back, same as it would after a restart. */
suspend fun reconnectNav(frame: CarEnvironment.Frame) {
navStopped = false
autostart(frame)
}
// --- 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
_mediaApp.value = app
autoplayPending = autoplay
media.connect(app)
// Not torn down and rebuilt per app: the capture covers whatever the phone plays, so
// switching media apps never costs the driver another consent dialog.
if (CarSettings.pipeAudio.value) CarAudio.request()
}
/** 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) }
app.seb3thehacker.gearslip.call.CarCalls.stop()
runCatching { nav.disconnect() }
runCatching { media.disconnect() }
CarAudio.release()
_mediaApp.value = null
navStopped = false
}
}
@@ -0,0 +1,142 @@
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"
private const val KEY_PINNED = "car_pinned_apps"
/** How many shortcuts the nav bar makes room for; a driver reaching for one needs it to
* still be on the bar, not off the edge of a long list. */
const val MAX_PINNED = 3
/** 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(true)
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
private val pinnedFlow = MutableStateFlow<Set<String>>(emptySet())
/** Flattened component names of the apps long-pressed onto the nav bar as shortcuts. */
val pinnedApps: StateFlow<Set<String>> = pinnedFlow
/** 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. On by default, since
* sound in the car is the point of the exercise; the platform allows the capture only behind a
* consent dialog, which [app.seb3thehacker.gearslip.audio.CarAudio] raises once the car has
* somewhere to play it. Turned off, the app plays through the phone's normal output instead.
*/
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, "true") == "true"
nightFlow.value = runCatching {
NightMode.valueOf(AppSettings.getString(app, KEY_NIGHT, NightMode.AUTO.name))
}.getOrDefault(NightMode.AUTO)
pinnedFlow.value = AppSettings.getString(app, KEY_PINNED, "")
.split(",").filter { it.isNotEmpty() }.toSet()
}
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()
}
/** Long-pressed onto or off the nav bar. Silently does nothing past [MAX_PINNED] rather than
* bumping the oldest pin - a driver who filled the bar on purpose shouldn't lose one by accident. */
fun togglePin(component: String) {
val current = pinnedFlow.value
val next = when {
component in current -> current - component
current.size >= MAX_PINNED -> return
else -> current + component
}
pinnedFlow.value = next
AppSettings.putString(app, KEY_PINNED, next.joinToString(","))
}
}
@@ -0,0 +1,264 @@
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.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.AppSettings
import app.seb3thehacker.gearslip.ui.CertSummary
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Settings that make sense while sitting in the car. The web page URL is edited here, with the
* car's own on-screen keyboard ([CarKeyboard]); certificate management still needs a real
* keyboard and file picker, so it stays on the phone's Settings screen.
*/
@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(
"Through Gearslip sends the sound over the USB link. Android asks for consent the " +
"first time a media app plays in the car, and some apps refuse to be captured at " +
"all. Phone output needs no permission: the app plays as it normally would, for " +
"example over Bluetooth to the car.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
WebPageSetting()
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…")
}
}
}
@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)
}
}
}
}
}
}
/** The URL (or raw HTML) the Web app shows. Edited with [CarKeyboard] - no phone in hand needed. */
@Composable
private fun WebPageSetting() {
val context = LocalContext.current
var editing by remember { mutableStateOf(false) }
var url by remember { mutableStateOf(AppSettings.startupUrl(context)) }
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Web page", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
Surface(
onClick = { editing = !editing },
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = Modifier.fillMaxWidth(),
) {
Text(
url.ifEmpty { "DuckDuckGo (default) - tap to set a different page" },
style = MaterialTheme.typography.bodyLarge,
color = if (url.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
)
}
Text(
"Shown by the Web app on the launcher. Applies on the next connection.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (editing) {
CarKeyboard(
url,
onTextChange = {
url = it
AppSettings.setStartupUrl(context, it)
},
onSubmit = { editing = false },
)
}
}
}
@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,474 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
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.Notifications
import androidx.compose.material.icons.filled.PlayArrow
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.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.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import app.seb3thehacker.gearslip.media.CarMedia
import app.seb3thehacker.gearslip.media.MediaArt
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.host.CarAppConnection
import app.seb3thehacker.gearslip.notify.CarNotifications
import androidx.core.graphics.drawable.toBitmap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
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) }
val controlsWake = remember { MapControlsWake() }
LaunchedEffect(controlsWake) {
while (true) {
delay(250)
controlsWake.tick()
}
}
remember(appContext) { Prefetch.warm(appContext, force = true); 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,
LocalMapControlsWake provides controlsWake,
) {
MaterialTheme(colorScheme = colors) {
Surface(
Modifier
.fillMaxSize()
// Watches every touch without taking any: first in line, consumes nothing.
.pointerInput(controlsWake) {
awaitPointerEventScope {
while (true) {
awaitPointerEvent(PointerEventPass.Initial)
controlsWake.touch()
}
}
},
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(),
)
}
val screen = navigator.current
Column(Modifier.fillMaxSize().padding(pad)) {
// Header navigation (breadcrumb trail) - off for now, kept to tweak later.
// BreadcrumbBar(navigator)
Box(Modifier.weight(1f).fillMaxWidth()) {
when (screen) {
CarScreen.Home -> CarHome()
CarScreen.Apps -> CarLauncher()
CarScreen.Media -> mediaApp?.let { MediaScreen(it) { navigator.back() } }
CarScreen.Settings -> CarSettingsScreen()
CarScreen.Dashboard -> CarDashboardScreen()
CarScreen.Weather -> WeatherScreen()
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))
}
// Above the popup: a call is more urgent than any notification.
CallOverlay(Modifier.align(Alignment.TopCenter))
}
CarNavBar(navigator)
}
}
}
}
}
/**
* "Home > Apps > Settings", like a file manager's address bar: hidden at Home itself (nothing to
* show yet), and grows by one crumb for every screen pushed from there. The back arrow undoes one
* step; a crumb jumps straight to it, dropping everything after - this is the only on-screen way
* back at all, since a car touchscreen has no hardware button for it.
*/
@Composable
private fun BreadcrumbBar(navigator: CarNavigator) {
val trail = navigator.trail
if (trail.size <= 1) return
Row(
Modifier.fillMaxWidth().height(40.dp).padding(start = 2.dp, end = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = navigator::back, modifier = Modifier.size(36.dp)) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back", Modifier.size(20.dp))
}
Spacer(Modifier.width(2.dp))
trail.forEachIndexed { index, crumb ->
val isLast = index == trail.lastIndex
Text(
crumb.label,
style = MaterialTheme.typography.labelLarge,
fontWeight = if (isLast) FontWeight.SemiBold else FontWeight.Normal,
color = if (isLast) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = if (isLast) Modifier else Modifier
.clip(RoundedCornerShape(6.dp))
.clickable { navigator.jumpTo(index) }
.padding(horizontal = 4.dp, vertical = 2.dp),
)
if (!isLast) {
Text(
"›",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 2.dp),
)
}
}
}
}
/**
* Apps and open/pinned shortcuts on the left; now-playing pill against them; clock (tap for
* calendar and weather), phone battery and the darkness signal on the right. Icon-only: a driver
* reads a glyph faster than a label, and it keeps the bar short enough to leave the app underneath
* more room.
*/
@Composable
private fun CarNavBar(navigator: CarNavigator) {
val battery by CarEnvironment.battery.collectAsState()
val now by rememberNow()
val weather by Weather.state.collectAsState()
val weatherData = (weather as? WeatherState.Ready)?.data
// The one app that's actually open and not otherwise reachable from this bar: media has its
// own pill already, so the only "open app" icon worth showing is whichever map is connected.
val navStatus by CarServices.nav.status.collectAsState()
val lastNav by CarSettings.lastNav.collectAsState()
val openMapComponent = lastNav?.takeIf { navStatus.phase == CarAppConnection.Phase.RUNNING }
val context = LocalContext.current
val pinnedIds by CarSettings.pinnedApps.collectAsState()
val entries by produceState(LauncherCache.entries ?: emptyList(), context) {
value = LauncherCache.entries ?: withContext(Dispatchers.IO) { LauncherCache.load(context) }
}
val pinnedEntries = remember(entries, pinnedIds) {
pinnedIds.mapNotNull { id -> entries.find { it.componentId == id } }
}
val frame by CarEnvironment.frame.collectAsState()
Surface(color = MaterialTheme.colorScheme.surfaceContainer) {
BoxWithConstraints(Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 10.dp)) {
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
// Home and Apps are two different places now, not a toggle on one button - the
// breadcrumb trail is how you retrace your steps, but reaching for either of
// these two from three screens deep in a media app shouldn't mean reading it
// first.
NavItem(Icons.Filled.Home, navigator.current == CarScreen.Home) { navigator.home() }
Spacer(Modifier.width(6.dp))
NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() }
openMapComponent?.let { component ->
Spacer(Modifier.width(6.dp))
NavAppIcon(component, contentDescription = "Map", onClick = navigator::home)
}
pinnedEntries.forEach { entry ->
Spacer(Modifier.width(6.dp))
entry.componentId?.let { id ->
NavAppIcon(id, contentDescription = entry.label) { launchEntry(entry, navigator, frame) }
}
}
Box(Modifier.weight(1f).padding(horizontal = 10.dp), contentAlignment = Alignment.CenterStart) {
NavNowPlaying(navigator, selected = navigator.current == CarScreen.Media)
}
// Weather no longer has its own chip - the temperature alone (no icon, no room
// for one) sits where "Night"/"Day" used to, freeing a whole chip's width for the
// Home button above.
NavChip(navigator.current == CarScreen.Dashboard, { navigator.dashboard() }) {
Column(horizontalAlignment = Alignment.End) {
Text(
DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(now)),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp)) {
if (battery.percent >= 0) {
Text(
"${battery.percent}%",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (battery.charging) {
ChargingGlyph(Modifier.size(13.dp))
}
}
weatherData?.let { w ->
Text(
" · ${w.temp}°",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
Spacer(Modifier.width(10.dp))
NavItem(
Icons.Filled.Notifications, navigator.current is CarScreen.Notifications, padding = 8.dp,
) { navigator.notifications() }
}
}
}
}
/** One open or pinned app's icon on the nav bar - the same fixed size and shape either way. */
@Composable
private fun NavAppIcon(component: String, contentDescription: String?, onClick: () -> Unit) {
val context = LocalContext.current
val pkg = remember(component) { android.content.ComponentName.unflattenFromString(component)?.packageName }
val icon by produceState<androidx.compose.ui.graphics.ImageBitmap?>(null, pkg) {
value = pkg?.let {
runCatching { context.packageManager.getApplicationIcon(it).toBitmap(96, 96).asImageBitmap() }.getOrNull()
}
}
Box(
Modifier
.size(44.dp)
.clip(RoundedCornerShape(14.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
icon?.let { Image(it, contentDescription, Modifier.size(30.dp).clip(RoundedCornerShape(8.dp))) }
}
}
/** A tappable group in the bar that stays highlighted while its screen is open, like [NavItem]. */
@Composable
private fun NavChip(selected: Boolean, onClick: () -> Unit, content: @Composable () -> Unit) {
val scheme = MaterialTheme.colorScheme
Box(
Modifier
.clip(RoundedCornerShape(18.dp))
.background(if (selected) scheme.secondaryContainer else scheme.surfaceContainer)
.clickable(onClick = onClick)
.padding(horizontal = 10.dp, vertical = 6.dp),
contentAlignment = Alignment.Center,
) { content() }
}
@Composable
private fun NavItem(icon: ImageVector, selected: Boolean, padding: androidx.compose.ui.unit.Dp = 12.dp, onClick: () -> Unit) {
val scheme = MaterialTheme.colorScheme
Box(
Modifier
.clip(RoundedCornerShape(18.dp))
.background(if (selected) scheme.secondaryContainer else scheme.surfaceContainer)
.clickable(onClick = onClick)
.padding(padding),
contentAlignment = Alignment.Center,
) {
Icon(icon, contentDescription = null, modifier = Modifier.size(28.dp))
}
}
/** 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)
}
}
/**
* The one persistent player, everywhere - not just a Home-screen extra. Art and title open the
* full media screen; skip and play/pause work right here; the last button toggles the lyrics
* panel beside the map on Home (see [CarHome]), the pill's own lyrics affordance wherever it sits.
*/
@Composable
private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier: Modifier = Modifier) {
val media = CarServices.media
val phase by media.phase.collectAsState()
val now by media.now.collectAsState()
if (phase != CarMedia.Phase.READY || !now.isActive) return
val context = LocalContext.current
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
Surface(
modifier = modifier,
shape = RoundedCornerShape(50),
color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.secondaryContainer,
contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSecondaryContainer,
) {
Row(
Modifier.padding(start = 6.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Row(
Modifier
.weight(1f, fill = false)
.clip(RoundedCornerShape(50))
.clickable { navigator.media() },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Box(Modifier.size(40.dp).clip(RoundedCornerShape(50)).background(MaterialTheme.colorScheme.surfaceVariant)) {
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
}
Text(
now.title.ifEmpty { "Nothing playing" },
modifier = Modifier.weight(1f, fill = false),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
FilledIconButton(onClick = media::togglePlay, modifier = Modifier.size(44.dp)) {
Icon(
if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow,
if (now.playing) "Pause" else "Play",
Modifier.size(26.dp),
)
}
FilledIconButton(
onClick = navigator::toggleLyrics,
modifier = Modifier.size(44.dp),
colors = androidx.compose.material3.IconButtonDefaults.filledIconButtonColors(
containerColor = if (navigator.lyricsOpen) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor = if (navigator.lyricsOpen) MaterialTheme.colorScheme.onPrimary
else MaterialTheme.colorScheme.onSurfaceVariant,
),
) {
LyricsGlyph(Modifier.size(22.dp))
}
}
}
}
/**
* A card with two lines of text on it - read as "lyrics"/"captions" at a glance, which no icon in
* the core Material set does (the closest, a bullet list, reads as a menu, not song text). Drawn
* rather than a vector asset since the whole glyph is two shapes.
*/
@Composable
private fun LyricsGlyph(modifier: Modifier = Modifier) {
val tint = androidx.compose.material3.LocalContentColor.current
androidx.compose.foundation.Canvas(modifier) {
val stroke = size.minDimension * 0.11f
drawRoundRect(
color = tint,
cornerRadius = androidx.compose.ui.geometry.CornerRadius(size.minDimension * 0.22f),
style = androidx.compose.ui.graphics.drawscope.Stroke(width = stroke),
)
val lineStyle = androidx.compose.ui.graphics.drawscope.Stroke(width = stroke * 0.85f, cap = androidx.compose.ui.graphics.StrokeCap.Round)
val startX = size.width * 0.24f
listOf(0.40f, 0.64f).forEach { fy ->
val endX = size.width * (if (fy < 0.5f) 0.76f else 0.62f)
drawLine(tint, androidx.compose.ui.geometry.Offset(startX, size.height * fy), androidx.compose.ui.geometry.Offset(endX, size.height * fy), strokeWidth = lineStyle.width, cap = lineStyle.cap)
}
}
}
/**
* A filled lightning bolt, next to the battery percentage instead of the word "charging" - the
* core Material icon set this project ships (no extended pack, to keep the app small) has no
* bolt, so this is drawn the same way [LyricsGlyph] is.
*/
@Composable
private fun ChargingGlyph(modifier: Modifier = Modifier, tint: Color = MaterialTheme.colorScheme.onSurfaceVariant) {
androidx.compose.foundation.Canvas(modifier) {
val w = size.width
val h = size.height
val bolt = androidx.compose.ui.graphics.Path().apply {
moveTo(w * 0.58f, 0f)
lineTo(w * 0.12f, h * 0.58f)
lineTo(w * 0.46f, h * 0.58f)
lineTo(w * 0.42f, h)
lineTo(w * 0.88f, h * 0.40f)
lineTo(w * 0.54f, h * 0.40f)
close()
}
drawPath(bolt, tint)
}
}
@@ -0,0 +1,38 @@
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
import android.os.SystemClock
/**
* Whether the map's buttons (settings, pan, zoom, locate) are showing.
*
* They fade out after [IDLE_MS] with no touch anywhere on the car screen, to leave the map clear,
* and any touch brings them back. [touch] is called for every pointer event and is cheap: it only
* writes state when the buttons were actually hidden.
*/
class MapControlsWake {
var awake by mutableStateOf(true)
private set
private var lastTouch = SystemClock.uptimeMillis()
fun touch() {
lastTouch = SystemClock.uptimeMillis()
if (!awake) awake = true
}
/** Called on a timer; hides the buttons once nothing has touched the screen for [IDLE_MS]. */
fun tick() {
if (awake && SystemClock.uptimeMillis() - lastTouch >= IDLE_MS) awake = false
}
companion object {
const val IDLE_MS = 4_000L
}
}
val LocalMapControlsWake = staticCompositionLocalOf { MapControlsWake() }
@@ -0,0 +1,499 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
import android.provider.Settings
import android.content.Intent
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
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 app.seb3thehacker.gearslip.media.QueueTrack
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 queue by media.queue.collectAsStateWithLifecycle()
val browse by media.browse.collectAsStateWithLifecycle()
val rejection by media.rejection.collectAsStateWithLifecycle()
val capture by CarAudio.capture.collectAsStateWithLifecycle()
var tab by remember { mutableStateOf(Tab.NOW_PLAYING) }
// 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 -> when (rejection) {
CarMedia.Rejection.NEEDS_NOTIFICATION_ACCESS -> Notice(
"${app.label} keeps its library to itself.",
"Gearslip can still control it once it has Notification access on the phone. " +
"Allow it under Settings > Notifications > Notification access.",
action = "Open on phone" to {
val app = context.applicationContext
runCatching {
app.startActivity(
Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
}
},
)
CarMedia.Rejection.REFUSED -> 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),
) {
// The art rail sits at the top, level with the tab row beside it, on every tab -
// not under a full-width tab bar the way it used to be. Transport and the
// progress bar stay on it always; only the app's extra buttons (shuffle, repeat,
// like) are exclusive to the "Now playing" tab's own content area, since those are
// the one thing that isn't already always visible here.
NowPlayingRail(now, media, Modifier.width(220.dp).fillMaxHeight())
Column(Modifier.weight(1f).fillMaxHeight()) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
MediaTab("Now playing", tab == Tab.NOW_PLAYING, Modifier.weight(1f)) { tab = Tab.NOW_PLAYING }
MediaTab("Browse", tab == Tab.BROWSE, Modifier.weight(1f)) { tab = Tab.BROWSE }
MediaTab("Up next", tab == Tab.QUEUE, Modifier.weight(1f)) { tab = Tab.QUEUE }
MediaTab("Lyrics", tab == Tab.LYRICS, Modifier.weight(1f)) { tab = Tab.LYRICS }
}
Spacer(Modifier.height(8.dp))
Box(Modifier.weight(1f).fillMaxWidth()) {
when (tab) {
Tab.NOW_PLAYING -> NowPlayingExtras(now, media, Modifier.fillMaxSize())
Tab.BROWSE -> BrowsePanel(browse, media, app.label, Modifier.fillMaxSize())
Tab.QUEUE -> QueuePanel(queue, now, media, Modifier.fillMaxSize())
Tab.LYRICS -> LyricsPanel(now, Modifier.fillMaxSize())
}
}
}
}
}
}
}
private enum class Tab { NOW_PLAYING, BROWSE, QUEUE, LYRICS }
/** A bigger, easier-to-hit tab than a plain [TextButton] - this row is the only navigation on the screen. */
@Composable
private fun MediaTab(label: String, selected: Boolean, modifier: Modifier, onClick: () -> Unit) {
Surface(
onClick = onClick,
modifier = modifier.height(48.dp),
shape = MaterialTheme.shapes.large,
color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh,
contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
) {
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
Text(label, style = MaterialTheme.typography.titleMedium, fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal)
}
}
}
/**
* 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
internal 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?, action: Pair<String, () -> Unit>? = null) {
Column(
Modifier.fillMaxSize().padding(horizontal = 24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(title, style = MaterialTheme.typography.titleMedium, textAlign = androidx.compose.ui.text.style.TextAlign.Center)
detail?.let {
Text(
it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
}
action?.let { (label, run) ->
Spacer(Modifier.height(12.dp))
androidx.compose.material3.FilledTonalButton(onClick = run) { Text(label) }
}
}
}
/** "1:05" for under a minute past the hour, "1:01:05" once an hour is involved. */
private fun formatDuration(ms: Long): String {
val totalSeconds = (ms / 1000).coerceAtLeast(0)
val hours = totalSeconds / 3600
val minutes = (totalSeconds % 3600) / 60
val seconds = totalSeconds % 60
return if (hours > 0) "%d:%02d:%02d".format(hours, minutes, seconds) else "%d:%02d".format(minutes, seconds)
}
// --- now playing -----------------------------------------------------------------------------
/**
* The art rail beside every tab: art, title, transport and the seek bar - always visible no
* matter which tab is open. Only the app's extra buttons (shuffle, repeat, like) are held back
* for [NowPlayingExtras], since those are the one thing that isn't already always shown here.
*/
@Composable
private fun NowPlayingRail(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)
}
}
// Whether the progress bar (and its duration text) is showing depends on whether the app has
// reported a duration yet, which can arrive a beat after everything else - so this rail's
// content height isn't fixed. A plain fillMaxHeight Column just clips whatever doesn't fit
// once that row appears; scrolling means it's always reachable instead.
BoxWithConstraints(modifier) {
// Off the space actually available rather than a fixed dp value, so it doesn't overrun a
// short car frame the way a flat size did on anything shorter than a phone screen.
val artSize = minOf(maxWidth, maxHeight * 0.45f).coerceAtLeast(96.dp)
Column(Modifier.verticalScroll(rememberScrollState())) {
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.size(artSize),
) {
art?.let {
Image(it.asImageBitmap(), null, Modifier.fillMaxSize().clip(MaterialTheme.shapes.large), contentScale = ContentScale.Crop)
}
}
Spacer(Modifier.height(16.dp))
Text(
now.title.ifEmpty { "Nothing playing" },
style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold,
maxLines = 2, overflow = TextOverflow.Ellipsis,
)
Text(
listOf(now.artist, now.album).filter { it.isNotEmpty() }.joinToString(" - "),
style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
now.error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, maxLines = 2) }
Spacer(Modifier.height(16.dp))
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.fillMaxWidth().padding(vertical = 4.dp),
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(formatDuration(position), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(formatDuration(now.durationMs), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Spacer(Modifier.height(12.dp))
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.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))
}
}
}
}
}
/**
* The "Now playing" tab's own content: just the app's extra buttons (shuffle, repeat, like), by
* name - their icons are resources inside the app's own package, so there's nothing to draw but
* the label. Transport and progress already live on [NowPlayingRail], visible on every tab.
*/
@Composable
private fun NowPlayingExtras(now: NowPlaying, media: CarMedia, modifier: Modifier) {
Column(modifier, verticalArrangement = Arrangement.Center) {
Row(horizontalArrangement = Arrangement.spacedBy(8.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, appLabel: String, modifier: Modifier) {
Column(modifier) {
// At the library root there's no trail and nowhere to go up to, so there's nothing this
// row could say that the app's name in the top-left corner hasn't already - showing it
// again here (as "Browse", or as the app's own name) was just the same label twice.
if (media.canGoUp || browse.trail.isNotEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (media.canGoUp) {
IconButton(onClick = media::up) { Icon(Icons.Filled.ArrowBack, "Up") }
}
browse.trail.lastOrNull()?.let {
Text(
it,
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
}
}
when {
browse.loading -> Notice("Loading…", null)
browse.unavailable -> Notice(
"$appLabel keeps its library to itself.",
"Start music in the app on your phone. Play, pause, skip and seek work from here.",
)
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) }
}
}
}
}
// --- up next -----------------------------------------------------------------------------------
/**
* Whatever the app has queued up, starting right after the track that's playing now - not the
* whole queue from the top, which would just repeat what [NowPlayingRail] already shows. Apps
* that never publish a queue (many don't) show an empty notice rather than a permanently blank tab.
*/
@Composable
private fun QueuePanel(queue: List<QueueTrack>, now: NowPlaying, media: CarMedia, modifier: Modifier) {
val upNext = remember(queue, now.activeQueueItemId) {
val current = queue.indexOfFirst { it.queueId == now.activeQueueItemId }
if (current >= 0) queue.drop(current + 1) else queue
}
Column(modifier) {
if (upNext.isEmpty()) {
Notice(
"Nothing queued.",
"${if (now.title.isEmpty()) "Nothing playing" else "Only what's already playing"} - " +
"not every app tells Gearslip what comes next.",
)
} else {
LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(upNext, key = { it.queueId }) { track -> QueueRow(track, media) }
}
}
}
}
@Composable
private fun QueueRow(track: QueueTrack, media: CarMedia) {
val context = LocalContext.current
val icon by produceState(track.iconBitmap, track.iconUri) {
value = track.iconBitmap ?: MediaArt.load(context, track.iconUri)
}
Surface(
onClick = { media.playQueueItem(track) },
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(track.title, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (track.subtitle.isNotEmpty()) {
Text(track.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
Icon(Icons.Filled.PlayArrow, "Play", tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@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")
/** The 3x3 app grid; not in material-icons-core. */
val Apps = icon("Apps", "M4,8h4L8,4L4,4v4zM10,20h4v-4h-4v4zM4,20h4v-4L4,16v4zM4,14h4v-4L4,10v4zM10,14h4v-4h-4v4zM16,4v4h4L20,4h-4zM10,8h4L14,4h-4v4zM16,14h4v-4h-4v4zM16,20h4v-4h-4v4z")
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 = PhoneApps.cached() ?: emptyList(), context) {
value = PhoneApps.cached() ?: withContext(Dispatchers.IO) { PhoneApps.installed(context).also { PhoneApps.warm(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,374 @@
package app.seb3thehacker.gearslip.car
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.ContactsContract
import android.provider.Settings
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
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.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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Call
import androidx.compose.material3.Button
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
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.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.graphics.Color
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/** A phone contact with at least one number, as read off the device's contacts. */
private data class Contact(val name: String, val number: String)
private val CALL_PERMISSIONS = arrayOf(Manifest.permission.CALL_PHONE, Manifest.permission.READ_CONTACTS)
private fun hasCallPermissions(context: Context): Boolean =
CALL_PERMISSIONS.all { context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }
private fun loadContacts(context: Context): List<Contact> {
if (context.checkSelfPermission(Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) return emptyList()
val out = mutableListOf<Contact>()
val projection = arrayOf(
ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME,
ContactsContract.CommonDataKinds.Phone.NUMBER,
)
context.contentResolver.query(
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
projection, null, null,
"${ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME} ASC",
)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME)
val numberIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
while (cursor.moveToNext()) {
val name = cursor.getString(nameIndex) ?: continue
val number = cursor.getString(numberIndex) ?: continue
out += Contact(name, number)
}
}
return out.distinctBy { it.number }
}
private fun placeCall(context: Context, number: String) {
if (number.isBlank()) return
if (context.checkSelfPermission(Manifest.permission.CALL_PHONE) != PackageManager.PERMISSION_GRANTED) return
runCatching {
context.startActivity(
Intent(Intent.ACTION_CALL, Uri.parse("tel:${Uri.encode(number)}")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
}
}
private const val TAB_CONTACTS = 0
private const val TAB_KEYPAD = 1
/** Dialing a number or calling a contact from the car screen: a contacts tab and a keypad tab. */
@Composable
fun PhoneDialerScreen() {
val context = LocalContext.current
val hasPermissions = hasCallPermissions(context)
var tab by remember { mutableStateOf(TAB_CONTACTS) }
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(
"Phone",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(vertical = 4.dp),
)
if (!hasPermissions) {
MissingPermission(context)
return@Column
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
DialerTab("Contacts", tab == TAB_CONTACTS, Modifier.weight(1f)) { tab = TAB_CONTACTS }
DialerTab("Keypad", tab == TAB_KEYPAD, Modifier.weight(1f)) { tab = TAB_KEYPAD }
}
Box(Modifier.weight(1f).fillMaxWidth()) {
if (tab == TAB_CONTACTS) ContactsList(context) else Keypad(context)
}
}
}
@Composable
private fun MissingPermission(context: Context) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
"Gearslip needs the Phone and Contacts permissions to dial from here. Grant them " +
"from the app's settings.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
FilledTonalButton(onClick = {
context.startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)),
)
}) { Text("Open app settings") }
}
}
@Composable
private fun DialerTab(label: String, selected: Boolean, modifier: Modifier, onClick: () -> Unit) {
Surface(
onClick = onClick,
modifier = modifier.height(52.dp),
shape = MaterialTheme.shapes.large,
color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh,
contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
) {
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
Text(label, style = MaterialTheme.typography.titleMedium, fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal)
}
}
}
/**
* A 3-column digit grid beside a fourth column holding backspace and call, stacked - four equal
* columns in all. Every row and both side buttons take an equal weighted share of whatever height
* is available, so this fits the car's short landscape frame without scrolling instead of
* overflowing off the bottom the way fixed-height rows did.
*/
@Composable
private fun Keypad(context: Context) {
var number by remember { mutableStateOf("") }
val rows = listOf(
listOf("1", "2", "3"),
listOf("4", "5", "6"),
listOf("7", "8", "9"),
listOf("*", "0", "#"),
)
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
number.ifEmpty { "Enter a number" },
style = MaterialTheme.typography.headlineSmall,
color = if (number.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(vertical = 6.dp),
)
Row(Modifier.weight(1f).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Column(Modifier.weight(3f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
rows.forEach { row ->
Row(Modifier.weight(1f).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
row.forEach { digit ->
KeypadButton(digit, Modifier.weight(1f).fillMaxHeight()) { number += digit }
}
}
}
}
Column(Modifier.weight(1f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
FilledTonalButton(
onClick = { if (number.isNotEmpty()) number = number.dropLast(1) },
enabled = number.isNotEmpty(),
modifier = Modifier.weight(1f).fillMaxWidth(),
) { Text("⌫") }
FilledIconButton(
onClick = { placeCall(context, number) },
enabled = number.isNotEmpty(),
modifier = Modifier.weight(1f).fillMaxWidth(),
colors = androidx.compose.material3.IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
),
) { Icon(Icons.Filled.Call, "Call") }
}
}
}
}
@Composable
private fun KeypadButton(digit: String, modifier: Modifier, onClick: () -> Unit) {
Surface(
onClick = onClick,
modifier = modifier,
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
Text(digit, style = MaterialTheme.typography.titleLarge)
}
}
}
@Composable
private fun ContactsList(context: Context) {
val contacts by produceState(emptyList<Contact>(), context) {
value = withContext(Dispatchers.IO) { loadContacts(context) }
}
var query by remember { mutableStateOf("") }
var searching by remember { mutableStateOf(false) }
var pendingCall by remember { mutableStateOf<Contact?>(null) }
val filtered = remember(contacts, query) {
if (query.isBlank()) contacts
else contacts.filter { it.name.contains(query, ignoreCase = true) || it.number.contains(query) }
}
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (contacts.isNotEmpty()) {
ContactSearchField(query, searching, onToggle = { searching = !searching })
}
// The list (and its empty states) sit between the search field and the keyboard, never
// under either - the keyboard is docked at the bottom instead of jammed in right below
// the field, so results stay visible while typing rather than getting squeezed out.
Box(Modifier.weight(1f).fillMaxWidth()) {
when {
contacts.isEmpty() -> Text(
"No contacts with a phone number found.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
filtered.isEmpty() -> Text(
"No contacts match \"$query\".",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
else -> LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(filtered) { contact ->
ContactRow(contact) { pendingCall = contact }
}
}
}
}
if (searching) {
CarKeyboard(query, onTextChange = { query = it }, onSubmit = { searching = false })
}
}
pendingCall?.let { contact ->
ConfirmCallOverlay(
contact = contact,
onConfirm = { placeCall(context, contact.number); pendingCall = null },
onDismiss = { pendingCall = null },
)
}
}
/**
* A plain Compose overlay, not [androidx.compose.material3.AlertDialog] - that opens a real
* Android Dialog/window, and this UI lives in a Presentation on a virtual display where a second
* window of the wrong type crashes outright ("Window type mismatch"). Every other popup in the
* car UI is drawn in-composition for the same reason; this just follows suit.
*/
@Composable
private fun ConfirmCallOverlay(contact: Contact, onConfirm: () -> Unit, onDismiss: () -> Unit) {
Box(
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.6f)).clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },
onClick = onDismiss,
),
contentAlignment = Alignment.Center,
) {
Surface(
onClick = {},
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = Modifier.fillMaxWidth(0.85f).padding(16.dp),
) {
Column(
Modifier.padding(32.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("Call ${contact.name}?", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text(contact.number, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
OutlinedButton(onClick = onDismiss, modifier = Modifier.weight(1f).height(56.dp)) {
Text("Cancel", style = MaterialTheme.typography.titleMedium)
}
Button(onClick = onConfirm, modifier = Modifier.weight(1f).height(56.dp)) {
Text("Call", style = MaterialTheme.typography.titleMedium)
}
}
}
}
}
}
@Composable
private fun ContactSearchField(query: String, active: Boolean, onToggle: () -> Unit) {
Surface(
onClick = onToggle,
shape = MaterialTheme.shapes.large,
color = if (active) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = Modifier.fillMaxWidth(),
) {
Text(
query.ifEmpty { "Search contacts" },
style = MaterialTheme.typography.bodyLarge,
color = if (query.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
)
}
}
@Composable
private fun ContactRow(contact: Contact, onClick: () -> Unit) {
Surface(
onClick = onClick,
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = Modifier.fillMaxWidth(),
) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Box(
Modifier.size(40.dp).background(MaterialTheme.colorScheme.primaryContainer, CircleShape),
contentAlignment = Alignment.Center,
) {
Text(
contact.name.take(1).uppercase(),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
fontWeight = FontWeight.Bold,
)
}
Column(Modifier.weight(1f)) {
Text(contact.name, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(contact.number, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Icon(Icons.Filled.Call, contentDescription = "Call", tint = MaterialTheme.colorScheme.primary)
}
}
}
@@ -0,0 +1,42 @@
package app.seb3thehacker.gearslip.car
import android.content.Context
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.mirror.PhoneApps
import java.util.concurrent.Executors
/**
* Loads what the car screens show before anyone opens them: the car and media apps with their
* icons, the phone's launcher apps, the weather and the calendar. Each screen paints from these
* results at once and refreshes behind them, so nothing waits on the package manager, the
* network or the calendar provider.
*
* Safe to call as often as you like: calls closer together than [MIN_GAP_MS] are dropped.
*/
object Prefetch {
private const val MIN_GAP_MS = 60_000L
private val worker = Executors.newSingleThreadExecutor { Thread(it, "gearslip-prefetch").apply { isDaemon = true } }
@Volatile private var lastRun = 0L
/** [force] skips the throttle; use it when something changed, such as a permission grant. */
fun warm(context: Context, force: Boolean = false) {
val now = System.currentTimeMillis()
if (!force && now - lastRun < MIN_GAP_MS) return
lastRun = now
val app = context.applicationContext
worker.execute {
val start = System.currentTimeMillis()
step("launcher apps") { warmLauncherApps(app) }
step("phone apps") { PhoneApps.warm(app) }
step("weather and calendar") { warmDashboard(app) }
GearslipLog.i("prefetch done in ${System.currentTimeMillis() - start}ms")
}
}
// One failing loader must not stop the others.
private fun step(name: String, block: () -> Unit) {
runCatching(block).onFailure { GearslipLog.w("prefetch: $name failed: ${it.message}") }
}
}
@@ -0,0 +1,470 @@
package app.seb3thehacker.gearslip.car
import android.graphics.Rect
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.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.heightIn
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.KeyboardArrowUp
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.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.car.app.model.CarIcon
import androidx.car.app.model.Template
import androidx.car.app.model.PlaceListMapTemplate
import androidx.car.app.navigation.model.Lane
import androidx.car.app.navigation.model.LaneDirection
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.Step
import androidx.car.app.navigation.model.TravelEstimate
import app.seb3thehacker.gearslip.host.clockTime
import app.seb3thehacker.gearslip.host.color
import app.seb3thehacker.gearslip.host.image
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) {
val pan by CarServices.nav.panMode.collectAsState()
Box(modifier.fillMaxWidth()) {
if (!pan) (template.navigationInfo as? RoutingInfo)?.let {
RoutingCard(it, Modifier.align(Alignment.TopStart).padding(12.dp).width(320.dp))
}
RightEdgeControls(
if (pan) null else template.actionStrip,
template.mapActionStrip,
Modifier.align(Alignment.TopEnd).padding(12.dp),
)
if (!pan) template.destinationTravelEstimate?.let {
TravelEstimateBar(it, Modifier.align(Alignment.BottomStart).padding(12.dp))
}
}
}
/** 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 = ChromeType.title)
}
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 = ChromeType.headline)
}
Text(step.cue.text(), style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
val road = step.road.text()
if (road.isNotEmpty()) {
Text(
road,
style = ChromeType.body,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
// Neither is sent by every app - most say only "turn left" and trust the driver to
// find the lane themselves - so both are drawn only when there is something to draw.
info.junctionImage?.let { junction ->
Spacer(Modifier.height(10.dp))
JunctionImage(junction)
}
LaneGuidance(step, Modifier.padding(top = 10.dp))
info.nextStep?.let { next ->
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text("then", style = ChromeType.label, 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 = ChromeType.body, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
}
/** The zoomed-in intersection an app draws for a junction too complex for an arrow to explain. */
@Composable
private fun JunctionImage(icon: CarIcon, modifier: Modifier = Modifier) {
val bitmap = icon.image(LocalContext.current) ?: return
Image(
bitmap,
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = modifier.fillMaxWidth().heightIn(max = 120.dp),
)
}
/**
* Which lane to be in before the manoeuvre, either as the app's own picture ([Step.getLanesImage])
* or as a row of arrows built from its structured lane data - whichever it sent. The recommended
* lane, or lanes, stand out in the theme's own colour; the rest sit on the same muted, solid tile
* everything else uses, never dimmed by transparency.
*/
@Composable
private fun LaneGuidance(step: Step, modifier: Modifier = Modifier) {
step.lanesImage?.let {
JunctionImage(it, modifier)
return
}
val lanes = step.lanes.orEmpty()
if (lanes.isEmpty()) return
Row(modifier, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
lanes.forEach { lane -> LaneTile(lane) }
}
}
@Composable
private fun LaneTile(lane: Lane) {
val directions = lane.directions.orEmpty()
val shown = directions.firstOrNull { it.isRecommended } ?: directions.firstOrNull()
val recommended = shown?.isRecommended == true
Box(
Modifier
.size(36.dp)
.clip(RoundedCornerShape(8.dp))
.background(
if (recommended) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceContainerHighest,
),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Filled.KeyboardArrowUp,
contentDescription = if (recommended) "Recommended lane" else "Lane",
tint = if (recommended) MaterialTheme.colorScheme.onPrimaryContainer
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp).rotate(shown.turnDegrees()),
)
}
}
/** The one arrow glyph this app draws, turned to stand in for every shape a lane can permit. */
private fun LaneDirection?.turnDegrees(): Float = when (this?.shape) {
LaneDirection.SHAPE_SLIGHT_RIGHT -> 30f
LaneDirection.SHAPE_NORMAL_RIGHT -> 90f
LaneDirection.SHAPE_SHARP_RIGHT -> 135f
LaneDirection.SHAPE_U_TURN_RIGHT -> 170f
LaneDirection.SHAPE_SLIGHT_LEFT -> -30f
LaneDirection.SHAPE_NORMAL_LEFT -> -90f
LaneDirection.SHAPE_SHARP_LEFT -> -135f
LaneDirection.SHAPE_U_TURN_LEFT -> -170f
else -> 0f
}
/**
* The trip at a glance: time and distance left, the clock time it ends at, and - beside them,
* where a thumb reaches - the app's own way to stop navigating. A floating card rather than a
* full-width bar, so it reads as one thing beside the manoeuvre card above it instead of a strip
* that happens to hold three numbers. The close button is Gearslip's own, not the app's: an
* actionStrip's actions carry no flag saying which one (if any) means "stop navigating" - the
* first button here turned out to be a mute toggle, not an exit - so this leaves the app the way
* going Home already does, rather than guess at the app's own controls and risk getting it wrong.
*/
@Composable
private fun TravelEstimateBar(estimate: TravelEstimate, modifier: Modifier = Modifier) {
val time = remainingTime(estimate.remainingTimeSeconds)
val distance = estimate.remainingDistance.display()
val arrival = estimate.arrivalTimeAtDestination.clockTime()
val trip = estimate.tripText.text()
if (time.isEmpty() && distance.isEmpty() && arrival.isEmpty() && trip.isEmpty()) return
val detail = listOfNotNull(
arrival.takeIf { it.isNotEmpty() },
distance.takeIf { it.isNotEmpty() },
trip.takeIf { it.isNotEmpty() },
).joinToString(" • ")
// The same solid card the manoeuvre card above it uses - one surface for the whole map
// chrome, rather than this one spot styled on its own as a bar over the map.
ChromeSurface(modifier, shadowElevation = 8.dp) {
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
Row(
Modifier.padding(start = 8.dp, end = 18.dp, top = 10.dp, bottom = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
Modifier
.size(40.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
// Not a disconnect: the connection stays up and the app decides its own next
// screen. Ending navigation is its own call, separate from a back press - a
// back press only pops the on-screen card, leaving the app still believing it
// is navigating (so a freshly picked destination silently does nothing); this
// is the call that actually clears that flag on the app's side.
.clickable { CarServices.nav.stopNavigating() },
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Filled.Close,
contentDescription = "Stop navigating",
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(20.dp),
)
}
Spacer(Modifier.width(10.dp))
Column {
if (time.isNotEmpty()) {
Text(
time,
style = ChromeType.headline,
color = estimate.remainingTimeColor.color(dark, MaterialTheme.colorScheme.onSurface),
)
}
if (detail.isNotEmpty()) {
Text(detail, style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
}
// --- map with a side pane ----------------------------------------------------------------------
/**
* The shape every remaining map-backed template shares: the app's map across the whole surface,
* the template's content in a card floating over its left side, and both action strips stacked at
* the right edge. The card is as tall as its content, up to the height of the screen, so a
* handful of buttons no longer claim a whole column of the map. [fullHeight] is for content that
* needs the room, such as a search field with its keyboard.
*/
@Composable
private fun MapPaneLayout(
modifier: Modifier,
actionStrip: androidx.car.app.model.ActionStrip?,
mapActionStrip: androidx.car.app.model.ActionStrip?,
fullHeight: Boolean = false,
pane: @Composable () -> Unit,
) {
// Pan mode hands the whole screen to the map, leaving only its own controls.
val pan by CarServices.nav.panMode.collectAsState()
val edgeActions = (actionStrip?.actions.orEmpty() + mapActionStrip?.actions.orEmpty()).map { it.identity() }.toSet()
androidx.compose.foundation.layout.BoxWithConstraints(modifier.fillMaxWidth()) {
if (!pan) {
ChromeSurface(
Modifier
.align(Alignment.TopStart)
.padding(12.dp)
.width(maxOf(maxWidth * CONTENT_PANE_FRACTION - 12.dp, 232.dp))
.let { if (fullHeight) it.height(maxHeight - 24.dp) else it.heightIn(max = maxHeight - 24.dp) },
shape = androidx.compose.foundation.shape.RoundedCornerShape(20.dp),
shadowElevation = 8.dp,
) {
androidx.compose.runtime.CompositionLocalProvider(LocalMapEdgeActions provides edgeActions) { pane() }
}
}
RightEdgeControls(
if (pan) null else actionStrip,
mapActionStrip,
Modifier.align(Alignment.TopEnd).padding(12.dp),
)
}
}
@Composable
private fun MapWithContentChrome(template: MapWithContentTemplate, modifier: Modifier) {
MapPaneLayout(
modifier,
template.actionStrip,
template.mapController?.mapActionStrip,
fullHeight = template.contentTemplate.needsFullHeight(),
) {
// A pane floating over the map is a place or a route the app is proposing, so its one
// action - Start - counts itself down rather than waiting to be pressed; the same pane
// filling a whole screen elsewhere gets no such push.
ContentTemplate(template.contentTemplate, Modifier, autoStartPane = true)
}
}
@Composable
private fun MapChrome(template: MapTemplate, modifier: Modifier) {
MapPaneLayout(
modifier,
template.actionStrip,
template.mapController?.mapActionStrip,
) {
ChromeSurface(Modifier, 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, autoStart = true)
else ItemListColumn(template.itemList)
}
}
}
}
@Composable
private fun PlaceListNavigationChrome(template: PlaceListNavigationTemplate, modifier: Modifier) {
MapPaneLayout(modifier, template.actionStrip, template.mapActionStrip) {
ListPane(
headerTitle(template.header, template.title.text()),
template.header?.startHeaderAction ?: template.headerAction,
template.isLoading,
template.itemList,
Modifier,
)
}
}
@Composable
private fun PlaceListMapChrome(template: PlaceListMapTemplate, modifier: Modifier) {
MapPaneLayout(modifier, template.actionStrip, null) {
ListPane(
template.title.text(),
template.headerAction,
template.isLoading,
template.itemList,
Modifier,
)
}
}
/** 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) {
ChromeSurface(Modifier, shape = androidx.compose.foundation.shape.RoundedCornerShape(0.dp)) {
Column {
HeaderBar(
headerTitle(template.header, template.title.text()),
template.header?.startHeaderAction ?: template.headerAction,
template.header?.endHeaderActions.orEmpty(),
)
Box(Modifier.weight(1f, fill = false)) {
if (template.isLoading) {
Text("Finding routes…", Modifier.padding(14.dp), style = ChromeType.title)
} else {
ItemListColumn(template.itemList)
}
}
template.navigateAction?.let {
AutoStartButton(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), style = ChromeType.title)
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
/** Search, sign-in and the like are laid out for a whole column, so they keep one. */
private fun Template?.needsFullHeight() =
this is androidx.car.app.model.SearchTemplate || this is androidx.car.app.model.signin.SignInTemplate ||
this is androidx.car.app.model.TabTemplate || this is androidx.car.app.model.LongMessageTemplate
@@ -0,0 +1,520 @@
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.TextAlign
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.isDrawable
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, autoStartPane: Boolean = false) {
when (template) {
null -> Unit
is ListTemplate -> ListContent(template, modifier)
is GridTemplate -> GridContent(template, modifier)
is PaneTemplate -> PaneContent(template, modifier, autoStartPane)
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 },
Modifier.weight(1f, fill = false),
)
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, Modifier.weight(1f, fill = false))
ActionRow(template.actions.orEmpty(), Modifier.padding(12.dp))
}
}
@Composable
private fun PaneContent(template: PaneTemplate, modifier: Modifier, autoStart: Boolean = false) {
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, autoStart = autoStart)
}
}
// --- messages -------------------------------------------------------------------------------
@Composable
private fun MessageContent(template: MessageTemplate, modifier: Modifier) {
ContentSurface(modifier) {
// The header carries only the back arrow, not a title - a message template's own title
// is usually the long-form question ("Would you like to download the map...") that
// reads fine centred in the body but doesn't fit a one-line header without truncating.
val backAction = template.header?.startHeaderAction ?: template.headerAction
HeaderBar("", backAction, template.header?.endHeaderActions.orEmpty(), template.actionStrip)
if (template.isLoading) {
Loading()
return@ContentSurface
}
// A back arrow already undoes this screen, so an app's own "Cancel" button would just
// be a second way to do the one thing the header already offers.
val actions = template.actions.orEmpty().filter { it.isDrawable() }.let { list ->
if (backAction != null) list.filterNot { it.title.text().equals("Cancel", ignoreCase = true) } else list
}
Column(
Modifier.weight(1f).fillMaxWidth().padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
template.icon?.let {
CarGlyph(it, Modifier.size(40.dp))
Spacer(Modifier.height(12.dp))
}
Text(template.title.text(), style = ChromeType.title, textAlign = TextAlign.Center)
val detail = template.message.text()
if (detail.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
Text(
detail,
style = ChromeType.body,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (actions.isNotEmpty()) {
Spacer(Modifier.height(24.dp))
ActionRow(actions, Modifier.fillMaxWidth(0.6f), large = true)
}
}
}
}
/**
* 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 = ChromeType.body,
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 = ChromeType.body,
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 = ChromeType.body,
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 = ChromeType.label,
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 = ChromeType.label,
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 = ChromeType.headline,
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 = ChromeType.label, 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), style = ChromeType.body)
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 = ChromeType.body,
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.fillMaxWidth(), 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 = ChromeType.title)
}
}
@@ -0,0 +1,649 @@
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.background
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.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.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 app.seb3thehacker.gearslip.host.selected
import app.seb3thehacker.gearslip.host.checkedChanged
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Switch
import androidx.core.graphics.drawable.toBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.draw.alpha
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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, always, in the same one colour: a card that lets the map show through in places
* reads as broken glass, not as a deliberate choice, and a driver has no way to tell which was
* meant. There is no alpha knob here on purpose - every card, bar and strip in the template
* chrome shares this surface, so nothing drawn over a map is ever see-through.
*/
@Composable
internal fun ChromeSurface(
modifier: Modifier = Modifier,
shape: Shape = RoundedCornerShape(16.dp),
shadowElevation: androidx.compose.ui.unit.Dp = 0.dp,
content: @Composable () -> Unit,
) {
Surface(
shadowElevation = shadowElevation,
color = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface,
shape = shape,
modifier = modifier,
content = content,
)
}
/**
* The one type scale for everything a templated app's screen draws. Apps hand the host plain
* strings with no size of their own - the Car App Library deliberately keeps that decision out of
* their hands - so without a shared scale, each template's text ends up whatever size felt right
* when that template was written, and two apps' otherwise-identical rows read as different sizes
* of importance. Every car chrome file should reach for one of these four rather than naming a
* Material size directly.
*/
internal object ChromeType {
/** The one number or word a card exists to show: distance to a turn, minutes left, a PIN. */
val headline: androidx.compose.ui.text.TextStyle
@Composable get() = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Bold)
/** A row's name, a header, a manoeuvre's instruction - the line the rest is supporting. */
val title: androidx.compose.ui.text.TextStyle
@Composable get() = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold)
/** Supporting detail: a road, an address, a message's body, a second line under a title. */
val body: androidx.compose.ui.text.TextStyle
@Composable get() = MaterialTheme.typography.titleMedium
/** The smallest text Gearslip draws over a map - still large enough to read while moving. */
val label: androidx.compose.ui.text.TextStyle
@Composable get() = MaterialTheme.typography.bodyLarge
}
@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 = ChromeType.title,
)
}
}
}
/**
* 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,
) {
// Beside a map the action strip already sits at the right edge, so the header must not repeat it;
// and an app that lists the same action in two places should still see it once.
val onMap = LocalMapEdgeActions.current
val end = (endActions + if (onMap != null) emptyList() else actionStrip?.actions.orEmpty())
.filter { it.isDrawable() && (onMap == null || it.identity() !in onMap) }
.distinctBy { it.identity() }
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 = ChromeType.title,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
end.forEach { ActionButton(it) }
}
}
/**
* Set inside the card beside a map to what the map's right edge already shows: the action strip is
* drawn there rather than in the header, and the header leaves out anything the edge repeats.
* Null anywhere else.
*/
internal val LocalMapEdgeActions = androidx.compose.runtime.compositionLocalOf<Set<String>?> { null }
/** What makes two actions the same button: what they do and what they look like. */
internal fun Action.identity(): String {
val icon = icon?.icon
val image = if (icon != null && icon.type == androidx.core.graphics.drawable.IconCompat.TYPE_RESOURCE) {
"${icon.resPackage}:${icon.resId}"
} else icon?.toString().orEmpty()
return "$type|${title.text()}|$image"
}
/** 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
// A template that stops offering Pan must not leave the screen stuck in pan mode.
val hasPan = (actions + mapActions).any { it.type == Action.TYPE_PAN }
androidx.compose.runtime.LaunchedEffect(hasPan) { if (!hasPan) CarServices.nav.setPanMode(false) }
// Faded out when the screen has been left alone; then they are only a memory of buttons, so
// the first touch on one wakes them rather than pressing it.
val awake = LocalMapControlsWake.current.awake
val pan by CarServices.nav.panMode.collectAsState()
val visible = awake || pan
val alpha by androidx.compose.animation.core.animateFloatAsState(
targetValue = if (visible) 1f else 0f,
animationSpec = androidx.compose.animation.core.tween(if (visible) 150 else 500),
label = "mapControls",
)
Column(
modifier
.alpha(alpha)
.let { base ->
if (visible) base else base.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
awaitPointerEvent(PointerEventPass.Initial).changes.forEach { it.consume() }
}
}
}
},
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) }
}
}
/** [large] is the pane's main call to action: it shares the card's full width, taller, in bigger type. */
@Composable
internal fun ActionRow(actions: List<Action>, modifier: Modifier = Modifier, large: Boolean = false) {
val drawable = actions.filter { it.isDrawable() }
if (drawable.isEmpty()) return
Row(if (large) modifier.fillMaxWidth() else modifier, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
drawable.forEach { ActionButton(it, large = large, modifier = if (large) Modifier.weight(1f) else Modifier) }
}
}
/** A row with nothing to show: no words, no picture, nothing to tap. */
private fun CarRow.isBlank(): Boolean =
title.text().isBlank() && texts.orEmpty().all { it.text().isBlank() } && image == null &&
toggle == null && onClickDelegate == null && actions.orEmpty().isEmpty()
/**
* One look everywhere: solid, in the theme's own colors, whether the button sits on a card or over
* the map. An app that names a color for its button (a green Start) keeps it.
*/
@Composable
internal fun ActionButton(action: Action, large: Boolean = false, modifier: Modifier = Modifier) {
if (action.type == Action.TYPE_APP_ICON) {
AppIconBadge()
return
}
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
val title = action.title.text()
val panOn by CarServices.nav.panMode.collectAsState()
val active = action.type == Action.TYPE_PAN && panOn
val scheme = MaterialTheme.colorScheme
val custom = action.backgroundColor?.type?.let { it != androidx.car.app.model.CarColor.TYPE_DEFAULT } == true
val fallback = scheme.surfaceContainerHighest
val background = if (active) scheme.primary else action.backgroundColor.color(dark, fallback)
val content = when {
active -> scheme.onPrimary
custom -> Color.White
else -> scheme.onSurface
}
val hasGlyph = action.icon != null || action.type != Action.TYPE_CUSTOM
Surface(
color = background,
contentColor = content,
shape = if (title.isEmpty()) androidx.compose.foundation.shape.CircleShape else RoundedCornerShape(if (large) 28.dp else 24.dp),
modifier = modifier.clickable(enabled = action.isEnabled) {
if (action.type == Action.TYPE_BACK) CarServices.nav.backPressed()
else if (action.type == Action.TYPE_PAN) CarServices.nav.setPanMode(!panOn)
else action.onClickDelegate?.click("action")
},
) {
if (large && title.isNotEmpty()) {
// The label sits dead centre on the button; the icon hangs off the left edge, so the
// pair's uneven visual weight can't pull the words off to one side.
androidx.compose.foundation.layout.Box(
Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 18.dp),
contentAlignment = Alignment.Center,
) {
if (hasGlyph) CarGlyph(action.icon, Modifier.size(30.dp).align(Alignment.CenterStart), standardFor(action))
Text(title, style = ChromeType.title, maxLines = 1)
}
} else if (title.isEmpty()) {
// Every icon-only button - back, settings, the map's zoom and locate - is the same size.
androidx.compose.foundation.layout.Box(Modifier.size(ICON_BUTTON_SIZE), contentAlignment = Alignment.Center) {
if (hasGlyph) CarGlyph(action.icon, Modifier.size(ICON_BUTTON_GLYPH), standardFor(action))
}
} else {
Row(
Modifier.padding(horizontal = 18.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (hasGlyph) CarGlyph(action.icon, Modifier.size(24.dp), standardFor(action))
if (hasGlyph) Spacer(Modifier.width(8.dp))
Text(title, style = ChromeType.body, maxLines = 1)
}
}
}
}
/**
* A large action that fires on its own after five seconds, filling to show the countdown - for
* the one button on a map-preview pane, where the app is proposing a single place or route and a
* driver glancing at the car screen has already decided. Tapping it, same as any other button,
* fires it at once; the fill is only ever a preview of what a touch already does.
*/
@Composable
internal fun AutoStartButton(action: Action, modifier: Modifier = Modifier) {
var fraction by remember(action.identity()) { mutableStateOf(0f) }
var fired by remember(action.identity()) { mutableStateOf(false) }
fun fire() {
if (fired) return
fired = true
action.onClickDelegate.click("auto-start")
}
androidx.compose.runtime.LaunchedEffect(action.identity()) {
val steps = 60
repeat(steps) { step ->
kotlinx.coroutines.delay(AUTO_START_MS / steps)
fraction = (step + 1) / steps.toFloat()
}
fire()
}
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
val custom = action.backgroundColor?.type?.let { it != androidx.car.app.model.CarColor.TYPE_DEFAULT } == true
val background = action.backgroundColor.color(dark, MaterialTheme.colorScheme.primary)
val content = if (custom) Color.White else MaterialTheme.colorScheme.onPrimary
val hasGlyph = action.icon != null
Surface(
// An unfilled track, solid, not the button's own colour dimmed with alpha - so the map
// behind never shows through however much of the five seconds is left.
color = MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor = content,
shape = RoundedCornerShape(28.dp),
modifier = modifier.fillMaxWidth().clickable(enabled = action.isEnabled) { fire() },
) {
androidx.compose.foundation.layout.Box {
// The countdown itself: a plain fill sweeping left to right underneath the label.
// matchParentSize(), not fillMaxHeight() - this Box sits beside a label Box that
// wraps its own content, and the outer Box sizes itself to its tallest child; a
// plain fillMaxHeight() here has nothing bounded to fill up to but the whole screen,
// which is what was stretching the whole button that tall. matchParentSize() instead
// takes whatever size the label ends up being, without itself voting on what that is.
androidx.compose.foundation.layout.Box(Modifier.matchParentSize()) {
androidx.compose.foundation.layout.Box(
Modifier
.fillMaxWidth(fraction)
.fillMaxHeight()
.background(background),
)
}
androidx.compose.foundation.layout.Box(
Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 16.dp),
contentAlignment = Alignment.Center,
) {
if (hasGlyph) CarGlyph(action.icon, Modifier.size(24.dp).align(Alignment.CenterStart))
Text(
action.title.text().ifEmpty { "Start" },
style = ChromeType.title,
maxLines = 1,
)
}
}
}
}
private const val AUTO_START_MS = 5_000L
/** The connected app's own launcher icon, which is what the app-icon action stands for. */
@Composable
private fun AppIconBadge() {
val context = LocalContext.current
val pkg = CarServices.nav.appPackage
val icon = remember(pkg) {
pkg?.let {
runCatching {
context.packageManager.getApplicationIcon(it).toBitmap(96, 96).asImageBitmap()
}.getOrNull()
}
} ?: return
Image(icon, contentDescription = null, modifier = Modifier.size(36.dp).clip(RoundedCornerShape(10.dp)))
}
/** 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 = ChromeType.title,
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), style = ChromeType.body)
return
}
// A list the app made selectable is a single-choice list: one option chosen, shown as a radio.
val onSelected = single?.onSelectedDelegate
val chosen = single?.selectedIndex ?: -1
LazyColumn(modifier, contentPadding = PaddingValues(bottom = 8.dp)) {
itemsIndexed(rows) { index, row ->
RowItem(row, onSelected?.let { RowSelection(index == chosen) { it.selected(index) } })
}
}
}
/** 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(84.dp),
modifier = modifier,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.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 = ChromeType.label,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val subtitle = item.text.text()
if (subtitle.isNotEmpty()) {
Text(
subtitle,
style = ChromeType.label,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
/**
* [autoStart] is for a pane floating over a map - a place or a route the app is proposing - where
* a single action counts itself down rather than waiting to be pressed, on the theory that a
* driver glancing at the car screen has already decided. It never applies to more than one
* action: a pane offering a real choice (Start vs. something else) always waits to be tapped.
*/
@Composable
internal fun PaneRows(pane: Pane?, modifier: Modifier = Modifier, autoStart: Boolean = false) {
// Some apps pad a pane with a row of blank strings; drawn, it is just a gap.
val rows = pane?.rows.orEmpty().filterNot { it.isBlank() }
val actions = pane?.actions.orEmpty().filter { it.isDrawable() }
Column(modifier) {
rows.forEach { RowItem(it, large = true) }
if (autoStart && actions.size == 1) {
AutoStartButton(actions.single(), Modifier.padding(horizontal = 14.dp, vertical = 10.dp))
} else {
ActionRow(actions, Modifier.padding(horizontal = 14.dp, vertical = 10.dp), large = true)
}
}
}
@Composable
internal fun RowItem(row: CarRow, selection: RowSelection? = null, large: Boolean = false) {
val toggle = row.toggle
// Shown at once and corrected when the app answers with its own template, so the switch
// never lags a tap; keyed on what the app last said so its answer wins.
var checked by remember(toggle?.isChecked) { mutableStateOf(toggle?.isChecked == true) }
val clickable = row.isEnabled && (toggle != null || selection != null || row.onClickDelegate != null)
Row(
Modifier
.fillMaxWidth()
.let {
if (!clickable) it else it.clickable {
when {
toggle != null -> {
checked = !checked
toggle.onCheckedChangeDelegate.checkedChanged(checked)
}
selection != null -> selection.onPick()
else -> row.onClickDelegate.click("row")
}
}
}
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (selection != null) {
RadioButton(selected = selection.selected, onClick = null)
Spacer(Modifier.width(12.dp))
}
row.image?.let {
CarGlyph(it, Modifier.size(28.dp))
Spacer(Modifier.width(12.dp))
}
// large only changes how much room a row gets (more lines, a bigger icon above) - the
// type itself is the same scale as every other row, so a pane's one row and a plain
// list's tenth row read as the same kind of text.
Column(Modifier.weight(1f)) {
Text(
row.title.text(),
style = ChromeType.title,
maxLines = if (large) 2 else 1,
overflow = TextOverflow.Ellipsis,
)
row.texts.orEmpty().take(if (large) 3 else 2).forEach { line ->
Text(
line.text(),
style = ChromeType.body,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = if (large) 2 else 1,
overflow = TextOverflow.Ellipsis,
)
}
}
ActionRow(row.actions.orEmpty())
if (toggle != null) {
Spacer(Modifier.width(8.dp))
Switch(checked = checked, onCheckedChange = null, enabled = row.isEnabled)
}
}
}
/** A row's place in a single-choice list: whether it is the chosen one, and what picking it does. */
internal class RowSelection(val selected: Boolean, val onPick: () -> Unit)
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
}
private val ICON_BUTTON_SIZE = 54.dp
private val ICON_BUTTON_GLYPH = 30.dp
@@ -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,213 @@
package app.seb3thehacker.gearslip.car
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.location.Geocoder
import android.location.Location
import android.location.LocationManager
import app.seb3thehacker.gearslip.GearslipLog
import java.net.HttpURLConnection
import java.net.URL
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 org.json.JSONObject
class HourForecast(val time: Long, val temp: Int, val code: Int, val isDay: Boolean, val rainChance: Int)
class DayForecast(val date: Long, val high: Int, val low: Int, val code: Int, val rainChance: Int)
class WeatherData(
val place: String,
val temp: Int,
val feelsLike: Int,
val code: Int,
val isDay: Boolean,
val humidity: Int,
val wind: Int,
val tempUnit: String,
val windUnit: String,
val hours: List<HourForecast>,
val days: List<DayForecast>,
val fetchedAt: Long,
)
sealed interface WeatherState {
data object Loading : WeatherState
data class Unavailable(val reason: String) : WeatherState
data class Ready(val data: WeatherData) : WeatherState
}
/**
* Current weather and a week's forecast for wherever the phone is, from Open-Meteo (free, no
* key or account; the data is CC BY 4.0, so the weather screen credits it).
*
* The location is the phone's last fix if it is recent, otherwise a fresh one is requested, so
* a phone that has been idle does not leave the card on "unavailable".
*/
object Weather {
private const val TTL_MS = 30 * 60 * 1000L
private const val FIX_MAX_AGE_MS = 30 * 60 * 1000L
private const val FIX_TIMEOUT_S = 12L
private val flow = MutableStateFlow<WeatherState>(WeatherState.Loading)
val state: StateFlow<WeatherState> = flow
private val io = Executors.newSingleThreadExecutor { Thread(it, "gearslip-weather").apply { isDaemon = true } }
private var placeCache: Pair<String, String>? = null // rounded "lat,lon" to place name
/** Runs off the main thread. Fresh data younger than [TTL_MS] is kept unless [force]. */
fun refresh(context: Context, force: Boolean = false) {
val current = flow.value
if (!force && current is WeatherState.Ready && System.currentTimeMillis() - current.data.fetchedAt < TTL_MS) return
val app = context.applicationContext
io.execute { load(app) }
}
@Synchronized
private fun load(context: Context) {
// Keep showing the old reading while a new one loads; only the first load shows a spinner.
val location = locate(context)
if (location == null) {
if (flow.value !is WeatherState.Ready) {
val allowed = hasLocationPermission(context)
flow.value = WeatherState.Unavailable(
if (allowed) "Waiting for a location fix." else "Allow location for Gearslip on the phone to see the weather.",
)
}
return
}
val data = runCatching { fetch(context, location) }
.onFailure { GearslipLog.w("weather fetch failed: ${it.message}") }
.getOrNull()
if (data != null) {
flow.value = WeatherState.Ready(data)
GearslipLog.i("weather: ${data.place} ${data.temp}${data.tempUnit} ${describe(data.code)}")
}
else if (flow.value !is WeatherState.Ready) flow.value = WeatherState.Unavailable("Could not reach the weather service.")
}
// --- location -------------------------------------------------------------------------
private fun hasLocationPermission(context: Context) =
context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
context.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
private fun locate(context: Context): Location? {
if (!hasLocationPermission(context)) return null
val manager = context.getSystemService(LocationManager::class.java) ?: return null
val known = manager.allProviders
.mapNotNull { runCatching { manager.getLastKnownLocation(it) }.getOrNull() }
.maxByOrNull { it.time }
if (known != null && System.currentTimeMillis() - known.time < FIX_MAX_AGE_MS) return known
val provider = listOf(LocationManager.FUSED_PROVIDER, LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER)
.firstOrNull { it in manager.allProviders && manager.isProviderEnabled(it) }
?: return known
val latch = CountDownLatch(1)
var fresh: Location? = null
runCatching {
manager.getCurrentLocation(provider, null, io) { fresh = it; latch.countDown() }
latch.await(FIX_TIMEOUT_S, TimeUnit.SECONDS)
}.onFailure { GearslipLog.w("weather: fresh fix failed: ${it.message}") }
// A stale fix still gives the right city, which is all a forecast needs.
return fresh ?: known
}
private fun placeName(context: Context, location: Location): String {
val key = "%.2f,%.2f".format(Locale.US, location.latitude, location.longitude)
placeCache?.let { if (it.first == key) return it.second }
val name = runCatching {
if (!Geocoder.isPresent()) return@runCatching null
@Suppress("DEPRECATION")
Geocoder(context, Locale.getDefault()).getFromLocation(location.latitude, location.longitude, 1)
?.firstOrNull()?.let { it.locality ?: it.subAdminArea ?: it.adminArea }
}.getOrNull() ?: "%.2f, %.2f".format(Locale.US, location.latitude, location.longitude)
placeCache = key to name
return name
}
// --- network --------------------------------------------------------------------------
private fun fetch(context: Context, location: Location): WeatherData {
val imperial = Locale.getDefault().country in setOf("US", "LR", "MM")
val url = URL(
"https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}" +
"&current=temperature_2m,apparent_temperature,relative_humidity_2m,weather_code,wind_speed_10m,is_day" +
"&hourly=temperature_2m,weather_code,precipitation_probability,is_day" +
"&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max" +
"&timezone=auto&timeformat=unixtime&forecast_days=7" +
if (imperial) "&temperature_unit=fahrenheit&wind_speed_unit=mph" else "",
)
val body = (url.openConnection() as HttpURLConnection).run {
connectTimeout = 6_000
readTimeout = 8_000
inputStream.bufferedReader().use { it.readText() }
}
val json = JSONObject(body)
val cur = json.getJSONObject("current")
val hourly = json.getJSONObject("hourly")
val times = hourly.getJSONArray("time")
val nowSec = System.currentTimeMillis() / 1000
val start = (0 until times.length()).firstOrNull { times.getLong(it) >= nowSec - 3600 } ?: 0
val hours = (start until minOf(start + 12, times.length())).map { i ->
HourForecast(
time = times.getLong(i) * 1000,
temp = Math.round(hourly.getJSONArray("temperature_2m").getDouble(i)).toInt(),
code = hourly.getJSONArray("weather_code").getInt(i),
isDay = hourly.getJSONArray("is_day").getInt(i) == 1,
rainChance = hourly.getJSONArray("precipitation_probability").optInt(i, 0),
)
}
val daily = json.getJSONObject("daily")
val dates = daily.getJSONArray("time")
val days = (0 until dates.length()).map { i ->
DayForecast(
date = dates.getLong(i) * 1000,
high = Math.round(daily.getJSONArray("temperature_2m_max").getDouble(i)).toInt(),
low = Math.round(daily.getJSONArray("temperature_2m_min").getDouble(i)).toInt(),
code = daily.getJSONArray("weather_code").getInt(i),
rainChance = daily.getJSONArray("precipitation_probability_max").optInt(i, 0),
)
}
return WeatherData(
place = placeName(context, location),
temp = Math.round(cur.getDouble("temperature_2m")).toInt(),
feelsLike = Math.round(cur.getDouble("apparent_temperature")).toInt(),
code = cur.getInt("weather_code"),
isDay = cur.getInt("is_day") == 1,
humidity = cur.getInt("relative_humidity_2m"),
wind = Math.round(cur.getDouble("wind_speed_10m")).toInt(),
tempUnit = if (imperial) "°F" else "°C",
windUnit = if (imperial) "mph" else "km/h",
hours = hours,
days = days,
fetchedAt = System.currentTimeMillis(),
)
}
/** Open-Meteo's WMO weather codes, in words. */
fun describe(code: Int): String = when (code) {
0 -> "Clear"
1 -> "Mostly clear"
2 -> "Partly cloudy"
3 -> "Overcast"
45, 48 -> "Fog"
in 51..57 -> "Drizzle"
in 61..67 -> "Rain"
in 71..77 -> "Snow"
in 80..82 -> "Rain showers"
in 85..86 -> "Snow showers"
in 95..99 -> "Thunderstorm"
else -> "-"
}
}
@@ -0,0 +1,101 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlin.math.cos
import kotlin.math.sin
private val SUN = Color(0xFFFFB300)
private val MOON = Color(0xFFCFD8DC)
private val CLOUD = Color(0xFF90A4AE)
private val RAIN = Color(0xFF29B6F6)
private val SNOW = Color(0xFF81D4FA)
private val BOLT = Color(0xFFFFD54F)
/** Sun, moon, cloud, rain, snow, storm or fog for a WMO weather code. Drawn, so it needs no icon pack. */
@Composable
fun WeatherIcon(code: Int, isDay: Boolean, size: Dp = 28.dp, modifier: Modifier = Modifier) {
Canvas(modifier.size(size)) {
when (code) {
0 -> if (isDay) sun(center, this.size.minDimension * 0.5f) else moon(center, this.size.minDimension * 0.5f)
1, 2 -> {
if (isDay) sun(Offset(this.size.width * 0.38f, this.size.height * 0.36f), this.size.minDimension * 0.36f)
else moon(Offset(this.size.width * 0.38f, this.size.height * 0.36f), this.size.minDimension * 0.36f)
cloud(Offset(this.size.width * 0.06f, this.size.height * 0.34f), Size(this.size.width * 0.9f, this.size.height * 0.6f))
}
3 -> cloud(Offset(0f, this.size.height * 0.2f), Size(this.size.width, this.size.height * 0.7f))
45, 48 -> fog()
in 71..77, in 85..86 -> precip(SNOW, dots = true)
in 95..99 -> storm()
else -> precip(RAIN, dots = false)
}
}
}
private fun DrawScope.sun(c: Offset, r: Float) {
drawCircle(SUN, r * 0.55f, c)
val stroke = r * 0.14f
for (i in 0 until 8) {
val a = Math.toRadians(i * 45.0)
drawLine(
SUN, c + Offset(cos(a).toFloat(), sin(a).toFloat()) * (r * 0.75f),
c + Offset(cos(a).toFloat(), sin(a).toFloat()) * r, stroke, StrokeCap.Round,
)
}
}
private fun DrawScope.moon(c: Offset, r: Float) {
val disc = Path().apply { addOval(androidx.compose.ui.geometry.Rect(c, r * 0.62f)) }
val bite = Path().apply { addOval(androidx.compose.ui.geometry.Rect(c + Offset(r * 0.32f, -r * 0.22f), r * 0.55f)) }
drawPath(Path.combine(androidx.compose.ui.graphics.PathOperation.Difference, disc, bite), MOON)
}
private fun DrawScope.cloud(topLeft: Offset, s: Size) {
val h = s.height
drawCircle(CLOUD, h * 0.42f, topLeft + Offset(s.width * 0.3f, h * 0.55f))
drawCircle(CLOUD, h * 0.55f, topLeft + Offset(s.width * 0.55f, h * 0.42f))
drawCircle(CLOUD, h * 0.38f, topLeft + Offset(s.width * 0.78f, h * 0.62f))
drawRoundRect(CLOUD, topLeft + Offset(s.width * 0.28f, h * 0.55f), Size(s.width * 0.52f, h * 0.45f),
androidx.compose.ui.geometry.CornerRadius(h * 0.2f))
}
private fun DrawScope.precip(color: Color, dots: Boolean) {
cloud(Offset(0f, 0f), Size(size.width, size.height * 0.62f))
for (i in 0 until 3) {
val x = size.width * (0.28f + 0.22f * i)
val y = size.height * 0.72f
if (dots) drawCircle(color, size.width * 0.045f, Offset(x, y + size.height * 0.08f))
else drawLine(color, Offset(x, y), Offset(x - size.width * 0.06f, y + size.height * 0.2f), size.width * 0.07f, StrokeCap.Round)
}
}
private fun DrawScope.storm() {
cloud(Offset(0f, 0f), Size(size.width, size.height * 0.62f))
val w = size.width
val h = size.height
val bolt = Path().apply {
moveTo(w * 0.55f, h * 0.55f); lineTo(w * 0.36f, h * 0.82f); lineTo(w * 0.5f, h * 0.82f)
lineTo(w * 0.42f, h * 1f); lineTo(w * 0.66f, h * 0.7f); lineTo(w * 0.52f, h * 0.7f); close()
}
drawPath(bolt, BOLT)
}
private fun DrawScope.fog() {
val stroke = Stroke(width = size.height * 0.09f, cap = StrokeCap.Round)
for (i in 0 until 4) {
val y = size.height * (0.25f + 0.17f * i)
val inset = if (i % 2 == 0) 0.1f else 0.2f
drawLine(CLOUD, Offset(size.width * inset, y), Offset(size.width * (1f - inset), y), stroke.width, stroke.cap)
}
}
@@ -0,0 +1,128 @@
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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
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.collectAsState
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.unit.dp
import androidx.compose.ui.unit.sp
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/** The weather in full: now, the next twelve hours and the week. Opened from the nav bar or the dashboard. */
@Composable
fun WeatherScreen() {
val context = LocalContext.current.applicationContext
val state by Weather.state.collectAsState()
LaunchedEffect(Unit) { Weather.refresh(context) }
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
when (val s = state) {
WeatherState.Loading -> Text("Getting the weather…", style = MaterialTheme.typography.titleMedium)
is WeatherState.Unavailable -> Text(
s.reason, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
is WeatherState.Ready -> Forecast(s.data)
}
}
}
@Composable
private fun Forecast(w: WeatherData) {
Text(w.place, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.extraLarge) {
Row(
Modifier.fillMaxWidth().padding(24.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(24.dp),
) {
WeatherIcon(w.code, w.isDay, size = 88.dp)
Column {
Text("${w.temp}${w.tempUnit}", fontSize = 56.sp, fontWeight = FontWeight.Bold)
Text(Weather.describe(w.code), style = MaterialTheme.typography.titleLarge)
}
Spacer(Modifier.weight(1f))
Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Feels like ${w.feelsLike}${w.tempUnit}", style = MaterialTheme.typography.bodyLarge)
Text("Humidity ${w.humidity}%", style = MaterialTheme.typography.bodyLarge)
Text("Wind ${w.wind} ${w.windUnit}", style = MaterialTheme.typography.bodyLarge)
}
}
}
Text("Next hours", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
items(w.hours) { h ->
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.large) {
Column(
Modifier.width(84.dp).padding(vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(h.time)).replace(":00", ""),
style = MaterialTheme.typography.labelLarge,
)
WeatherIcon(h.code, h.isDay, size = 32.dp)
Text("${h.temp}°", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text(
if (h.rainChance > 0) "${h.rainChance}%" else " ",
style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary,
)
}
}
}
}
Text("This week", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
w.days.forEachIndexed { i, d ->
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.large) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
if (i == 0) "Today" else SimpleDateFormat("EEEE", Locale.getDefault()).format(Date(d.date)),
Modifier.width(120.dp), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold,
)
WeatherIcon(d.code, true, size = 28.dp)
Text(Weather.describe(d.code), Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium)
if (d.rainChance > 0) Text("${d.rainChance}%", color = MaterialTheme.colorScheme.primary)
Text("${d.low}° / ${d.high}°", style = MaterialTheme.typography.bodyLarge)
}
}
}
}
Text(
"Weather data by Open-Meteo.com (CC BY 4.0)",
style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@@ -0,0 +1,180 @@
package app.seb3thehacker.gearslip.car
import android.os.Handler
import android.os.Looper
import android.webkit.JavascriptInterface
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.Column
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
import org.json.JSONObject
/**
* 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.
*
* A page's own text inputs can't call up the phone's IME: the car UI lives in a Presentation on
* a virtual display, and the platform's soft keyboard has nowhere sensible to appear there (see
* [CarKeyboard]'s doc comment - every other text field in this app already draws its own for the
* same reason). [FOCUS_TRACKING_JS] reports focus/blur on the page's editable elements, and
* [CarKeyboard] edits the focused one back through injected JS instead of through the DOM's own
* native input path, which this Presentation can't reach.
*/
@Composable
fun WebApp(content: String) {
val navigator = LocalCarNavigator.current
val darkOutside = LocalDarkOutside.current
var web by remember { mutableStateOf<WebView?>(null) }
var fieldText by remember { mutableStateOf<String?>(null) }
DisposableEffect(web) {
navigator.backInterceptor = {
val view = web
if (view != null && view.canGoBack()) {
view.goBack()
true
} else {
false
}
}
onDispose { navigator.backInterceptor = null }
}
Column(Modifier.fillMaxSize()) {
AndroidView(
modifier = Modifier.weight(1f).fillMaxSize(),
factory = { context ->
WebView(context).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.loadWithOverviewMode = true
settings.useWideViewPort = true
webViewClient = LoggingClient()
addJavascriptInterface(
FieldFocusBridge(onFocus = { fieldText = it }, onBlur = { fieldText = null }),
"GearslipField",
)
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() },
)
fieldText?.let { text ->
CarKeyboard(
text = text,
onTextChange = { updated ->
fieldText = updated
web?.evaluateJavascript(setFieldValueJs(updated), null)
},
onSubmit = { web?.evaluateJavascript(SUBMIT_FIELD_JS, null) },
)
}
}
}
private class LoggingClient : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
GearslipLog.i("car web page loaded: $url")
view?.evaluateJavascript(FOCUS_TRACKING_JS, null)
}
// 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}")
}
}
}
/** Tells Kotlin when an editable element on the page gains or loses focus, so [CarKeyboard] can show. */
private class FieldFocusBridge(private val onFocus: (String) -> Unit, private val onBlur: () -> Unit) {
private val main = Handler(Looper.getMainLooper())
// WebView calls into this from its own thread, never Compose's.
@JavascriptInterface
fun focused(value: String) { main.post { onFocus(value) } }
@JavascriptInterface
fun blurred() { main.post { onBlur() } }
}
// Plain `el.value = x` never reaches a React-controlled input: React replaces the native value
// setter with its own to track edits, so a direct assignment sets the DOM attribute but leaves
// React's own state (and anything bound to it, like a search box's query) unaware. Calling the
// *native* setter through the prototype, the same trick browser extensions use, is what actually
// notifies it once the 'input' event fires.
private fun setFieldValueJs(value: String) = """
(function() {
var el = document.activeElement;
if (!el) return;
var proto = el.tagName === 'TEXTAREA' ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype;
var setter = Object.getOwnPropertyDescriptor(proto, 'value');
if (setter && setter.set) { setter.set.call(el, ${JSONObject.quote(value)}); } else { el.value = ${JSONObject.quote(value)}; }
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
})();
""".trimIndent()
// Fires a real Enter keydown first - the same event a site's own JS would see from a physical
// keyboard - and only falls back to submitting the form directly if nothing handled it, so a
// search-as-you-type page and a plain <form> both work without submitting twice.
private const val SUBMIT_FIELD_JS = """
(function() {
var el = document.activeElement;
if (!el) return;
var enter = new KeyboardEvent('keydown', {
key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true,
});
var notHandled = el.dispatchEvent(enter);
if (notHandled && el.form) {
if (el.form.requestSubmit) el.form.requestSubmit(); else el.form.submit();
}
})();
"""
/** Reports focus/blur on text inputs, textareas and contenteditable elements to [FieldFocusBridge]. */
private const val FOCUS_TRACKING_JS = """
(function() {
function isEditable(el) {
if (!el) return false;
var tag = el.tagName;
if (tag === 'TEXTAREA') return true;
if (el.isContentEditable) return true;
if (tag !== 'INPUT') return false;
var skip = ['button', 'submit', 'checkbox', 'radio', 'range', 'color', 'file', 'image', 'reset'];
return skip.indexOf((el.type || 'text').toLowerCase()) === -1;
}
document.addEventListener('focusin', function(e) {
if (isEditable(e.target)) window.GearslipField.focused(e.target.value || '');
});
document.addEventListener('focusout', function(e) {
if (isEditable(e.target)) window.GearslipField.blurred();
});
})();
"""
@@ -0,0 +1,47 @@
package app.seb3thehacker.gearslip.host
import android.content.Context
import android.content.pm.PackageManager
/**
* 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 {
/** Kept in sync with the `<queries><package>` entries in the manifest: package visibility
* hides an app's presence entirely unless one names it (or a broader query matches it), and
* this one has no launcher icon on current builds for a launcher-category query to find. */
private val KNOWN_PACKAGES = listOf("com.google.android.projection.gearhead")
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(" ", "")
}
/** The package name of Google's Android Auto, if it is installed. */
fun installedPackage(context: Context): String? {
val manager = context.packageManager
return KNOWN_PACKAGES.firstOrNull { pkg ->
runCatching { manager.getPackageInfo(pkg, 0) }.isSuccess
}
}
/**
* Whether [packageName] can still fight Gearslip for the USB connection: installed, and not
* already disabled for this user. `ApplicationInfo.enabled` only reflects the manifest's
* static default, not a driver's own Settings toggle, so this reads the actual runtime
* setting instead - a driver who disabled it instead of uninstalling it (the app info screen
* offers both) has already done what this step asks.
*/
fun isActive(context: Context, packageName: String): Boolean {
val setting = runCatching { context.packageManager.getApplicationEnabledSetting(packageName) }
.getOrDefault(PackageManager.COMPONENT_ENABLED_STATE_DEFAULT)
return setting == PackageManager.COMPONENT_ENABLED_STATE_DEFAULT ||
setting == PackageManager.COMPONENT_ENABLED_STATE_ENABLED
}
}
@@ -0,0 +1,82 @@
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()) }
.filterNot { KnownApps.isHidden(it.serviceInfo.packageName) }
.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,668 @@
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.navigation.INavigationManager
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
/** Only present for an app that asked for it - most apps aren't navigation apps. */
private var navigationManager: INavigationManager? = 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
/** Whose icon the header shows for the standard app-icon action. */
val appPackage: String? get() = component?.packageName
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 bind = {
var bindError: Throwable? = null
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)
bindError = it
false
}
if (!bound) {
binding = null
fail(Phase.REJECTED, explainBindFailure(app.component, bindError))
} else {
GearslipLog.i("host: binding to ${app.component.flattenToShortString()}")
}
}
bind()
}
/**
* A service that names a permission can only be bound by an app holding it. Some navigation apps
* name one that only Google's own Android Auto host holds, and no other app can be granted.
*/
private fun explainBindFailure(component: ComponentName, error: Throwable?): String {
if (error !is SecurityException) return "the app would not let Gearslip connect"
val needed = runCatching { context.packageManager.getServiceInfo(component, 0).permission }.getOrNull()
return if (needed != null) {
"It only accepts Google's own Android Auto: its service requires the permission $needed, which Gearslip can't hold."
} else {
"It only accepts Google's own Android Auto host."
}
}
/** The app's own account of why it said no, in words rather than an object id. */
private fun describeFailure(step: String, value: Any?): String {
val failure = value as? androidx.car.app.FailureResponse
?: return "$step failed: ${value ?: "no reason given"}"
val firstLine = failure.stackTrace.lineSequence().firstOrNull { it.isNotBlank() }.orEmpty()
GearslipLog.w("host: $step failed: type=${failure.errorType}\n${failure.stackTrace.lineSequence().take(6).joinToString("\n")}")
// The Car App Library's own host check: the app keeps a list of hosts it trusts by signature.
if (firstLine.contains("Unrecognized host")) {
return "It only trusts hosts on its own allow-list (Google's Android Auto and a few others), and Gearslip isn't on it."
}
val kind = when (failure.errorType) {
androidx.car.app.FailureResponse.SECURITY_EXCEPTION -> "It rejected Gearslip as a host"
androidx.car.app.FailureResponse.ILLEGAL_STATE_EXCEPTION -> "It reported a state error"
androidx.car.app.FailureResponse.INVALID_PARAMETER_EXCEPTION -> "It reported an invalid parameter"
else -> "It failed"
}
return "$kind at $step: ${firstLine.substringAfterLast("Exception: ", firstLine)}"
}
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
navigationManager = null
_panMode.value = false
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) }
// Best-effort and silent: an app that never asked for NavigationManager (not every
// templated app navigates) simply has no binder to hand back, which is normal, not a
// failure worth surfacing.
runCatching {
app.getManager(CarContext.NAVIGATION_SERVICE, object : IOnDoneCallback.Stub() {
override fun onSuccess(response: Bundleable?) {
val value = response?.let { runCatching { it.get() }.getOrNull() }
navigationManager = when (value) {
is INavigationManager -> value
is IBinder -> INavigationManager.Stub.asInterface(value)
else -> null
}
}
override fun onFailure(response: Bundleable?) = Unit
})
}
}
/**
* Tells the app itself to stop navigating - the proper end of a route, not just leaving its
* screen. Ending only the on-screen guidance (a back press) leaves the app's own
* "am I navigating" flag set, which is what stopped a fresh route from starting afterwards;
* this is the call [NavigationManagerCallback.onStopNavigation] on the app's side answers,
* and it is expected to clear that flag and present its own post-navigation screen.
*/
fun stopNavigating() {
val manager = navigationManager ?: return
GearslipLog.i("host: asking the app to stop navigating")
runCatching { manager.onStopNavigation(noop("stop navigation")) }
.onFailure { GearslipLog.w("host: could not stop navigation: ${it.message}") }
}
/** 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) }
}
private val _panMode = MutableStateFlow(false)
/**
* Pan mode is the host's to run: the Pan action has no click handler, tapping it just switches
* the screen to the map and its own controls. The app is told only if its template asks.
*/
val panMode: StateFlow<Boolean> = _panMode
fun setPanMode(on: Boolean) {
if (_panMode.value == on) return
_panMode.value = on
GearslipLog.i("host: pan mode ${if (on) "on" else "off"}")
(_template.value as? androidx.car.app.navigation.model.NavigationTemplate)
?.panModeDelegate.panModeChanged(on)
}
/**
* The Back action carries no click handler of its own: the app keeps its own screen stack, so
* the host reports the press and the app answers with the template it went back to.
*/
fun backPressed() {
val manager = appManager ?: return
GearslipLog.i("host: back pressed")
call("onBackPressed", onValue = { requestTemplate() }) { manager.onBackPressed(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.REJECTED, describeFailure(name, 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,192 @@
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"
}
/** "1:10 AM" - the clock at the destination, not the phone's own, in case a route crosses a zone. */
fun androidx.car.app.model.DateTimeWithZone?.clockTime(): String {
val at = this ?: return ""
val shifted = java.util.Date(at.timeSinceEpochMillis + at.zoneOffsetSeconds * 1000L)
return runCatching {
java.text.SimpleDateFormat("h:mm a", java.util.Locale.getDefault()).apply {
timeZone = java.util.TimeZone.getTimeZone("UTC")
}.format(shifted)
}.getOrDefault("")
}
/**
* 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}") }
}
/** Tells the app the driver entered or left pan mode, so it can hide its own map controls. */
fun androidx.car.app.navigation.model.PanModeDelegate?.panModeChanged(active: Boolean) {
val delegate = this ?: return
runCatching { delegate.sendPanModeChanged(active, doneCallback("pan mode")) }
.onFailure { GearslipLog.w("host: could not send pan mode: ${it.message}") }
}
/** Tells the app a toggle row was switched; it answers with a template showing the new state. */
fun androidx.car.app.model.OnCheckedChangeDelegate?.checkedChanged(checked: Boolean) {
val delegate = this ?: return
runCatching { delegate.sendCheckedChange(checked, doneCallback("toggle")) }
.onFailure { GearslipLog.w("host: could not send a toggle: ${it.message}") }
}
/** Tells the app which option of a single-choice list was picked. */
fun androidx.car.app.model.OnSelectedDelegate?.selected(index: Int) {
val delegate = this ?: return
runCatching { delegate.sendSelected(index, doneCallback("selection")) }
.onFailure { GearslipLog.w("host: could not send a selection: ${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,41 @@
package app.seb3thehacker.gearslip.host
/**
* Apps that have been run through Gearslip from start to finish and behave. The launcher marks
* them so a driver can pick something that works without finding out at the wheel.
*
* An app belongs here only after a real session: the app starts, draws, takes touches and
* answers its buttons. Apps that connect but show no library, or refuse Gearslip as a host, stay
* off the list.
*/
object KnownApps {
private val working = setOf(
"app.organicmaps", // Organic Maps: map, search, routing, settings
"net.osmand.plus", // OsmAnd: map, search, routing, settings
"in.krosbits.musicolet", // Musicolet: browse, play, seek, lyrics
"com.kododake.aabrowser", // Aardvark: browser template, starts, draws, takes touches
"com.vivi.vivimusic", // Vivi Music: browse, play, seek, works well
)
fun works(packageName: String): Boolean = packageName in working
/**
* Registers a car template service but never gives Gearslip a usable screen - left off the
* launcher entirely rather than shown as a tile that goes nowhere.
*/
private val hidden = setOf(
"com.google.android.dialer", // Google Phone
"com.google.android.googlequicksearchbox", // Google Assistant driving surfaces
)
fun isHidden(packageName: String): Boolean = packageName in hidden
/** Shown on the launcher with a red X: known broken, but expected enough that hiding it outright would look like a bug. */
private val broken = setOf(
"com.google.android.apps.messaging", // Google Messages
"com.waze", // Waze
)
fun isBroken(packageName: String): Boolean = packageName in broken
}
@@ -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,341 @@
package app.seb3thehacker.gearslip.media
import android.content.ComponentName
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.MediaSession
import android.media.session.MediaSessionManager
import android.media.session.PlaybackState
import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.notify.GearslipNotificationListener
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?)
/** One entry in the app's own queue - not its library, whatever it has lined up to play next. */
class QueueTrack(
val queueId: Long,
val title: String,
val subtitle: String,
val iconUri: Uri?,
val iconBitmap: Bitmap?,
)
/** 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,
/** Which [QueueTrack.queueId] is playing now, so "up next" can show what follows it. */
val activeQueueItemId: Long = MediaSession.QueueItem.UNKNOWN_ID.toLong(),
) {
val playing get() = state == PlaybackState.STATE_PLAYING || state == PlaybackState.STATE_BUFFERING
val hasTrack get() = title.isNotEmpty() || state != PlaybackState.STATE_NONE
/**
* Something is actually playing or paused mid-track. A stopped, errored or empty session, or one
* that has not named a track yet, must not keep the player on screen.
*/
val isActive get() = title.isNotEmpty() && when (state) {
PlaybackState.STATE_NONE, PlaybackState.STATE_STOPPED, PlaybackState.STATE_ERROR -> false
else -> true
}
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,
/** The app connected but shares no library with us, so there is nothing to browse - only to control. */
val unavailable: 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 }
/** Why a connection was [Phase.REJECTED], so the screen can say something useful. */
enum class Rejection { REFUSED, NEEDS_NOTIFICATION_ACCESS }
private val _rejection = MutableStateFlow(Rejection.REFUSED)
val rejection: StateFlow<Rejection> = _rejection.asStateFlow()
private val main = Handler(Looper.getMainLooper())
private var connecting: MediaApp? = null
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 val _queue = MutableStateFlow<List<QueueTrack>>(emptyList())
val queue: StateFlow<List<QueueTrack>> = _queue.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()
connecting = app
_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 to its library")
attachToSession(app)
}
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
}
}
/**
* Some apps only let Google's own host browse them. Their playback session is still public, so
* playback can be driven from it: what is playing, play and pause, skip, seek. Only the library
* is out of reach. Reading another app's session needs Notification access, which the driver
* grants once on the phone.
*/
private fun attachToSession(app: MediaApp, attempt: Int = 0) {
if (connecting != app) return
val manager = context.getSystemService(MediaSessionManager::class.java)
val listener = ComponentName(context, GearslipNotificationListener::class.java)
val session = try {
manager.getActiveSessions(listener).firstOrNull { it.packageName == app.component.packageName }
} catch (e: SecurityException) {
GearslipLog.w("media: cannot read ${app.label}'s session without notification access")
_rejection.value = Rejection.NEEDS_NOTIFICATION_ACCESS
_phase.value = Phase.REJECTED
return
}
if (session == null) {
// The app only publishes its session once its service has started; give it a moment.
if (attempt < SESSION_RETRIES) {
main.postDelayed({ attachToSession(app, attempt + 1) }, SESSION_RETRY_MS)
} else {
GearslipLog.w("media: ${app.label} has no active session to control")
_rejection.value = Rejection.REFUSED
_phase.value = Phase.REJECTED
}
return
}
GearslipLog.i("media: controlling ${app.label} through its session; its library is not shared")
controller = session.also {
it.registerCallback(callback)
update(it.metadata, it.playbackState)
_queue.value = queueOf(it.queue)
}
_browse.value = BrowseState(loading = false, unavailable = true)
_phase.value = Phase.READY
}
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)
_queue.value = queueOf(it.queue)
}
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)
override fun onQueueChanged(queue: MutableList<MediaSession.QueueItem>?) {
_queue.value = queueOf(queue)
}
}
private fun queueOf(items: List<MediaSession.QueueItem>?): List<QueueTrack> = items.orEmpty().map { item ->
val d = item.description
QueueTrack(
queueId = item.queueId,
title = d.title?.toString().orEmpty(),
subtitle = d.subtitle?.toString().orEmpty(),
iconUri = d.iconUri,
iconBitmap = d.iconBitmap,
)
}
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(),
activeQueueItemId = state?.activeQueueItemId ?: MediaSession.QueueItem.UNKNOWN_ID.toLong(),
)
}
// --- 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,
// An empty top level is an app keeping its library to itself, not an empty one.
unavailable = path.isEmpty() && children.isEmpty(),
)
}
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 playQueueItem(track: QueueTrack) = controller?.transportControls?.skipToQueueItem(track.queueId)
fun disconnect() {
connecting = null
main.removeCallbacksAndMessages(null)
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()
_queue.value = emptyList()
}
private companion object {
const val SESSION_RETRIES = 6
const val SESSION_RETRY_MS = 500L
}
}
@@ -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,59 @@
package app.seb3thehacker.gearslip.media
import app.seb3thehacker.gearslip.host.AndroidAuto
import app.seb3thehacker.gearslip.host.KnownApps
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()) }
.filterNot { KnownApps.isHidden(it.serviceInfo.packageName) }
.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,93 @@
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
@Volatile private var cache: List<PhoneApp>? = null
/** What the last scan found, or null before the first one. */
fun cached(): List<PhoneApp>? = cache
/** Scans and stores the result; [Prefetch] calls this so the screen opens already filled. */
fun warm(context: Context) { cache = installed(context) }
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,263 @@
package app.seb3thehacker.gearslip.ui
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.content.res.Configuration
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.LocalConfiguration
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.TouchPoint
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) }
// Tied to how the phone itself is held, not a button: turn it sideways and the preview goes
// fullscreen, turn it back and the phone's own chrome (the Back/title above the frame) returns.
val fullscreen = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
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.actionIndex,
List(event.pointerCount) { i ->
TouchPoint(
event.getPointerId(i),
event.getX(i) * width / viewWidth,
event.getY(i) * 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(onBack) {
// 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") }
}
}
}
}
/**
* 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. This only shows once the phone is already turned sideways (the caller drives it off the
* phone's own orientation), so there is no rotation left to force here - just the system bars to
* hide while it's up and restore on the way out.
*/
@Composable
private fun Fullscreen(
onBack: () -> Unit,
content: @Composable BoxScope.() -> Unit,
) {
val activity = LocalContext.current.activity()
DisposableEffect(activity) {
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())
}
onDispose { insets.show(WindowInsetsCompat.Type.systemBars()) }
}
// Turning the phone back upright is what normally leaves fullscreen; back still leaves the
// preview screen entirely rather than doing nothing, in case the phone's own rotation lock
// is on and turning it sideways/back isn't an option.
BackHandler(onBack = onBack)
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 = onBack,
modifier = Modifier.align(Alignment.BottomStart).padding(8.dp),
) {
Icon(Icons.Filled.Close, contentDescription = "Leave the car preview")
}
}
}
/** 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
}
/** The actions [ScreenProjector.dispatchTouch] understands reach the car UI, second fingers included. */
private fun Int.toCarAction(): Int = when (this) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE,
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL,
MotionEvent.ACTION_POINTER_DOWN, MotionEvent.ACTION_POINTER_UP,
-> 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,265 @@
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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
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.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
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.runtime.Composable
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.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.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
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
/**
* Shown when Gearslip has no certificate loaded. Download is the one clear path; importing your
* own file is offered as a smaller, secondary option underneath it.
*
* The app ships no certificates. Skipping generates a self-signed one, which a real head unit
* rejects.
*/
@Composable
fun CertSetupScreen(onDone: () -> Unit) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var downloadState by remember { mutableStateOf<DownloadState>(DownloadState.Idle) }
var showImport by remember { mutableStateOf(false) }
var pendingUri by remember { mutableStateOf<Uri?>(null) }
var manualProgress by remember { mutableStateOf(false) }
val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri != null) pendingUri = uri
}
val installed = manualProgress || downloadState is DownloadState.Success
Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding ->
Column(
Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 24.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
"Certificate Setup",
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground,
)
Spacer(Modifier.height(8.dp))
Text(
if (installed) {
"Cars accept only Google-issued certificates, and you have one loaded."
} else {
"Cars accept only Google-issued certificates. Download one to connect."
},
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(32.dp))
if (installed) {
Text(
if (manualProgress) "Certificate imported." else "Certificate installed.",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(20.dp))
Button(onClick = onDone, modifier = Modifier.fillMaxWidth().height(56.dp)) {
Text("Continue")
}
Spacer(Modifier.height(40.dp))
return@Column
}
when (downloadState) {
is DownloadState.Idle -> {
Button(
onClick = {
downloadState = DownloadState.Loading
scope.launch {
val result = withContext(Dispatchers.IO) {
runCatching { CertProvider.downloadAndImport(context) }
}
downloadState = result.fold(
onSuccess = { DownloadState.Success(it.certificate.subjectX500Principal.name) },
onFailure = { DownloadState.Error(it.message ?: "Unknown error") },
)
}
},
modifier = Modifier.fillMaxWidth().height(56.dp),
) {
Text("Download certificate")
}
Text(
"Works with most cars built before 2020. Newer cars reject it.",
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
}
is DownloadState.Loading -> {
Box(Modifier.fillMaxWidth().height(56.dp), contentAlignment = Alignment.Center) {
DownloadingRow {
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
Text("Downloading…", style = MaterialTheme.typography.bodyLarge)
}
}
}
is DownloadState.Success -> Unit // handled above via `installed`
is DownloadState.Error -> {
Surface(color = MaterialTheme.colorScheme.errorContainer, shape = MaterialTheme.shapes.medium) {
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"Download failed",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onErrorContainer,
)
Text(
(downloadState as DownloadState.Error).message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer,
)
Button(onClick = { downloadState = DownloadState.Idle }, modifier = Modifier.fillMaxWidth()) {
Text("Retry")
}
}
}
}
}
Spacer(Modifier.height(16.dp))
if (showImport) {
OutlinedButton(
onClick = { importLauncher.launch(arrayOf("*/*")) },
modifier = Modifier.fillMaxWidth().height(52.dp),
) { Text("Import your own .p12 cert") }
Spacer(Modifier.height(16.dp))
} else {
TextButton(onClick = { showImport = true }) { Text("Import your own certificate instead") }
Spacer(Modifier.height(8.dp))
}
OutlinedButton(
onClick = {
AppSettings.setSkippedCertSetup(context)
onDone()
},
modifier = Modifier.fillMaxWidth().height(52.dp),
) {
Text("Skip car connection setup")
}
Spacer(Modifier.height(40.dp))
}
}
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
manualProgress = true
}.onFailure {
reportError("Wrong password or not a valid PKCS#12 file with a private key.")
}
}
},
)
}
}
/** A tight horizontal row for the inline "Downloading…" spinner state. */
@Composable
private fun DownloadingRow(content: @Composable RowScope.() -> Unit) {
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
content = content,
)
}
private sealed class DownloadState {
data object Idle : DownloadState()
data object Loading : DownloadState()
data class Success(val subjectName: String) : DownloadState()
data class Error(val message: String) : DownloadState()
}
@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,49 @@
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 (self-signed; cars reject it)",
null, null, null,
)
val cert = identity.certificate
val name = friendlyName(cert.subjectX500Principal.name)
val prefix = when (identity.kind) {
CertProvider.Kind.IMPORTED -> "Imported"
CertProvider.Kind.DOWNLOADED -> "Downloaded"
CertProvider.Kind.ADB_STAGED -> "Staged over adb"
CertProvider.Kind.SELF_SIGNED -> "Self-signed"
}
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,50 @@
package app.seb3thehacker.gearslip.ui
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
/**
* A command to run on a computer, shown so it can be copied rather than retyped. Tapping it puts
* it on the clipboard, which is the only way it reaches the computer from here anyway.
*/
@Composable
fun CopyableCommand(command: String) {
val clipboard = LocalClipboardManager.current
val context = LocalContext.current
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = MaterialTheme.shapes.small,
onClick = {
clipboard.setText(AnnotatedString(command))
Toast.makeText(context, "Command copied", Toast.LENGTH_SHORT).show()
},
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
) {
Column(Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
command,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
"Tap to copy",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
@@ -0,0 +1,88 @@
package app.seb3thehacker.gearslip.ui
import androidx.activity.compose.BackHandler
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import app.seb3thehacker.gearslip.AppSettings
import app.seb3thehacker.gearslip.CertProvider
private enum class Screen { HOME, LOGS, SETTINGS, CAR_PREVIEW, CERT_SETUP, SETUP_GUIDE, HELP }
/** Three screens and a back stack of depth one: no navigation library needed. */
@Composable
fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
val context = LocalContext.current
val needsCertSetup = remember {
!AppSettings.hasSkippedCertSetup(context) && !CertProvider.hasAnyCert(context)
}
val needsSetupGuide = remember { !AppSettings.hasSeenPermissionsSetup(context) }
var screen by rememberSaveable {
mutableStateOf(
when {
needsCertSetup -> Screen.CERT_SETUP
needsSetupGuide -> Screen.SETUP_GUIDE
else -> 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 },
onOpenHelp = { screen = Screen.HELP },
onDisconnect = onDisconnect,
)
Screen.LOGS -> LogsScreen(onBack = { screen = Screen.HOME })
Screen.SETTINGS -> SettingsScreen(onBack = { screen = Screen.HOME })
Screen.CAR_PREVIEW -> CarPreviewScreen(onBack = { screen = Screen.HOME })
Screen.HELP -> HelpScreen(onBack = { screen = Screen.HOME }, onRequestCallScreening = onRequestCallScreening)
Screen.CERT_SETUP -> CertSetupScreen(
onDone = {
screen = if (AppSettings.hasSeenPermissionsSetup(context)) Screen.HOME else Screen.SETUP_GUIDE
},
)
Screen.SETUP_GUIDE -> SetupGuideScreen(
onRequestCallScreening = onRequestCallScreening,
onDone = { screen = Screen.HOME },
)
}
CompatWarning()
}
/** Shown once, on first run: the certificate is known to fail on head units newer than ~2020. */
@Composable
private fun CompatWarning() {
val context = LocalContext.current
var visible by rememberSaveable { mutableStateOf(!AppSettings.hasSeenCompatWarning(context)) }
if (visible) {
AlertDialog(
onDismissRequest = {},
title = { Text("Before you plug in") },
text = {
Text(
"Gearslip works with cars built before 2020. " +
"Newer firmware often blocks the connection.",
)
},
confirmButton = {
TextButton(onClick = {
AppSettings.setSeenCompatWarning(context)
visible = false
}) { Text("Got it") }
},
)
}
}
@@ -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,157 @@
package app.seb3thehacker.gearslip.ui
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.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
/**
* One step of the setup list: a sentence that says what to do, and optionally how.
*
* [command] is for a step that cannot be done on the phone; it is shown as something to copy
* rather than to read, because a mistyped package name fails silently. [onClick] is for a step
* that can, in one tap, in which case it is shown as a button instead.
*/
private class Step(
val action: String,
val detail: String? = null,
val command: String? = null,
val buttonLabel: String? = null,
val onClick: (() -> Unit)? = null,
)
private fun setupSteps(onRequestCallScreening: () -> Unit) = listOf(
Step("Use a car from 2020 or earlier.", "Newer head units reject the certificate."),
Step(
"On GrapheneOS, exempt Gearslip from exploit protections.",
"Open Settings, then Apps, then Gearslip, and turn on Exploit protection compatibility mode.",
),
Step("Disable or uninstall the Android Auto app."),
Step("Load a certificate.", "Open Settings, then download or import a certificate."),
Step(
"Allow the microphone, so music can reach the car.",
"Android asks for the microphone before it will let any app pass music along. " +
"Gearslip never listens to you: it copies what a music app is playing, and nothing else.",
),
Step(
"Optional: stop Android asking about audio every drive.",
"Connect the phone to a computer with USB debugging turned on, and run this command. " +
"It tells Android to trust Gearslip with audio from now on. Skip it and everything " +
"still works, but you have to tap Start once each time you set off.",
command = "adb shell appops set app.seb3thehacker.gearslip PROJECT_MEDIA allow",
),
Step(
"Optional: keep notifications readable while music plays.",
"Android treats sending audio to the car like sharing your screen, and hides what your " +
"notifications say. Run this command too, and Gearslip switches that off only while " +
"music is going to the car, then puts it back.",
command = "adb shell pm grant app.seb3thehacker.gearslip android.permission.WRITE_SECURE_SETTINGS",
),
Step(
"Optional: let the car screen answer and decline calls.",
"This sets Gearslip as your phone's caller ID and spam-blocking app, the same kind of " +
"consent a call-blocking app asks for. Without it, calls still ring, but only the " +
"phone itself can answer or decline them.",
buttonLabel = "Set up",
onClick = onRequestCallScreening,
),
Step("Plug the phone into the car.", "Gearslip opens by itself when the head unit connects."),
)
/** The full setup reference, behind the help button: what the one-time guide walks through, kept
* here too so it can be reread without repeating the guide itself. */
@Composable
fun HelpScreen(onBack: () -> Unit, onRequestCallScreening: () -> Unit) {
val steps = remember(onRequestCallScreening) { setupSteps(onRequestCallScreening) }
Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding ->
Column(
Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 24.dp)
.verticalScroll(rememberScrollState()),
) {
Spacer(Modifier.height(24.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onBack) { Text("‹", style = MaterialTheme.typography.headlineMedium) }
Text(
"How to connect",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground,
)
}
Spacer(Modifier.height(16.dp))
Column(verticalArrangement = Arrangement.spacedBy(18.dp)) {
steps.forEachIndexed { index, step ->
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Text(
"${index + 1}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.width(16.dp),
)
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(step.action, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold)
step.detail?.let {
Text(
it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
step.command?.let { CopyableCommand(it) }
if (step.onClick != null) {
FilledTonalButton(onClick = step.onClick, modifier = Modifier.padding(top = 4.dp)) {
Text(step.buttonLabel ?: "Open")
}
}
}
}
}
}
Spacer(Modifier.height(32.dp))
}
}
}
/** The small circular "?" button that opens [HelpScreen] from the top right of the home screen. */
@Composable
fun HelpButton(onClick: () -> Unit) {
Surface(
onClick = onClick,
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
Box(Modifier.padding(10.dp), contentAlignment = Alignment.Center) {
Text(
"?",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
@@ -0,0 +1,181 @@
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 android.content.Context
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.Switch
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.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.seb3thehacker.gearslip.AppSettings
import app.seb3thehacker.gearslip.SessionStatus
@Composable
fun HomeScreen(
onOpenLogs: () -> Unit,
onOpenSettings: () -> Unit,
onOpenCarPreview: () -> Unit,
onOpenHelp: () -> Unit,
onDisconnect: () -> Unit,
) {
val status by SessionStatus.state.collectAsStateWithLifecycle()
val context = LocalContext.current
var debug by remember { mutableStateOf(AppSettings.debugMode(context)) }
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))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Column {
Text(
"Gearslip",
style = MaterialTheme.typography.displayLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground,
)
Text(
"v${LocalContext.current.appVersionName()}",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
HelpButton(onClick = onOpenHelp)
}
Spacer(Modifier.height(16.dp))
StatusCard(status)
Spacer(Modifier.height(24.dp))
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(Modifier.weight(1f)) {
Text("Debug mode", style = MaterialTheme.typography.titleMedium)
Text(
if (debug) "Full protocol logging, live logs and car preview."
else "Quiet logging. Warnings and errors are still recorded.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = debug, onCheckedChange = {
debug = it
AppSettings.setDebugMode(context, it)
})
}
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 && debug) {
FilledTonalButton(
onClick = onOpenCarPreview,
modifier = Modifier.fillMaxWidth().height(56.dp),
) {
Text("Car preview")
}
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
if (debug) {
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)
}
}
}
}
/** The version shown on screen, straight from the manifest - one place to bump per release. */
private fun Context.appVersionName(): String =
runCatching { packageManager.getPackageInfo(packageName, 0).versionName }.getOrNull() ?: "?"
@@ -0,0 +1,162 @@
package app.seb3thehacker.gearslip.ui
import android.content.Intent
import androidx.compose.material3.Surface
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Button
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Arrangement
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.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")
}
},
)
},
bottomBar = {
Surface(color = MaterialTheme.colorScheme.surfaceContainer) {
Row(
Modifier.fillMaxWidth().navigationBarsPadding().padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Button(
onClick = {
scope.launch {
val uri = withContext(Dispatchers.IO) { GearslipLog.shareableFile(context) }
val send = Intent(Intent.ACTION_SEND)
.setType("text/plain")
.putExtra(Intent.EXTRA_SUBJECT, "Gearslip log")
.putExtra(Intent.EXTRA_STREAM, uri)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
context.startActivity(Intent.createChooser(send, "Share log"))
}
},
modifier = Modifier.weight(1f).height(56.dp),
) { Text("Share") }
OutlinedButton(
onClick = { GearslipLog.clearLive() },
modifier = Modifier.weight(1f).height(56.dp),
) { 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,230 @@
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.FilledTonalButton
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.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))
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 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)")
}
FilledTonalButton(
onClick = {
scope.launch {
val result = withContext(Dispatchers.IO) {
runCatching { CertProvider.downloadAndImport(context) }
}
result.onSuccess {
notice = "Certificate downloaded."
refresh++
}.onFailure {
notice = "Download failed: ${it.message}"
}
}
},
modifier = Modifier.fillMaxWidth().height(52.dp),
) {
Text("Download certificate")
}
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") }
}
if (summary?.kind == CertProvider.Kind.DOWNLOADED) {
OutlinedButton(
onClick = {
CertProvider.removeDownloaded(context)
notice = "Removed the downloaded certificate."
refresh++
},
modifier = Modifier.fillMaxWidth().height(52.dp),
) { Text("Remove downloaded certificate") }
}
notice?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) }
Text(
"Changes take 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") } },
)
}
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff. Show more