mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-11 00:12:24 +01:00
Compare commits
2
Commits
v0.0.05-alpha
..
pre
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af9e35070f | ||
|
|
9e35b38b17 |
No files matched your search
@@ -1,42 +0,0 @@
|
||||
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
@@ -1,27 +0,0 @@
|
||||
*.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/
|
||||
@@ -1,663 +0,0 @@
|
||||
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/>.
|
||||
@@ -1,16 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,63 +0,0 @@
|
||||
<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).
|
||||
@@ -1,7 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -1,421 +0,0 @@
|
||||
# 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.
|
Before Width: | Height: | Size: 17 KiB |
@@ -1,14 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.9 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 11 KiB |
@@ -1,14 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,176 +0,0 @@
|
||||
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.
|
Before Width: | Height: | Size: 79 KiB |
@@ -1,10 +0,0 @@
|
||||
# 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
|
||||
@@ -1,114 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,62 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,68 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,329 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,837 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
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 — projected from the phone</h1>
|
||||
<div id="t">starting…</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()
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
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)
|
||||
@@ -1,178 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
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.")
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
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",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
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
|
||||
@@ -1,40 +0,0 @@
|
||||
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 }
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
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(".", ",", "?", "!", "'", "#", "%", "*", "+", "="),
|
||||
)
|
||||
@@ -1,252 +0,0 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
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 }
|
||||
@@ -1,122 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
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(","))
|
||||
}
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,474 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
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() }
|
||||
@@ -1,499 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
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
|
||||
@@ -1,130 +0,0 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,374 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
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}") }
|
||||
}
|
||||
}
|
||||
@@ -1,470 +0,0 @@
|
||||
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
|
||||
@@ -1,520 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,649 +0,0 @@
|
||||
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
|
||||
@@ -1,92 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
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}" +
|
||||
"¤t=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 -> "-"
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
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();
|
||||
});
|
||||
})();
|
||||
"""
|
||||
@@ -1,47 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -1,668 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
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
|
||||
@@ -1,41 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
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")
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
-89
@@ -1,89 +0,0 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
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() }
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
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") } },
|
||||
)
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
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") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
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() ?: "?"
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
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
Reference in new issue
Block a user