mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
Compare commits
35
Commits
pre
..
v0.1.10-test
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01a7ae1930 | ||
|
|
c52a6c32b7 | ||
|
|
f21df41506 | ||
|
|
ff8a6a4cff | ||
|
|
a2d9ed7008 | ||
|
|
3994c9bd2e | ||
|
|
4e4a246a51 | ||
|
|
ebd0ba4add | ||
|
|
42785ce6be | ||
|
|
db205919c8 | ||
|
|
f452099926 | ||
|
|
03b74b1150 | ||
|
|
fbaeb0cc57 | ||
|
|
5f557a5973 | ||
|
|
86edc42b98 | ||
|
|
68f9bf35a1 | ||
|
|
e314aa8546 | ||
|
|
1b2010f939 | ||
|
|
f25eafcc48 | ||
|
|
473c12ad8f | ||
|
|
89b8157eb9 | ||
|
|
304c1de3bc | ||
|
|
6156da4b67 | ||
|
|
ae7c3ec308 | ||
|
|
e7f0aef43d | ||
|
|
97c6a26f61 | ||
|
|
5f9701ccff | ||
|
|
043dc11251 | ||
|
|
48be745659 | ||
|
|
7e4b4d6b99 | ||
|
|
a8b49a3672 | ||
|
|
94f8f23a56 | ||
|
|
e1732f8365 | ||
|
|
cb895f0658 | ||
|
|
5b7b9499ca |
No files matched your search
@@ -0,0 +1,59 @@
|
|||||||
|
name: Bug report
|
||||||
|
description: Something in Gearslip broke or behaves wrong.
|
||||||
|
labels: ["bug"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for reporting. Check the [open issues](https://github.com/Seb3thehacker/gearslip/issues) first, in case someone has already reported it.
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: car
|
||||||
|
attributes:
|
||||||
|
label: Car
|
||||||
|
description: Make, model, and year.
|
||||||
|
placeholder: 2019 Honda Civic
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: head-unit
|
||||||
|
attributes:
|
||||||
|
label: Head unit
|
||||||
|
options:
|
||||||
|
- The one the car came with
|
||||||
|
- A factory unit from another car
|
||||||
|
- An aftermarket unit (Pioneer, Kenwood, Alpine, and so on)
|
||||||
|
- A homemade or open-source unit, such as LIVI
|
||||||
|
- I don't know
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: connection
|
||||||
|
attributes:
|
||||||
|
label: Connection
|
||||||
|
options:
|
||||||
|
- USB cable
|
||||||
|
- Wireless dongle
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: what-happened
|
||||||
|
attributes:
|
||||||
|
label: What happened
|
||||||
|
description: What you did, what you expected, and what happened instead.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: log
|
||||||
|
attributes:
|
||||||
|
label: Log
|
||||||
|
description: |
|
||||||
|
On the phone, open Logs from Gearslip's home screen and tap Share log. Drag the file here.
|
||||||
|
The log records your phone, its Android version, the Gearslip version, and why the connection ended.
|
||||||
|
placeholder: Drop the log file here.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
name: Car report
|
||||||
|
description: Tell us whether Gearslip works in your car, so others know before they install.
|
||||||
|
labels: ["car report"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Reports from working cars help as much as reports from broken ones.
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: car
|
||||||
|
attributes:
|
||||||
|
label: Car
|
||||||
|
description: Make, model, and year.
|
||||||
|
placeholder: 2019 Honda Civic
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: head-unit
|
||||||
|
attributes:
|
||||||
|
label: Head unit
|
||||||
|
options:
|
||||||
|
- The one the car came with
|
||||||
|
- A factory unit from another car
|
||||||
|
- An aftermarket unit (Pioneer, Kenwood, Alpine, and so on)
|
||||||
|
- A homemade or open-source unit, such as LIVI
|
||||||
|
- I don't know
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: connection
|
||||||
|
attributes:
|
||||||
|
label: Connection
|
||||||
|
options:
|
||||||
|
- USB cable
|
||||||
|
- Wireless dongle
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: result
|
||||||
|
attributes:
|
||||||
|
label: Does it work?
|
||||||
|
options:
|
||||||
|
- Yes, everything I tried works
|
||||||
|
- Partly. It connects, but something is wrong
|
||||||
|
- No, it never connects
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: notes
|
||||||
|
attributes:
|
||||||
|
label: Notes
|
||||||
|
description: What you tried, and anything that didn't work.
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: log
|
||||||
|
attributes:
|
||||||
|
label: Log
|
||||||
|
description: |
|
||||||
|
Needed if the car doesn't work. On the phone, open Logs from Gearslip's home screen and tap Share log. Drag the file here.
|
||||||
|
placeholder: Drop the log file here.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
name: Feature request
|
||||||
|
description: Suggest something Gearslip should do.
|
||||||
|
labels: ["enhancement"]
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: What do you want to do?
|
||||||
|
description: Describe the problem, not the solution. "I can't see my next turn while music plays" helps more than "add a turn widget."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: idea
|
||||||
|
attributes:
|
||||||
|
label: How should it work?
|
||||||
|
description: Your idea for a fix, if you have one.
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: apps
|
||||||
|
attributes:
|
||||||
|
label: Apps involved
|
||||||
|
description: Any apps this touches, such as a map or music app.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21"
|
||||||
|
|
||||||
|
- uses: gradle/actions/setup-gradle@v4
|
||||||
|
|
||||||
|
- 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: |
|
||||||
|
# The build moves its APKs to <root>/build/Gearslip-<variant>.apk (see renameApk).
|
||||||
|
apk=build/Gearslip-release.apk
|
||||||
|
test -f "$apk" || { echo "no APK at $apk"; ls -R build | head -50; exit 1; }
|
||||||
|
echo "path=$apk" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Create release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
files: ${{ steps.apk.outputs.path }}
|
||||||
|
generate_release_notes: true
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
*.iml
|
||||||
|
.gradle/
|
||||||
|
.idea/
|
||||||
|
.DS_Store
|
||||||
|
.kotlin
|
||||||
|
/build
|
||||||
|
local.properties
|
||||||
|
|
||||||
|
# Keys
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
keystore.properties
|
||||||
|
|
||||||
|
# Native build cache.
|
||||||
|
.cxx/
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
/docs/cert-sweep.sh
|
||||||
|
extraction/
|
||||||
|
/cloned-repos
|
||||||
|
|
||||||
|
# Internal
|
||||||
|
*.pdf
|
||||||
|
todo
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# For AI code
|
||||||
|
.claude/
|
||||||
|
CLAUDE.md
|
||||||
|
|
||||||
|
# Other
|
||||||
|
archive/
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Contributing to Gearslip
|
||||||
|
|
||||||
|
Thanks for helping. Bug reports from real cars are the most useful thing you can send, so
|
||||||
|
start there if you aren't sure where to begin.
|
||||||
|
|
||||||
|
## Reporting a bug
|
||||||
|
|
||||||
|
Open an issue on [GitHub](https://github.com/Seb3thehacker/gearslip/issues) and include:
|
||||||
|
|
||||||
|
- your car's make, model, and year, and whether the head unit is the original one; and
|
||||||
|
- the log from the drive. Open Logs on the phone app's home screen and tap Share log.
|
||||||
|
|
||||||
|
The log records your phone, its Android version, the Gearslip version, the head unit, and
|
||||||
|
why the connection ended. Most car bugs can't be fixed without it. Head units often report
|
||||||
|
only a part number, so the log can't name your car for you.
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
You need Android Studio, or the Android SDK with JDK 21. Gradle downloads the NDK, CMake,
|
||||||
|
and the speech library on the first build.
|
||||||
|
|
||||||
|
```
|
||||||
|
./gradlew :gearslip:assembleDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
The APK lands at `build/Gearslip-debug.apk`. It installs as Gearslip Dev, next to the
|
||||||
|
regular app.
|
||||||
|
|
||||||
|
## Pull requests
|
||||||
|
|
||||||
|
Work happens on the `experimental` branch, which merges into `main` at each release. Base your
|
||||||
|
branch on `experimental`, and open your pull request against it.
|
||||||
|
|
||||||
|
- Keep each pull request to one change, and explain the problem it solves.
|
||||||
|
- Say how you tested it: which car or head unit, which phone, and which apps.
|
||||||
|
- Code written with AI tools is welcome. Read it, test it, and stand behind it as you would
|
||||||
|
your own.
|
||||||
|
- Gearslip asks for no adb, root, or Shizuku. A feature must work with ordinary Android
|
||||||
|
permissions and on-device consent dialogs.
|
||||||
|
|
||||||
|
## Writing
|
||||||
|
|
||||||
|
Text that users read, such as screens, release notes, and docs, follows Strunk's
|
||||||
|
[*The Elements of Style*](https://www.gutenberg.org/ebooks/37134). In short: use the active
|
||||||
|
voice, state things positively, cut needless words, and use the serial comma.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Gearslip is licensed under AGPL-3.0, and your contributions are too. The author also sells a
|
||||||
|
commercial license, described in [NOTICE.md](NOTICE.md). By opening a pull request, you
|
||||||
|
agree that your contribution may be offered under that license as well.
|
||||||
@@ -0,0 +1,663 @@
|
|||||||
|
Copyright (C) 2026 seb
|
||||||
|
|
||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# License
|
||||||
|
|
||||||
|
Gearslip is licensed under the GNU Affero General Public License v3.0. The full text is in `LICENSE`.
|
||||||
|
|
||||||
|
You may use, run, and modify Gearslip freely. If you distribute a modified version, or let others use one over a network, you must publish your changes under the same license. The obligation follows the code even when you hand out no binary, which suits software that runs as a background service.
|
||||||
|
|
||||||
|
## Commercial licensing
|
||||||
|
|
||||||
|
If the AGPL's terms don't work for you, the author sells a separate commercial license. The usual case is a company that wants to use Gearslip privately without publishing its changes. Terms, including any limits on modification, are set case by case. Contact me on Matrix; the address is on my profile.
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<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.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Phone calls.** Dial from a keypad, or search your contacts, then confirm before the
|
||||||
|
call goes out.
|
||||||
|
- **Navigation.** Gearslip hosts real Car App Library map apps on the car screen; Organic
|
||||||
|
Maps and OsmAnd both work today.
|
||||||
|
- **Music.** Browse, play, and queue tracks from a car-enabled media app, with a Now
|
||||||
|
Playing view, an Up Next queue, and lyrics.
|
||||||
|
- **The web.** A browser template with its own on-screen keyboard, for anything that
|
||||||
|
doesn't ship a car interface of its own.
|
||||||
|
- **Weather and the day ahead.** A dashboard screen shows the week's calendar next to
|
||||||
|
the current and coming weather.
|
||||||
|
- **Messages.** New messages appear as a small card on the car screen. Gearslip reads
|
||||||
|
each one aloud, and you reply by voice or with a quick reply. Speech runs on the phone,
|
||||||
|
so it needs no Google account.
|
||||||
|
- **Notifications.** Only messages, missed calls, alarms, and reminders interrupt you.
|
||||||
|
The rest wait on the dashboard.
|
||||||
|
- **Car controls.** Steering wheel buttons, control knobs, and D-pads all work.
|
||||||
|
- **Themes.** Choose flat buttons or skeuomorphic ones that look like real keys.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- **The connection is wired only, for now.** Plug the phone into the head unit's USB port;
|
||||||
|
Gearslip does not yet offer a wireless option.
|
||||||
|
- **Setup walks you through it.** First launch asks for the Android Auto certificate,
|
||||||
|
then one permission at a time, each with a plain reason and an Allow or Skip. A help
|
||||||
|
screen inside the car answers anything setup didn't cover.
|
||||||
|
- **Templated apps work.** Gearslip renders all 16 template types that the Car App
|
||||||
|
Library defines, and it draws its own keyboard for search, sign-in, and text fields.
|
||||||
|
It has been tested against several real navigation and media apps.
|
||||||
|
- **Gearslip works with [LIVI](https://github.com/f-io/LIVI),** the open-source head unit
|
||||||
|
used in homemade car dashboards.
|
||||||
|
- **Some apps refuse Gearslip.** Spotify, Waze, and Google Messages use a version of
|
||||||
|
Google's car library that accepts only Android Auto itself. Spotify still plays through
|
||||||
|
Gearslip's own player.
|
||||||
|
- **For now, Gearslip works with cars from before 2020.** Newer head units reject its
|
||||||
|
certificate.
|
||||||
|
- **Calls use the car's Bluetooth,** as they do in Android Auto. Pair the phone with the
|
||||||
|
car to hear calls through its speakers.
|
||||||
|
- **Audio reaches the car by capturing playback, not by mirroring the screen.** Android
|
||||||
|
shows a screen-share consent dialog for this, even though Gearslip never reads the
|
||||||
|
screen; it is the only way an ordinary app can capture another app's audio. The dialog
|
||||||
|
appears once per drive.
|
||||||
|
- **Gearslip needs no adb, root, or Shizuku.** Every feature relies on an ordinary
|
||||||
|
Android permission or an on-device consent dialog. The setup guide offers two optional
|
||||||
|
adb commands: one removes the audio dialog for good, and the other keeps notifications
|
||||||
|
visible while audio plays.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Trademarks
|
||||||
|
|
||||||
|
Android, Android Auto, Google Messages, and Waze are trademarks of Google LLC. Spotify is
|
||||||
|
a trademark of Spotify AB. Gearslip is not affiliated with, endorsed by, or sponsored by
|
||||||
|
any of these companies. All other names belong to their owners.
|
||||||
|
|
||||||
|
## 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).
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
# Building apps for Gearslip
|
||||||
|
|
||||||
|
Gearslip renders four kinds of app on a car screen. This page specifies all four.
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
| [Messaging apps](#messaging-apps) | An app whose notifications 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`. Car App Library 1.8.0-beta01
|
||||||
|
and earlier, and 1.9.0-alpha01, accept any host holding it, so Gearslip passes the default
|
||||||
|
validator. From 1.8.0-rc01 and 1.9.0-alpha02 the library checks that permission only on
|
||||||
|
Android Automotive, so on a phone Gearslip passes only when your allowlist names it. Add this
|
||||||
|
entry:
|
||||||
|
|
||||||
|
```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 reports the same area for both, and it depends on the template:
|
||||||
|
|
||||||
|
| Template | What Gearslip covers |
|
||||||
|
| --- | --- |
|
||||||
|
| `NavigationTemplate` | A strip along the bottom for the trip estimate |
|
||||||
|
| `MapWithContentTemplate`, `MapTemplate`, and the place list and route preview templates | A content pane on the left |
|
||||||
|
|
||||||
|
The turn card and map buttons float over the visible area, as they do in Android Auto.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Messaging apps
|
||||||
|
|
||||||
|
Gearslip shows conversations from the notifications your app already posts, as Android Auto
|
||||||
|
does. Your app needs no car screen of its own.
|
||||||
|
|
||||||
|
Gearslip lists a messaging app when its car descriptor declares notifications:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- res/xml/automotive_app_desc.xml -->
|
||||||
|
<automotiveApp>
|
||||||
|
<uses name="notification" />
|
||||||
|
</automotiveApp>
|
||||||
|
```
|
||||||
|
|
||||||
|
Reference it from the same `com.google.android.gms.car.application` meta-data that a media app
|
||||||
|
uses. Opening your tile on the car screen shows your recent conversations.
|
||||||
|
|
||||||
|
### Posting messages
|
||||||
|
|
||||||
|
Post each conversation as a `MessagingStyle` notification. Gearslip reads the conversation
|
||||||
|
title, each message's sender and text, and whether the chat is a group, and shows the last eight
|
||||||
|
messages. Leave the sender empty on the phone owner's own messages; Gearslip treats a message
|
||||||
|
with no sender as one the driver sent.
|
||||||
|
|
||||||
|
Gearslip reads new messages aloud and lets the driver reply by voice or with a quick reply. For
|
||||||
|
that, attach two actions, either to the notification or to its `WearableExtender`:
|
||||||
|
|
||||||
|
- **Reply:** an action carrying a `RemoteInput` that allows free-form input. Gearslip fills it
|
||||||
|
with the driver's reply and fires the action's intent.
|
||||||
|
- **Mark as read:** an action with `SEMANTIC_ACTION_MARK_AS_READ`. Gearslip shows a Mark read
|
||||||
|
button when you provide it.
|
||||||
|
|
||||||
|
### Which notifications interrupt the driver
|
||||||
|
|
||||||
|
Gearslip pops up a card for a conversation, a notification with a reply action, or a
|
||||||
|
notification in the `msg`, `missed_call`, `alarm`, `reminder` or `event` category. A channel
|
||||||
|
below default importance never pops up. Every other notification waits quietly on the
|
||||||
|
dashboard.
|
||||||
|
|
||||||
|
Gearslip drops these altogether:
|
||||||
|
|
||||||
|
- ongoing notifications and group summaries;
|
||||||
|
- the `transport`, `progress`, `service`, `sys` and `navigation` categories; and
|
||||||
|
- media notifications, because the player already shows them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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": "Honda"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`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.
|
||||||
|
- [CONTRIBUTING.md](../CONTRIBUTING.md) - how to build Gearslip and report bugs.
|
||||||
|
- [Android for Cars App Library](https://developer.android.com/training/cars/apps) - the
|
||||||
|
reference for templated apps.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,14 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="432" height="432" viewBox="0 0 108 108">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="trail" x1="73.4" y1="46.0" x2="95.8" y2="44.5" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0%" stop-color="#8C1F1F" stop-opacity="0"/>
|
||||||
|
<stop offset="100%" stop-color="#8C1F1F" stop-opacity="0.7"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="108" height="108" fill="#0c0c0d"/>
|
||||||
|
<circle cx="54" cy="54" r="52" fill="#131415"/>
|
||||||
|
<path d="M 73.23,47.15 L 94.77,51.90 L 96.89,37.05 L 73.57,44.78 Z" fill="url(#trail)"/>
|
||||||
|
<path d="M 54.00,27.00 L 59.99,19.91 Q 61.02,18.69 62.50,19.30 L 72.52,23.45 Q 74.00,24.07 73.87,25.66 L 73.23,33.31 Q 73.09,34.91 73.70,36.39 L 80.39,52.52 Q 81.00,54.00 82.22,55.03 L 88.09,59.99 Q 89.31,61.02 88.70,62.50 L 84.55,72.52 Q 83.93,74.00 82.34,73.87 L 73.09,73.09 Q 73.09,73.09 73.09,73.09 L 73.09,73.09 Q 73.09,73.09 73.09,73.09 L 73.87,82.34 Q 74.00,83.93 72.52,84.55 L 62.50,88.70 Q 61.02,89.31 59.99,88.09 L 54.00,81.00 Q 54.00,81.00 54.00,81.00 L 54.00,81.00 Q 54.00,81.00 54.00,81.00 L 48.01,88.09 Q 46.98,89.31 45.50,88.70 L 35.48,84.55 Q 34.00,83.93 34.13,82.34 L 34.91,73.09 Q 34.91,73.09 34.91,73.09 L 34.91,73.09 Q 34.91,73.09 34.91,73.09 L 25.66,73.87 Q 24.07,74.00 23.45,72.52 L 19.30,62.50 Q 18.69,61.02 19.91,59.99 L 27.00,54.00 Q 27.00,54.00 27.00,54.00 L 27.00,54.00 Q 27.00,54.00 27.00,54.00 L 19.91,48.01 Q 18.69,46.98 19.30,45.50 L 23.45,35.48 Q 24.07,34.00 25.66,34.13 L 34.91,34.91 Q 34.91,34.91 34.91,34.91 L 34.91,34.91 Q 34.91,34.91 34.91,34.91 L 34.13,25.66 Q 34.00,24.07 35.48,23.45 L 45.50,19.30 Q 46.98,18.69 48.01,19.91 L 54.00,27.00 Q 54.00,27.00 54.00,27.00 L 54.00,27.00 Q 54.00,27.00 54.00,27.00 Z" fill="#8B9096"/>
|
||||||
|
<path d="M 92.78,34.42 L 99.94,37.20 Q 101.43,37.78 101.34,39.37 L 100.67,50.20 Q 100.58,51.80 99.03,52.19 L 91.58,54.08 Q 90.03,54.47 90.13,52.87 L 91.19,35.44 Q 91.29,33.84 92.78,34.42 Z" fill="#8C1F1F"/>
|
||||||
|
<circle cx="54" cy="54" r="12" fill="#0c0c0d"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,14 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="432" height="432" viewBox="0 0 108 108">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="trail" x1="73.4" y1="46.0" x2="95.8" y2="44.5" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0%" stop-color="#8C1F1F" stop-opacity="0"/>
|
||||||
|
<stop offset="100%" stop-color="#8C1F1F" stop-opacity="0.7"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="108" height="108" fill="#0c0c0d"/>
|
||||||
|
<circle cx="54" cy="54" r="52" fill="#131415"/>
|
||||||
|
<path d="M 73.23,47.15 L 94.77,51.90 L 96.89,37.05 L 73.57,44.78 Z" fill="url(#trail)"/>
|
||||||
|
<path d="M 54.00,27.00 L 61.02,18.69 L 74.00,24.07 L 73.09,34.91 L 81.00,54.00 L 89.31,61.02 L 83.93,74.00 L 73.09,73.09 L 73.09,73.09 L 74.00,83.93 L 61.02,89.31 L 54.00,81.00 L 54.00,81.00 L 46.98,89.31 L 34.00,83.93 L 34.91,73.09 L 34.91,73.09 L 24.07,74.00 L 18.69,61.02 L 27.00,54.00 L 27.00,54.00 L 18.69,46.98 L 24.07,34.00 L 34.91,34.91 L 34.91,34.91 L 34.00,24.07 L 46.98,18.69 L 54.00,27.00 Z" fill="#8B9096"/>
|
||||||
|
<path d="M 91.29,33.84 L 101.43,37.78 L 100.58,51.80 L 90.03,54.47 Z" fill="#8C1F1F"/>
|
||||||
|
<circle cx="54" cy="54" r="12" fill="#0c0c0d"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,176 @@
|
|||||||
|
import math
|
||||||
|
|
||||||
|
def pt(cx, cy, r, deg):
|
||||||
|
a = math.radians(deg - 90)
|
||||||
|
return (cx + r*math.cos(a), cy + r*math.sin(a))
|
||||||
|
|
||||||
|
def gear_points(cx, cy, teeth, outer_r, inner_r, tooth_frac=0.5):
|
||||||
|
slice_w = 360.0 / teeth
|
||||||
|
gap = slice_w * (1 - tooth_frac)
|
||||||
|
pts = []
|
||||||
|
for i in range(teeth):
|
||||||
|
start = i * slice_w
|
||||||
|
a1 = start + gap/2
|
||||||
|
a2 = a1 + slice_w*tooth_frac
|
||||||
|
pts.append(pt(cx, cy, inner_r, start))
|
||||||
|
pts.append(pt(cx, cy, outer_r, a1))
|
||||||
|
pts.append(pt(cx, cy, outer_r, a2))
|
||||||
|
pts.append(pt(cx, cy, inner_r, start + slice_w))
|
||||||
|
return pts
|
||||||
|
|
||||||
|
def path_from_pts(pts):
|
||||||
|
d = f"M {pts[0][0]:.2f},{pts[0][1]:.2f} "
|
||||||
|
for p in pts[1:]:
|
||||||
|
d += f"L {p[0]:.2f},{p[1]:.2f} "
|
||||||
|
return d + "Z"
|
||||||
|
|
||||||
|
def rotate(pts, ocx, ocy, deg):
|
||||||
|
a = math.radians(deg)
|
||||||
|
out = []
|
||||||
|
for x, y in pts:
|
||||||
|
x0, y0 = x-ocx, y-ocy
|
||||||
|
out.append((x0*math.cos(a)-y0*math.sin(a)+ocx, x0*math.sin(a)+y0*math.cos(a)+ocy))
|
||||||
|
return out
|
||||||
|
|
||||||
|
def scale_pts(pts, ocx, ocy, s):
|
||||||
|
return [((x-ocx)*s+ocx, (y-ocy)*s+ocy) for x, y in pts]
|
||||||
|
|
||||||
|
cx, cy = 54, 54
|
||||||
|
teeth = 8
|
||||||
|
outer_r = 36
|
||||||
|
inner_r = 27
|
||||||
|
|
||||||
|
all_pts = gear_points(cx, cy, teeth, outer_r, inner_r)
|
||||||
|
broken_idx = 1
|
||||||
|
body_pts = [p for i, p in enumerate(all_pts) if not (4*broken_idx <= i < 4*broken_idx+4)]
|
||||||
|
|
||||||
|
tooth_pts = all_pts[4*broken_idx:4*broken_idx+4]
|
||||||
|
tooth_cx = sum(p[0] for p in tooth_pts)/4
|
||||||
|
tooth_cy = sum(p[1] for p in tooth_pts)/4
|
||||||
|
|
||||||
|
dx, dy = 14, 2
|
||||||
|
final_pts = rotate([(x+dx, y+dy) for x, y in tooth_pts], tooth_cx+dx, tooth_cy+dy, 26)
|
||||||
|
final_cx, final_cy = tooth_cx+dx, tooth_cy+dy
|
||||||
|
|
||||||
|
trail_half_w_near = 1.2
|
||||||
|
trail_half_w_far = 7.5
|
||||||
|
perp = (-dy, dx)
|
||||||
|
plen = math.hypot(*perp)
|
||||||
|
perp = (perp[0]/plen, perp[1]/plen)
|
||||||
|
|
||||||
|
gap_angle = math.atan2(tooth_cy - cy, tooth_cx - cx)
|
||||||
|
extend_r = inner_r - 6
|
||||||
|
gap_x = cx + math.cos(gap_angle) * extend_r
|
||||||
|
gap_y = cy + math.sin(gap_angle) * extend_r
|
||||||
|
|
||||||
|
near1 = (gap_x + perp[0]*trail_half_w_near, gap_y + perp[1]*trail_half_w_near)
|
||||||
|
near2 = (gap_x - perp[0]*trail_half_w_near, gap_y - perp[1]*trail_half_w_near)
|
||||||
|
far1 = (final_cx + perp[0]*trail_half_w_far, final_cy + perp[1]*trail_half_w_far)
|
||||||
|
far2 = (final_cx - perp[0]*trail_half_w_far, final_cy - perp[1]*trail_half_w_far)
|
||||||
|
trail_pts = [near1, far1, far2, near2]
|
||||||
|
|
||||||
|
hub_r = 12
|
||||||
|
|
||||||
|
# --- scale everything down to fit the adaptive-icon safe zone (66dp diameter circle) ---
|
||||||
|
SCALE = 0.66
|
||||||
|
def S(pts):
|
||||||
|
return scale_pts(pts, cx, cy, SCALE)
|
||||||
|
|
||||||
|
body_s = S(body_pts)
|
||||||
|
final_s = S(final_pts)
|
||||||
|
trail_s = S(trail_pts)
|
||||||
|
hub_r_s = hub_r * SCALE
|
||||||
|
gap_x_s, gap_y_s = scale_pts([(gap_x, gap_y)], cx, cy, SCALE)[0]
|
||||||
|
final_cx_s, final_cy_s = scale_pts([(final_cx, final_cy)], cx, cy, SCALE)[0]
|
||||||
|
|
||||||
|
body_path = path_from_pts(body_s)
|
||||||
|
final_path = path_from_pts(final_s)
|
||||||
|
trail_path = path_from_pts(trail_s)
|
||||||
|
|
||||||
|
GREY = "#8B9096"
|
||||||
|
RED = "#8C1F1F"
|
||||||
|
BG = "#0c0c0d"
|
||||||
|
RING = "#131415"
|
||||||
|
|
||||||
|
# ===== 1. Background: full-bleed, unscaled =====
|
||||||
|
bg_xml = f'''<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:pathData="M0,0h108v108h-108z"
|
||||||
|
android:fillColor="{BG}"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M54,2 A52,52 0 1,1 53.99,2 Z"
|
||||||
|
android:fillColor="{RING}"/>
|
||||||
|
</vector>
|
||||||
|
'''
|
||||||
|
|
||||||
|
# ===== 2. Foreground: gear + broken piece + trail, scaled into the safe zone =====
|
||||||
|
# Trail uses a gradient (narrow near the gear, fading out) via an <aapt:attr> gradient,
|
||||||
|
# same visual as the mockup.
|
||||||
|
fg_xml = f'''<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path android:pathData="{trail_path}">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:type="linear"
|
||||||
|
android:startX="{gap_x_s:.2f}"
|
||||||
|
android:startY="{gap_y_s:.2f}"
|
||||||
|
android:endX="{final_cx_s:.2f}"
|
||||||
|
android:endY="{final_cy_s:.2f}">
|
||||||
|
<item android:offset="0" android:color="#00{RED[1:]}"/>
|
||||||
|
<item android:offset="1" android:color="{RED}"/>
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
android:pathData="{body_path}"
|
||||||
|
android:fillColor="{GREY}"/>
|
||||||
|
<path
|
||||||
|
android:pathData="{final_path}"
|
||||||
|
android:fillColor="{RED}"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M{cx},{cy-hub_r_s:.2f} A{hub_r_s:.2f},{hub_r_s:.2f} 0 1,1 {cx-0.01:.2f},{cy-hub_r_s:.2f} Z"
|
||||||
|
android:fillColor="{BG}"/>
|
||||||
|
</vector>
|
||||||
|
'''
|
||||||
|
|
||||||
|
# ===== 3. Monochrome: same silhouette, single alpha-bearing color for Material You
|
||||||
|
# theming. The system substitutes its own tint - the fillColor value here is
|
||||||
|
# irrelevant, only the shape/alpha matters. Trail kept at a lower alpha so the
|
||||||
|
# fade effect survives tinting too.
|
||||||
|
mono_xml = f'''<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:pathData="{trail_path}"
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:fillAlpha="0.35"/>
|
||||||
|
<path
|
||||||
|
android:pathData="{body_path}"
|
||||||
|
android:fillColor="#FF000000"/>
|
||||||
|
<path
|
||||||
|
android:pathData="{final_path}"
|
||||||
|
android:fillColor="#FF000000"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M{cx},{cy-hub_r_s:.2f} A{hub_r_s:.2f},{hub_r_s:.2f} 0 1,1 {cx-0.01:.2f},{cy-hub_r_s:.2f} Z"
|
||||||
|
android:fillColor="#00000000"/>
|
||||||
|
</vector>
|
||||||
|
'''
|
||||||
|
|
||||||
|
out = '/tmp/claude-1000/-home-seb-AABrowser/dadb0938-9338-4349-968f-4a89bc8de777/scratchpad/icon'
|
||||||
|
open(f'{out}/ic_launcher_background.xml', 'w').write(bg_xml)
|
||||||
|
open(f'{out}/ic_launcher_foreground.xml', 'w').write(fg_xml)
|
||||||
|
open(f'{out}/ic_launcher_monochrome.xml', 'w').write(mono_xml)
|
||||||
|
print("written")
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
# Build output is redirected to <root>/build/gearslip/ (see build.gradle.kts), already
|
||||||
|
# covered by the root .gitignore's /build - this is just a safety net in case that ever
|
||||||
|
# changes back.
|
||||||
|
build/
|
||||||
|
|
||||||
|
# User-supplied certificate material - never checked in (see CertProvider.kt).
|
||||||
|
phone.p12
|
||||||
|
phone.pass
|
||||||
|
*.crt
|
||||||
|
*.key
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
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 = 7
|
||||||
|
versionName = "0.1.10-alpha"
|
||||||
|
manifestPlaceholders["appLabel"] = "Gearslip"
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
// Its own package, so a dev build installs beside the release one instead of over it.
|
||||||
|
// Signed with the release key when it's here: both builds declare the car template
|
||||||
|
// permission, and Android refuses a second app declaring it under a different key.
|
||||||
|
debug {
|
||||||
|
applicationIdSuffix = ".dev"
|
||||||
|
signingConfigs.findByName("release")?.let { signingConfig = it }
|
||||||
|
manifestPlaceholders["appLabel"] = "Gearslip Dev"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
// Speech-to-text ships for 64-bit ARM only: every phone Gearslip runs on, and one copy
|
||||||
|
// of the native library instead of four.
|
||||||
|
ndk { abiFilters += "arm64-v8a" }
|
||||||
|
}
|
||||||
|
ndkVersion = "27.1.12297006"
|
||||||
|
externalNativeBuild {
|
||||||
|
cmake {
|
||||||
|
path = file("src/main/cpp/CMakeLists.txt")
|
||||||
|
version = "3.22.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
androidResources {
|
||||||
|
// Model weights barely compress, and stored plainly they stream straight out of the APK.
|
||||||
|
noCompress += "bin"
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
implementation(libs.androidx.media)
|
||||||
|
testImplementation(libs.junit)
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType<KotlinJvmCompile>().configureEach {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget.set(JvmTarget.JVM_21)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<application>
|
||||||
|
<!-- Debug builds only: renders the car UI to PNGs so it can be checked without a car. -->
|
||||||
|
<activity android:name="app.seb3thehacker.gearslip.CarPreviewActivity" android:exported="true" />
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package app.seb3thehacker.gearslip
|
||||||
|
|
||||||
|
import app.seb3thehacker.gearslip.audio.CarAudio
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debug-only stand-in for the head unit's media audio sink.
|
||||||
|
*
|
||||||
|
* The car end of the audio path can only be exercised in a car, but everything before it - the
|
||||||
|
* setting, the consent dialog, the capture service, and whether the media app allows itself to be
|
||||||
|
* heard at all - is phone-side and can be tested at a desk. This sink takes the place of
|
||||||
|
* [AudioLink] so that path runs to completion, and reports what arrived.
|
||||||
|
*
|
||||||
|
* adb shell am start -n app.seb3thehacker.gearslip/.CarPreviewActivity \
|
||||||
|
* --ei w 800 --ei h 480 --ez audio true --ei snaps 10
|
||||||
|
*
|
||||||
|
* A healthy run logs a rising byte count and a peak well above zero. A byte count that rises with
|
||||||
|
* a peak of zero means the app refused to be captured.
|
||||||
|
*/
|
||||||
|
class BenchAudioSink(override val sampleRate: Int = 48_000, override val channels: Int = 2) : CarAudio.Link {
|
||||||
|
|
||||||
|
private var chunks = 0
|
||||||
|
private var bytes = 0L
|
||||||
|
private var peak = 0
|
||||||
|
private var reportedAt = 0L
|
||||||
|
|
||||||
|
override fun start() {
|
||||||
|
chunks = 0
|
||||||
|
bytes = 0
|
||||||
|
peak = 0
|
||||||
|
reportedAt = System.currentTimeMillis()
|
||||||
|
GearslipLog.i("bench sink: started - pretending to be a ${sampleRate}Hz x$channels car sink")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stop() {
|
||||||
|
GearslipLog.i("bench sink: stopped after $chunks chunks / $bytes bytes")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun write(pcm: ByteArray, size: Int) {
|
||||||
|
chunks++
|
||||||
|
bytes += size
|
||||||
|
for (i in 0 until size - 1 step 2) {
|
||||||
|
val sample = (pcm[i].toInt() and 0xFF) or (pcm[i + 1].toInt() shl 8)
|
||||||
|
peak = maxOf(peak, abs(sample.toShort().toInt()))
|
||||||
|
}
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (now - reportedAt < REPORT_MS) return
|
||||||
|
reportedAt = now
|
||||||
|
val seconds = bytes / (sampleRate.toDouble() * channels * 2)
|
||||||
|
GearslipLog.i(
|
||||||
|
"bench sink: %d chunks / %d bytes (%.1fs of audio), peak %.3f%s".format(
|
||||||
|
chunks, bytes, seconds, peak / 32768f,
|
||||||
|
if (peak == 0) " - SILENT, the app is not letting itself be captured" else "",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
peak = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val REPORT_MS = 2_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package app.seb3thehacker.gearslip
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.PixelFormat
|
||||||
|
import android.hardware.display.DisplayManager
|
||||||
|
import android.media.ImageReader
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import app.seb3thehacker.gearslip.audio.AudioCaptureService
|
||||||
|
import app.seb3thehacker.gearslip.audio.CarAudio
|
||||||
|
import app.seb3thehacker.gearslip.car.CarEnvironment
|
||||||
|
import app.seb3thehacker.gearslip.car.CarSettings
|
||||||
|
import app.seb3thehacker.gearslip.car.CarUi
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debug-only harness: runs the real car UI on a virtual display (the same path a head unit
|
||||||
|
* gets), taps where told, and saves what it rendered as PNGs in the external files dir.
|
||||||
|
*
|
||||||
|
* adb shell am start -n app.seb3thehacker.gearslip/.CarPreviewActivity \
|
||||||
|
* --ei w 800 --ei h 480 --ei dpi 160 --es taps "400,440;90,440"
|
||||||
|
*
|
||||||
|
* Writes preview-0.png (before any tap), then one more per tap.
|
||||||
|
*/
|
||||||
|
class CarPreviewActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
private val main = Handler(Looper.getMainLooper())
|
||||||
|
private var projector: ScreenProjector? = null
|
||||||
|
private var reader: ImageReader? = null
|
||||||
|
@Volatile private var latest: Bitmap? = null
|
||||||
|
|
||||||
|
private val audioPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||||
|
if (granted) askAudioCapture() else CarAudio.denied()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val audioConsent = registerForActivityResult(
|
||||||
|
androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult(),
|
||||||
|
) { result ->
|
||||||
|
val data = result.data
|
||||||
|
if (result.resultCode == RESULT_OK && data != null) {
|
||||||
|
AudioCaptureService.start(this, result.resultCode, data)
|
||||||
|
} else {
|
||||||
|
GearslipLog.w("preview: audio capture consent declined")
|
||||||
|
CarAudio.denied()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun askAudioCapture() {
|
||||||
|
val manager = getSystemService(android.media.projection.MediaProjectionManager::class.java)
|
||||||
|
runCatching { audioConsent.launch(manager.createScreenCaptureIntent()) }
|
||||||
|
.onFailure { GearslipLog.e("preview: could not ask for audio capture", it); CarAudio.denied() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
GearslipLog.init(this)
|
||||||
|
CarSettings.init(this)
|
||||||
|
CarEnvironment.start(this)
|
||||||
|
|
||||||
|
val w = intent.getIntExtra("w", 800)
|
||||||
|
val h = intent.getIntExtra("h", 480)
|
||||||
|
val dpi = intent.getIntExtra("dpi", 160)
|
||||||
|
val taps = intent.getStringExtra("taps").orEmpty().split(";").filter { it.isNotBlank() }
|
||||||
|
.map { it.split(",").let { p -> p[0].toFloat() to p[1].toFloat() } }
|
||||||
|
|
||||||
|
val imageReader = ImageReader.newInstance(w, h, PixelFormat.RGBA_8888, 3)
|
||||||
|
reader = imageReader
|
||||||
|
imageReader.setOnImageAvailableListener({ r ->
|
||||||
|
r.acquireLatestImage()?.use { image ->
|
||||||
|
val plane = image.planes[0]
|
||||||
|
val padded = plane.rowStride / plane.pixelStride
|
||||||
|
val bmp = Bitmap.createBitmap(padded, h, Bitmap.Config.ARGB_8888)
|
||||||
|
bmp.copyPixelsFromBuffer(plane.buffer)
|
||||||
|
latest = Bitmap.createBitmap(bmp, 0, 0, w, h)
|
||||||
|
}
|
||||||
|
}, main)
|
||||||
|
|
||||||
|
val p = ScreenProjector(this).also { projector = it }
|
||||||
|
p.start(imageReader.surface, w, h, dpi) { CarUi() }
|
||||||
|
|
||||||
|
// Stand in for the car's media sink, so the phone half of the audio path runs for real.
|
||||||
|
if (intent.getBooleanExtra("audio", false)) {
|
||||||
|
CarAudio.onConsentRequested = {
|
||||||
|
runOnUiThread {
|
||||||
|
if (checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) ==
|
||||||
|
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
askAudioCapture()
|
||||||
|
} else {
|
||||||
|
audioPermission.launch(android.Manifest.permission.RECORD_AUDIO)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CarAudio.attach(BenchAudioSink())
|
||||||
|
}
|
||||||
|
|
||||||
|
GearslipLog.i("preview: displayId=${p.displayId}")
|
||||||
|
if (intent.getBooleanExtra("hide", false)) p.setOverlayVisible(false)
|
||||||
|
|
||||||
|
intent.getStringExtra("launch")?.let { pkg ->
|
||||||
|
main.postDelayed({
|
||||||
|
packageManager.getLaunchIntentForPackage(pkg)?.let { p.launchApp(it) }
|
||||||
|
?: GearslipLog.e("preview: no launch intent for $pkg")
|
||||||
|
}, 1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
var step = 0
|
||||||
|
fun snap() {
|
||||||
|
val bmp = latest ?: return GearslipLog.e("preview: no frame yet for step $step")
|
||||||
|
val out = File(getExternalFilesDir(null), "preview-$step.png")
|
||||||
|
FileOutputStream(out).use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||||
|
GearslipLog.i("preview: wrote ${out.name}")
|
||||||
|
step++
|
||||||
|
}
|
||||||
|
// A two-finger pinch: "cx,cy,startGap,endGap" in projected pixels, after "pinchAfter" ms.
|
||||||
|
intent.getStringExtra("pinch")?.let { spec ->
|
||||||
|
val (cx, cy, gap0, gap1) = spec.split(",").map { it.toFloat() }
|
||||||
|
val pair = { gap: Float -> listOf(TouchPoint(3, cx - gap / 2, cy), TouchPoint(7, cx + gap / 2, cy)) }
|
||||||
|
val after = intent.getIntExtra("pinchAfter", 9000).toLong()
|
||||||
|
main.postDelayed({
|
||||||
|
snap()
|
||||||
|
p.dispatchTouch(MotionEvent.ACTION_DOWN, 0, listOf(pair(gap0)[0]))
|
||||||
|
p.dispatchTouch(MotionEvent.ACTION_POINTER_DOWN, 1, pair(gap0))
|
||||||
|
val steps = 14
|
||||||
|
for (i in 1..steps) main.postDelayed({
|
||||||
|
p.dispatchTouch(MotionEvent.ACTION_MOVE, 0, pair(gap0 + (gap1 - gap0) * i / steps))
|
||||||
|
}, 40L * i)
|
||||||
|
main.postDelayed({
|
||||||
|
p.dispatchTouch(MotionEvent.ACTION_POINTER_UP, 1, pair(gap1))
|
||||||
|
p.dispatchTouch(MotionEvent.ACTION_UP, 0, listOf(pair(gap1)[0]))
|
||||||
|
}, 40L * (steps + 1))
|
||||||
|
main.postDelayed({ snap() }, 40L * (steps + 1) + 2000)
|
||||||
|
}, after)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Free-running mode: keep snapshotting on a timer so the display can be driven from
|
||||||
|
// outside (adb `am start --display` / `input -d`) while the harness records it.
|
||||||
|
val snaps = intent.getIntExtra("snaps", 0)
|
||||||
|
if (snaps > 0) {
|
||||||
|
val every = intent.getIntExtra("every", 3000).toLong()
|
||||||
|
repeat(snaps) { i -> main.postDelayed({ snap() }, 2000L + every * i) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
main.postDelayed({
|
||||||
|
snap()
|
||||||
|
taps.forEachIndexed { i, (x, y) ->
|
||||||
|
main.postDelayed({
|
||||||
|
val point = listOf(TouchPoint(0, x, y))
|
||||||
|
p.dispatchTouch(MotionEvent.ACTION_DOWN, 0, point)
|
||||||
|
main.postDelayed({ p.dispatchTouch(MotionEvent.ACTION_UP, 0, point) }, 80)
|
||||||
|
}, 2500L * (i + 1))
|
||||||
|
main.postDelayed({ snap() }, 2500L * (i + 1) + 1500)
|
||||||
|
}
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
projector?.stop()
|
||||||
|
reader?.close()
|
||||||
|
CarAudio.onConsentRequested = null
|
||||||
|
CarAudio.release()
|
||||||
|
CarEnvironment.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Debug-only override of the main icon background (src/debug/res wins over src/main/res),
|
||||||
|
so a debug install is never mistaken for a release one on the home screen. -->
|
||||||
|
<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="#f5c518"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M54,2 A52,52 0 1,1 53.99,2 Z"
|
||||||
|
android:fillColor="#e6b400"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Debug-only override of the main icon foreground, to go with the yellow debug background:
|
||||||
|
a charcoal gear rather than light grey, and a yellow centre hole to match the background
|
||||||
|
instead of the release icon's near-black. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path android:pathData="M 66.69,49.48 L 80.91,52.61 L 82.31,42.81 L 66.92,47.91 Z">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:type="linear"
|
||||||
|
android:startX="66.80"
|
||||||
|
android:startY="48.70"
|
||||||
|
android:endX="81.61"
|
||||||
|
android:endY="47.71">
|
||||||
|
<item android:offset="0" android:color="#00E5484D"/>
|
||||||
|
<item android:offset="1" android:color="#E5484D"/>
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
android:pathData="M 54.00,36.18 L 58.64,30.70 L 67.20,34.24 L 66.60,41.40 L 71.82,54.00 L 77.30,58.64 L 73.76,67.20 L 66.60,66.60 L 66.60,66.60 L 67.20,73.76 L 58.64,77.30 L 54.00,71.82 L 54.00,71.82 L 49.36,77.30 L 40.80,73.76 L 41.40,66.60 L 41.40,66.60 L 34.24,67.20 L 30.70,58.64 L 36.18,54.00 L 36.18,54.00 L 30.70,49.36 L 34.24,40.80 L 41.40,41.40 L 41.40,41.40 L 40.80,34.24 L 49.36,30.70 L 54.00,36.18 Z"
|
||||||
|
android:fillColor="#2E3033"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M 78.61,40.70 L 85.31,43.29 L 84.74,52.55 L 77.78,54.31 Z"
|
||||||
|
android:fillColor="#E5484D"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M54,46.08 A7.92,7.92 0 1,1 53.99,46.08 Z"
|
||||||
|
android:fillColor="#e6b400"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
<?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.FOREGROUND_SERVICE_MICROPHONE" />
|
||||||
|
<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>
|
||||||
|
<!-- Speech: without these, Android hides the recognizer and the voice from us and
|
||||||
|
SpeechRecognizer reports none installed even when one is. -->
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.speech.RecognitionService" />
|
||||||
|
</intent>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.TTS_SERVICE" />
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher"
|
||||||
|
android:label="${appLabel}"
|
||||||
|
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>
|
||||||
|
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Answers the car's USB connection on GearslipActivity's behalf. Kept apart from the
|
||||||
|
activity so Settings can switch off just this (AppSettings.setGearslipEnabled) and leave
|
||||||
|
the head unit to Android Auto, without disabling the screen the switch is on.
|
||||||
|
-->
|
||||||
|
<activity-alias
|
||||||
|
android:name=".GearslipUsbAccessory"
|
||||||
|
android:targetActivity=".GearslipActivity"
|
||||||
|
android:exported="true">
|
||||||
|
|
||||||
|
<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-alias>
|
||||||
|
|
||||||
|
<!-- Dev builds' test message: its Reply and Mark as read actions land here. -->
|
||||||
|
<receiver
|
||||||
|
android:name=".notify.TestMessageReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
|
<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. It also claims
|
||||||
|
the microphone, so voice replies can hear the driver with the phone locked.
|
||||||
|
-->
|
||||||
|
<service
|
||||||
|
android:name=".audio.AudioCaptureService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="mediaProjection|microphone" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
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>
|
||||||
Binary file not shown.
@@ -0,0 +1,36 @@
|
|||||||
|
# Speech-to-text for voice replies and the assistant: whisper.cpp, pinned to one commit and
|
||||||
|
# fetched at build time, so nothing outside this folder (cloned-repos included) is needed.
|
||||||
|
cmake_minimum_required(VERSION 3.22)
|
||||||
|
project(gearslip_speech CXX C)
|
||||||
|
|
||||||
|
set(CMAKE_CXX_STANDARD 17)
|
||||||
|
|
||||||
|
# Optimised in debug builds too. Unoptimised, Whisper runs several times slower, and a dev build
|
||||||
|
# would make voice look far worse than what ships.
|
||||||
|
foreach(lang C CXX)
|
||||||
|
set(CMAKE_${lang}_FLAGS_DEBUG "-O3 -DNDEBUG")
|
||||||
|
endforeach()
|
||||||
|
|
||||||
|
include(FetchContent)
|
||||||
|
FetchContent_Declare(
|
||||||
|
whisper
|
||||||
|
URL https://github.com/ggml-org/whisper.cpp/archive/6e4ab854f67f743900934a703d5603419384c961.tar.gz
|
||||||
|
URL_HASH SHA256=55b585aec5e1bc03e2a5cea61da97c6406fa833996cf7daeed704972273cc039
|
||||||
|
)
|
||||||
|
|
||||||
|
set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE)
|
||||||
|
set(WHISPER_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
|
||||||
|
set(WHISPER_BUILD_TESTS OFF CACHE BOOL "" FORCE)
|
||||||
|
set(WHISPER_BUILD_SERVER OFF CACHE BOOL "" FORCE)
|
||||||
|
set(GGML_OPENMP OFF CACHE BOOL "" FORCE)
|
||||||
|
set(GGML_NATIVE OFF CACHE BOOL "" FORCE)
|
||||||
|
# ARMv8.2 with half floats and dot products: every phone that runs Android 12 or later in
|
||||||
|
# practice, and what makes Whisper fast enough to answer in under a second.
|
||||||
|
set(GGML_CPU_ARM_ARCH "armv8.2-a+dotprod+fp16" CACHE STRING "" FORCE)
|
||||||
|
FetchContent_MakeAvailable(whisper)
|
||||||
|
|
||||||
|
add_library(gearslip_speech SHARED speech_jni.cpp)
|
||||||
|
target_compile_options(gearslip_speech PRIVATE -O3)
|
||||||
|
find_library(LOG_LIB log)
|
||||||
|
target_link_libraries(gearslip_speech whisper android ${LOG_LIB})
|
||||||
|
target_link_options(gearslip_speech PRIVATE -Wl,--gc-sections -Wl,-z,max-page-size=16384)
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// The bridge between Kotlin's SpeechEngine and whisper.cpp: load a model, turn one recorded
|
||||||
|
// phrase into text, free the model. Recording and deciding when a phrase ends stay in Kotlin.
|
||||||
|
|
||||||
|
#include <jni.h>
|
||||||
|
#include <android/asset_manager.h>
|
||||||
|
#include <android/asset_manager_jni.h>
|
||||||
|
#include <android/log.h>
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <string>
|
||||||
|
#include "whisper.h"
|
||||||
|
|
||||||
|
#define TAG "GearslipSpeech"
|
||||||
|
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
|
||||||
|
|
||||||
|
static whisper_context_params context_params() {
|
||||||
|
whisper_context_params p = whisper_context_default_params();
|
||||||
|
p.use_gpu = false;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Streams a model straight out of the APK, so the bundled one is never copied to storage.
|
||||||
|
static size_t asset_read(void *ctx, void *output, size_t size) {
|
||||||
|
int n = AAsset_read(static_cast<AAsset *>(ctx), output, size);
|
||||||
|
return n > 0 ? static_cast<size_t>(n) : 0;
|
||||||
|
}
|
||||||
|
static bool asset_eof(void *ctx) { return AAsset_getRemainingLength64(static_cast<AAsset *>(ctx)) <= 0; }
|
||||||
|
static void asset_close(void *ctx) { AAsset_close(static_cast<AAsset *>(ctx)); }
|
||||||
|
|
||||||
|
extern "C" JNIEXPORT jlong JNICALL
|
||||||
|
Java_app_seb3thehacker_gearslip_speech_SpeechEngine_nativeInitAsset(JNIEnv *env, jobject, jobject assets, jstring jpath) {
|
||||||
|
AAssetManager *manager = AAssetManager_fromJava(env, assets);
|
||||||
|
const char *path = env->GetStringUTFChars(jpath, nullptr);
|
||||||
|
AAsset *asset = AAssetManager_open(manager, path, AASSET_MODE_STREAMING);
|
||||||
|
env->ReleaseStringUTFChars(jpath, path);
|
||||||
|
if (!asset) return 0;
|
||||||
|
whisper_model_loader loader = {asset, asset_read, asset_eof, asset_close};
|
||||||
|
return reinterpret_cast<jlong>(whisper_init_with_params(&loader, context_params()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bytes, not a jstring: NewStringUTF wants modified UTF-8 and aborts on some of what Whisper can
|
||||||
|
// write (four-byte characters), so Kotlin decodes the plain UTF-8 instead.
|
||||||
|
extern "C" JNIEXPORT jbyteArray JNICALL
|
||||||
|
Java_app_seb3thehacker_gearslip_speech_SpeechEngine_nativeTranscribe(
|
||||||
|
JNIEnv *env, jobject, jlong handle, jfloatArray jsamples, jstring jlang, jint threads) {
|
||||||
|
auto *ctx = reinterpret_cast<whisper_context *>(handle);
|
||||||
|
if (!ctx) return env->NewByteArray(0);
|
||||||
|
jsize n = env->GetArrayLength(jsamples);
|
||||||
|
jfloat *samples = env->GetFloatArrayElements(jsamples, nullptr);
|
||||||
|
const char *lang = env->GetStringUTFChars(jlang, nullptr);
|
||||||
|
|
||||||
|
whisper_full_params p = whisper_full_default_params(WHISPER_SAMPLING_GREEDY);
|
||||||
|
p.n_threads = threads;
|
||||||
|
p.language = lang;
|
||||||
|
p.translate = false;
|
||||||
|
p.no_context = true;
|
||||||
|
p.no_timestamps = true;
|
||||||
|
p.single_segment = true;
|
||||||
|
p.print_progress = false;
|
||||||
|
p.print_realtime = false;
|
||||||
|
p.print_special = false;
|
||||||
|
p.print_timestamps = false;
|
||||||
|
p.suppress_blank = true;
|
||||||
|
// The ACFT models are trained for a context cut to the audio's own length (1500 frames is
|
||||||
|
// 30 s, so 50 per second), which is what makes a three-second phrase fast. A little extra
|
||||||
|
// keeps the last word from being clipped.
|
||||||
|
p.audio_ctx = std::min(1500, static_cast<int>(std::ceil(n / 320.0)) + 32);
|
||||||
|
|
||||||
|
int rc = whisper_full(ctx, p, samples, n);
|
||||||
|
env->ReleaseStringUTFChars(jlang, lang);
|
||||||
|
env->ReleaseFloatArrayElements(jsamples, samples, JNI_ABORT);
|
||||||
|
if (rc != 0) {
|
||||||
|
LOGI("whisper_full failed: %d", rc);
|
||||||
|
return env->NewByteArray(0);
|
||||||
|
}
|
||||||
|
std::string text;
|
||||||
|
for (int i = 0; i < whisper_full_n_segments(ctx); i++) text += whisper_full_get_segment_text(ctx, i);
|
||||||
|
jbyteArray out = env->NewByteArray(static_cast<jsize>(text.size()));
|
||||||
|
env->SetByteArrayRegion(out, 0, static_cast<jsize>(text.size()), reinterpret_cast<const jbyte *>(text.data()));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" JNIEXPORT void JNICALL
|
||||||
|
Java_app_seb3thehacker_gearslip_speech_SpeechEngine_nativeFree(JNIEnv *, jobject, jlong handle) {
|
||||||
|
if (handle) whisper_free(reinterpret_cast<whisper_context *>(handle));
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package app.seb3thehacker.gearslip
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
|
||||||
|
/** 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_SEEN_PERMISSIONS_SETUP = "seen_permissions_setup"
|
||||||
|
private const val KEY_GEARSLIP_ENABLED = "gearslip_enabled"
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether Gearslip is allowed to claim the USB accessory connection. Off leaves the app
|
||||||
|
* installed but disables the GearslipUsbAccessory alias that answers
|
||||||
|
* USB_ACCESSORY_ATTACHED, so a real Android Auto head unit (or Google's own app) gets the
|
||||||
|
* connection instead - the two fight over it otherwise, since only one app can win the
|
||||||
|
* accessory handoff. The alias is separate from GearslipActivity itself - the activity that
|
||||||
|
* shows this very switch - so turning it off can't disable the screen you're looking at.
|
||||||
|
* On by default.
|
||||||
|
*/
|
||||||
|
fun gearslipEnabled(context: Context): Boolean =
|
||||||
|
prefs(context).getBoolean(KEY_GEARSLIP_ENABLED, true)
|
||||||
|
|
||||||
|
fun setGearslipEnabled(context: Context, value: Boolean) {
|
||||||
|
prefs(context).edit().putBoolean(KEY_GEARSLIP_ENABLED, value).apply()
|
||||||
|
applyGearslipEnabled(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes the USB alias match the saved switch. Called at every launch as well, so a switch
|
||||||
|
* saved without the alias following it (an older build crashed right there) heals itself.
|
||||||
|
* Only the alias changes: the driver's "always open with Gearslip" choice for the car is kept.
|
||||||
|
*/
|
||||||
|
fun applyGearslipEnabled(context: Context) {
|
||||||
|
val value = gearslipEnabled(context)
|
||||||
|
val appContext = context.applicationContext
|
||||||
|
// The class name comes from the code's namespace, not the package: a debug build installs
|
||||||
|
// as ".dev", but the alias is still app.seb3thehacker.gearslip.GearslipUsbAccessory.
|
||||||
|
val component = ComponentName(appContext.packageName, "${GearslipActivity::class.java.`package`!!.name}.GearslipUsbAccessory")
|
||||||
|
val state = if (value) {
|
||||||
|
PackageManager.COMPONENT_ENABLED_STATE_DEFAULT
|
||||||
|
} else {
|
||||||
|
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
|
||||||
|
}
|
||||||
|
val pm = appContext.packageManager
|
||||||
|
if (pm.getComponentEnabledSetting(component) == state) return
|
||||||
|
runCatching { pm.setComponentEnabledSetting(component, state, PackageManager.DONT_KILL_APP) }
|
||||||
|
.onFailure { GearslipLog.e("could not switch the USB connection ${if (value) "on" else "off"}", it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package app.seb3thehacker.gearslip
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the publicly available head unit certificate and key from the opencardev/aasdk
|
||||||
|
* repository on GitHub. The app ships no certificates; it downloads them only when the
|
||||||
|
* user chooses to, the same way a browser would.
|
||||||
|
*/
|
||||||
|
object CertDownloader {
|
||||||
|
|
||||||
|
private val log = GearslipLog.tagged("DOWNL")
|
||||||
|
|
||||||
|
private const val BASE = "https://raw.githubusercontent.com/opencardev/aasdk/main/cert"
|
||||||
|
private const val CRT = "$BASE/headunit.crt"
|
||||||
|
private const val KEY = "$BASE/headunit.key"
|
||||||
|
private const val CONNECT_TIMEOUT_MS = 10_000
|
||||||
|
private const val READ_TIMEOUT_MS = 15_000
|
||||||
|
|
||||||
|
class Downloaded(val certificatePem: String, val privateKeyPem: String)
|
||||||
|
|
||||||
|
/** Fetches both files; throws with a readable message on any failure. */
|
||||||
|
fun fetch(): Downloaded {
|
||||||
|
log.i("fetching certificate from $BASE")
|
||||||
|
val crt = fetch(CRT, "certificate")
|
||||||
|
val key = fetch(KEY, "private key")
|
||||||
|
log.i("downloaded certificate (${crt.length} bytes) and key (${key.length} bytes)")
|
||||||
|
return Downloaded(crt, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetch(url: String, label: String): String {
|
||||||
|
val body = try {
|
||||||
|
val conn = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||||
|
connectTimeout = CONNECT_TIMEOUT_MS
|
||||||
|
readTimeout = READ_TIMEOUT_MS
|
||||||
|
}
|
||||||
|
conn.inputStream.use { input ->
|
||||||
|
val out = ByteArrayOutputStream()
|
||||||
|
val buf = ByteArray(4096)
|
||||||
|
var n: Int
|
||||||
|
while (input.read(buf).also { n = it } != -1) out.write(buf, 0, n)
|
||||||
|
out.toString(Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw RuntimeException("could not download the $label: ${e.message}", e)
|
||||||
|
}
|
||||||
|
if (body.isBlank()) throw RuntimeException("the $label file is empty")
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
package app.seb3thehacker.gearslip
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.File
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supplies the certificate the phone presents to the head unit.
|
||||||
|
*
|
||||||
|
* FOSS posture: this code ships with NO certificate. Whoever wants to test the trusted-cert
|
||||||
|
* path supplies their own. Sources, in priority order:
|
||||||
|
*
|
||||||
|
* 1. **Imported** in the app (Settings > Certificate): a PKCS#12 file plus its password,
|
||||||
|
* copied into the app's private storage.
|
||||||
|
* 2. **Downloaded** from the public opencardev/aasdk repository. Fetched only when the
|
||||||
|
* user asks; the app never ships or hosts the certificate.
|
||||||
|
* 3. **Staged over adb** into the app's external files directory, the way the spike has
|
||||||
|
* always done it. Still honoured, so existing setups keep working untouched:
|
||||||
|
*
|
||||||
|
* openssl pkcs12 -export -in headunit.crt -inkey headunit.key \
|
||||||
|
* -name phone -passout pass:aaspike -out phone.p12
|
||||||
|
* adb push phone.p12 /sdcard/Android/data/app.seb3thehacker.gearslip/files/phone.p12
|
||||||
|
*
|
||||||
|
* Default password is "aaspike" (kept as-is so an already-staged phone.p12 doesn't need
|
||||||
|
* regenerating); a `phone.pass` file next to it overrides it.
|
||||||
|
* 4. A freshly generated self-signed cert, which a real head unit rejects (see
|
||||||
|
* SPIKE_FINDINGS.md).
|
||||||
|
*/
|
||||||
|
object CertProvider {
|
||||||
|
private val log = GearslipLog.tagged("CERT")
|
||||||
|
|
||||||
|
private const val P12_NAME = "phone.p12"
|
||||||
|
private const val PASS_NAME = "phone.pass"
|
||||||
|
private const val DEFAULT_PASSWORD = "aaspike"
|
||||||
|
private const val IMPORT_DIR = "identity"
|
||||||
|
|
||||||
|
enum class Kind { IMPORTED, DOWNLOADED, ADB_STAGED, SELF_SIGNED }
|
||||||
|
|
||||||
|
class Identity(
|
||||||
|
val keyStore: KeyStore,
|
||||||
|
val certificate: X509Certificate,
|
||||||
|
val password: CharArray,
|
||||||
|
val source: String,
|
||||||
|
val kind: Kind = Kind.SELF_SIGNED,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Loaded fresh each call, so a newly imported or pushed cert takes effect on the next connect. */
|
||||||
|
fun load(context: Context): Identity = loadFromFiles(context) ?: run {
|
||||||
|
val generated = SelfSignedCert.generate()
|
||||||
|
Identity(
|
||||||
|
generated.keyStore,
|
||||||
|
generated.certificate,
|
||||||
|
SelfSignedCert.PASSWORD,
|
||||||
|
"self-signed (generated) - a real head unit rejects this",
|
||||||
|
Kind.SELF_SIGNED,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The active identity without the cost of generating a self-signed key: null means "nothing
|
||||||
|
* supplied, a self-signed one would be generated". For the UI, which only wants to describe it.
|
||||||
|
*/
|
||||||
|
fun loadSupplied(context: Context): Identity? = loadFromFiles(context)
|
||||||
|
|
||||||
|
private fun loadFromFiles(context: Context): Identity? {
|
||||||
|
val imported = importedFile(context, P12_NAME)
|
||||||
|
if (imported.isFile) {
|
||||||
|
runCatching {
|
||||||
|
return loadP12(imported, importedPassword(context), "imported certificate", Kind.IMPORTED)
|
||||||
|
}.onFailure { log.e("could not load the imported certificate - trying the next source", it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val downloaded = downloadedFile(context, P12_NAME)
|
||||||
|
if (downloaded.isFile) {
|
||||||
|
runCatching {
|
||||||
|
return loadP12(downloaded, DEFAULT_PASSWORD, "downloaded certificate", Kind.DOWNLOADED)
|
||||||
|
}.onFailure { log.e("could not load the downloaded certificate - trying the next source", it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val dir = context.getExternalFilesDir(null)
|
||||||
|
val staged = dir?.let { File(it, P12_NAME) }
|
||||||
|
if (dir != null && staged != null && staged.isFile) {
|
||||||
|
runCatching {
|
||||||
|
val passFile = File(dir, PASS_NAME)
|
||||||
|
val password = if (passFile.isFile) {
|
||||||
|
passFile.readText().lineSequence().firstOrNull().orEmpty().trim()
|
||||||
|
} else {
|
||||||
|
DEFAULT_PASSWORD
|
||||||
|
}
|
||||||
|
return loadP12(staged, password, "$P12_NAME staged over adb", Kind.ADB_STAGED)
|
||||||
|
}.onFailure { log.e("could not load $P12_NAME - falling back to self-signed", it) }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates [uri] as a PKCS#12 identity with [password] and, only if it is good, makes it
|
||||||
|
* the active certificate. A wrong password or a file with no private key throws and
|
||||||
|
* leaves whatever was active before untouched.
|
||||||
|
*/
|
||||||
|
fun importFrom(context: Context, uri: Uri, password: String): Identity {
|
||||||
|
val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||||
|
?: throw IllegalArgumentException("could not open the selected file")
|
||||||
|
|
||||||
|
val staging = File(context.cacheDir, "import-$P12_NAME")
|
||||||
|
try {
|
||||||
|
staging.writeBytes(bytes)
|
||||||
|
val identity = loadP12(staging, password, "imported certificate", Kind.IMPORTED)
|
||||||
|
|
||||||
|
val target = importedFile(context, P12_NAME)
|
||||||
|
target.parentFile?.mkdirs()
|
||||||
|
staging.copyTo(target, overwrite = true)
|
||||||
|
importedFile(context, PASS_NAME).writeText(password)
|
||||||
|
log.i("imported a certificate: ${identity.certificate.subjectX500Principal}")
|
||||||
|
return identity
|
||||||
|
} finally {
|
||||||
|
staging.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasImported(context: Context): Boolean = importedFile(context, P12_NAME).isFile
|
||||||
|
|
||||||
|
/** Whether a downloaded certificate is stored (may or may not also be imported). */
|
||||||
|
fun hasDownloaded(context: Context): Boolean = downloadedFile(context, P12_NAME).isFile
|
||||||
|
|
||||||
|
/** Drops the imported identity; the next source in line (downloaded, adb-staged, then self-signed) takes over. */
|
||||||
|
fun removeImported(context: Context) {
|
||||||
|
importedFile(context, P12_NAME).delete()
|
||||||
|
importedFile(context, PASS_NAME).delete()
|
||||||
|
log.i("removed the imported certificate")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops the downloaded identity. */
|
||||||
|
fun removeDownloaded(context: Context) {
|
||||||
|
downloadedFile(context, P12_NAME).delete()
|
||||||
|
downloadedFile(context, PASS_NAME).delete()
|
||||||
|
log.i("removed the downloaded certificate")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether any certificate is available (imported, downloaded, or adb-staged). */
|
||||||
|
fun hasAnyCert(context: Context): Boolean =
|
||||||
|
hasImported(context) || hasDownloaded(context) || hasAdbStaged(context)
|
||||||
|
|
||||||
|
private fun hasAdbStaged(context: Context): Boolean =
|
||||||
|
context.getExternalFilesDir(null)?.let { File(it, P12_NAME).isFile } == true
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates raw PKCS#12 bytes with [password] and, if good, writes them as the imported
|
||||||
|
* identity. Used when the app assembles a certificate in memory (e.g. from a download)
|
||||||
|
* rather than importing from a file picker.
|
||||||
|
*/
|
||||||
|
fun importFromBytes(context: Context, p12Bytes: ByteArray, password: String): Identity {
|
||||||
|
val staging = File(context.cacheDir, "import-$P12_NAME")
|
||||||
|
try {
|
||||||
|
staging.writeBytes(p12Bytes)
|
||||||
|
val identity = loadP12(staging, password, "imported certificate", Kind.IMPORTED)
|
||||||
|
|
||||||
|
val target = importedFile(context, P12_NAME)
|
||||||
|
target.parentFile?.mkdirs()
|
||||||
|
staging.copyTo(target, overwrite = true)
|
||||||
|
importedFile(context, PASS_NAME).writeText(password)
|
||||||
|
log.i("imported a certificate from bytes: ${identity.certificate.subjectX500Principal}")
|
||||||
|
return identity
|
||||||
|
} finally {
|
||||||
|
staging.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads the publicly available head unit certificate from the opencardev/aasdk
|
||||||
|
* repository, converts it from PEM to PKCS#12, and stores it ready for use.
|
||||||
|
* Runs synchronously (caller is responsible for threading). Throws on any failure.
|
||||||
|
*/
|
||||||
|
fun downloadAndImport(context: Context): Identity {
|
||||||
|
val download = CertDownloader.fetch()
|
||||||
|
val result = PemKeyParser.build(download.certificatePem, download.privateKeyPem)
|
||||||
|
|
||||||
|
val target = downloadedFile(context, P12_NAME)
|
||||||
|
target.parentFile?.mkdirs()
|
||||||
|
val p12Password = DEFAULT_PASSWORD
|
||||||
|
val keyStore = result.keyStore
|
||||||
|
val p12Bytes = ByteArrayOutputStream().use { stream ->
|
||||||
|
keyStore.store(stream, p12Password.toCharArray())
|
||||||
|
stream.toByteArray()
|
||||||
|
}
|
||||||
|
target.writeBytes(p12Bytes)
|
||||||
|
downloadedFile(context, PASS_NAME).writeText(p12Password)
|
||||||
|
|
||||||
|
log.i("downloaded and stored certificate: ${result.certificate.subjectX500Principal}")
|
||||||
|
return Identity(keyStore, result.certificate, p12Password.toCharArray(),
|
||||||
|
"downloaded certificate (opencardev/aasdk)", Kind.DOWNLOADED)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The imported files live in the app's private internal storage (not the external files
|
||||||
|
// dir), so other apps cannot read the key, and `adb uninstall` clearing it is expected.
|
||||||
|
private fun importedFile(context: Context, name: String) = File(File(context.filesDir, IMPORT_DIR), name)
|
||||||
|
|
||||||
|
private fun downloadedFile(context: Context, name: String) =
|
||||||
|
File(File(context.filesDir, "$IMPORT_DIR-downloaded"), name)
|
||||||
|
|
||||||
|
private fun importedPassword(context: Context): String =
|
||||||
|
importedFile(context, PASS_NAME).takeIf { it.isFile }?.readText().orEmpty()
|
||||||
|
|
||||||
|
private fun loadP12(file: File, password: String, label: String, kind: Kind): Identity {
|
||||||
|
val chars = password.toCharArray()
|
||||||
|
val keyStore = KeyStore.getInstance("PKCS12")
|
||||||
|
file.inputStream().use { keyStore.load(it, chars) }
|
||||||
|
|
||||||
|
val alias = keyStore.aliases().asSequence().firstOrNull { keyStore.isKeyEntry(it) }
|
||||||
|
?: throw IllegalStateException("the file contains no private-key entry")
|
||||||
|
val certificate = keyStore.getCertificate(alias) as X509Certificate
|
||||||
|
|
||||||
|
return Identity(keyStore, certificate, chars, "$label (alias=$alias)", kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package app.seb3thehacker.gearslip
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What changed in each release, newest first, shown on the car screen after an update (and on
|
||||||
|
* the phone). Only the changes a driver would notice. Plain words, short lines: this is read in
|
||||||
|
* a parked car.
|
||||||
|
*/
|
||||||
|
object Changelog {
|
||||||
|
|
||||||
|
class Release(val versionCode: Int, val versionName: String, val changes: List<String>)
|
||||||
|
|
||||||
|
val releases = listOf(
|
||||||
|
Release(
|
||||||
|
7, "0.1.10-alpha",
|
||||||
|
listOf(
|
||||||
|
"The launcher groups your apps: Phone, Maps, Music, Messaging, Web, Screen sharing, and Settings.",
|
||||||
|
"To move an app, press and hold it on the launcher, then tap Move left or Move right.",
|
||||||
|
"MapQuest and Flitsmeister work in Gearslip and show the green check.",
|
||||||
|
"Spotify has one tile. If Spotify isn't open on the phone, tap Open Spotify to start it.",
|
||||||
|
"Flick a map and it glides on, as it does in Android Auto.",
|
||||||
|
"The map picks up where it left off when you come back from another screen.",
|
||||||
|
"Search boxes in map apps are larger and easier to read.",
|
||||||
|
"Map apps keep the right icons on their buttons after they update.",
|
||||||
|
"When Gearslip asks \"Do you want to reply?\", a bar shows how long it will listen. The bar stops while you speak.",
|
||||||
|
"Gearslip fits its picture to wide car screens and keeps it clear of the edges the car crops.",
|
||||||
|
"Gearslip answers the car's connection checks correctly. Some cars, Audis among them, hung up after a second without this.",
|
||||||
|
"Google Maps no longer shows up as a messaging app.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Release(
|
||||||
|
6, "0.1.0-alpha",
|
||||||
|
listOf(
|
||||||
|
"Messaging apps such as Signal and Molly open to your recent chats.",
|
||||||
|
"Gearslip reads a message aloud, and you reply by voice. Say \"cancel\" or \"change it\" before it sends, or tap a quick reply such as your arrival time.",
|
||||||
|
"Voice works on any phone, GrapheneOS included. Gearslip turns speech into text itself, so it needs no Google account.",
|
||||||
|
"New messages show as a small card at the top right, even while you drive. A bar shows how long until the card closes or the reply sends.",
|
||||||
|
"Only messages, missed calls, alarms, and reminders pop up. Everything else waits on the dashboard.",
|
||||||
|
"The map stays loaded when you switch screens, so it no longer flashes black.",
|
||||||
|
"Map apps draw more reliably and keep their connection when the phone is locked.",
|
||||||
|
"The Home button shows your map app, so it no longer appears twice.",
|
||||||
|
"The next turn shows in the nav bar when you leave the map.",
|
||||||
|
"Steering wheel buttons, control knobs, and D-pads now work. If yours don't, open an issue on GitHub.",
|
||||||
|
"To see the whole song title and artist in the nav bar, choose Settings > Player in the nav bar > Song title.",
|
||||||
|
"Choose miles or kilometres under Settings > Units.",
|
||||||
|
"Try the voice assistant and the vehicle data screen under Settings > Experimental features.",
|
||||||
|
"Logs now say why a car ended the connection.",
|
||||||
|
"Spotify, Waze, and Google Messages updated to a version of Google's car library that only accepts Android Auto itself. Their own car screens won't open in Gearslip. Spotify still plays through the Gearslip player, which has the green check.",
|
||||||
|
"Calls go through the car's Bluetooth, as they do in Android Auto. Pair your phone with the car to hear calls on its speakers.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The release that matches this build, if it has notes. */
|
||||||
|
val current: Release? get() = releases.firstOrNull { it.versionCode == BuildConfig.VERSION_CODE }
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package app.seb3thehacker.gearslip
|
||||||
|
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proves the H.264 encoder actually produces a bitstream on this device, before a drive.
|
||||||
|
*
|
||||||
|
* Without this, an unsupported colour format, a rejected Baseline profile, or a null
|
||||||
|
* getInputImage() would all present in the car as "the head unit shows nothing" - the same
|
||||||
|
* symptom as a protocol fault, and impossible to tell apart from the log. Same reasoning as
|
||||||
|
* the TLS self-test: make our own failures loud on the bench.
|
||||||
|
*/
|
||||||
|
object EncoderSelfTest {
|
||||||
|
private val log = GearslipLog.tagged("VIDEO")
|
||||||
|
|
||||||
|
fun run(width: Int = 800, height: Int = 480, frameRate: Int = 30): Boolean {
|
||||||
|
log.i("=== encoder self-test starting (${width}x$height @ ${frameRate}fps) ===")
|
||||||
|
|
||||||
|
var codecConfig: ByteArray? = null
|
||||||
|
var frames = 0
|
||||||
|
var sawKeyFrame = false
|
||||||
|
var sawResyncKeyFrame = false
|
||||||
|
var resyncRequestedAtFrame = -1
|
||||||
|
val done = CountDownLatch(1)
|
||||||
|
|
||||||
|
val source = VideoSource(
|
||||||
|
width = width,
|
||||||
|
height = height,
|
||||||
|
frameRate = frameRate,
|
||||||
|
onCodecConfig = { codecConfig = it },
|
||||||
|
onFrame = { _, _, keyFrame ->
|
||||||
|
frames++
|
||||||
|
if (keyFrame) sawKeyFrame = true
|
||||||
|
// At frame 5, ask for a resync keyframe - the same call sendVideoFrame makes
|
||||||
|
// after a drop - and confirm THIS encoder actually honours it. Trusting an
|
||||||
|
// API we have never exercised on this device is exactly the mistake that
|
||||||
|
// cost two drives already (the CONTROL message-type bit, the touch scaling).
|
||||||
|
if (frames == 5) {
|
||||||
|
resyncRequestedAtFrame = frames
|
||||||
|
}
|
||||||
|
if (resyncRequestedAtFrame in 1 until frames && keyFrame) {
|
||||||
|
sawResyncKeyFrame = true
|
||||||
|
}
|
||||||
|
if (frames >= 12) done.countDown()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return try {
|
||||||
|
source.start()
|
||||||
|
// Give it a couple of frames before requesting resync, matching how it is used
|
||||||
|
// for real: the request always follows at least one already-encoded frame.
|
||||||
|
Thread {
|
||||||
|
while (resyncRequestedAtFrame == -1) Thread.sleep(20)
|
||||||
|
source.requestSyncFrame()
|
||||||
|
}.apply { isDaemon = true; start() }
|
||||||
|
done.await(8, TimeUnit.SECONDS)
|
||||||
|
source.stop()
|
||||||
|
|
||||||
|
val csd = codecConfig
|
||||||
|
when {
|
||||||
|
csd == null -> {
|
||||||
|
log.e("encoder self-test FAILED: no CODEC_CONFIG (SPS/PPS) produced")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
frames == 0 -> {
|
||||||
|
log.e("encoder self-test FAILED: no frames produced")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
!sawKeyFrame -> {
|
||||||
|
log.e("encoder self-test FAILED: no keyframe - the car needs one first")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
!sawResyncKeyFrame -> {
|
||||||
|
log.e(
|
||||||
|
"encoder self-test FAILED: requestSyncFrame() produced no keyframe - " +
|
||||||
|
"the resync-after-drop fix would silently do nothing on this device",
|
||||||
|
)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
log.i(
|
||||||
|
"SPS/PPS = ${csd.size} bytes, $frames frames, keyframe present, " +
|
||||||
|
"requestSyncFrame() confirmed working",
|
||||||
|
)
|
||||||
|
log.hex(" csd", csd, limit = 48)
|
||||||
|
log.i("=== encoder self-test PASSED ===")
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
runCatching { source.stop() }
|
||||||
|
log.e("encoder self-test FAILED with an exception", t)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package app.seb3thehacker.gearslip
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android Auto frame layout, transcribed from aasdk.
|
||||||
|
*
|
||||||
|
* FrameHeader::getData (src/Messenger/FrameHeader.cpp:47-55),
|
||||||
|
* FrameSize::getData (src/Messenger/FrameSize.cpp:49-62) and
|
||||||
|
* MessageOutStream::compoundFrame (src/Messenger/MessageOutStream.cpp:100-120):
|
||||||
|
*
|
||||||
|
* byte 0 channel id
|
||||||
|
* byte 1 frameType | encryption | messageType
|
||||||
|
* bytes 2-3 uint16 BE payload length of THIS frame
|
||||||
|
* bytes 4-7 uint32 BE total message length - present ONLY when frameType == FIRST
|
||||||
|
* payload uint16 BE message id, then the body
|
||||||
|
*/
|
||||||
|
object Frames {
|
||||||
|
|
||||||
|
const val CHANNEL_CONTROL = 0
|
||||||
|
|
||||||
|
const val TYPE_MIDDLE = 0
|
||||||
|
const val TYPE_FIRST = 1
|
||||||
|
const val TYPE_LAST = 2
|
||||||
|
const val TYPE_BULK = 3
|
||||||
|
|
||||||
|
const val ENCRYPTED = 1 shl 3
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message type bit. Channel-open messages are CONTROL even when sent on a service
|
||||||
|
* channel - aasdk sets MessageType::CONTROL only in sendChannelOpenResponse and
|
||||||
|
* MessageType::SPECIFIC everywhere else (e.g.
|
||||||
|
* src/Channel/MediaSink/Video/VideoMediaSinkService.cpp:52 vs :66). Getting this wrong
|
||||||
|
* makes the head unit drop the USB link outright rather than answer.
|
||||||
|
*/
|
||||||
|
const val MESSAGE_SPECIFIC = 0
|
||||||
|
const val MESSAGE_CONTROL = 1 shl 2
|
||||||
|
|
||||||
|
/** aasdk splits at this size (MessageOutStream.hpp:62). Nothing the spike sends is close. */
|
||||||
|
const val MAX_FRAME_PAYLOAD = 0x4000
|
||||||
|
|
||||||
|
class Frame(
|
||||||
|
val channel: Int,
|
||||||
|
val frameType: Int,
|
||||||
|
val encrypted: Boolean,
|
||||||
|
val messageType: Int,
|
||||||
|
val payload: ByteArray,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds one frame. [totalSize] is the full plaintext message length and is written only
|
||||||
|
* for FIRST frames, matching aasdk's MessageOutStream::setFrameSize
|
||||||
|
* (`FrameSize(payloadSize, totalSize)` for FIRST, `FrameSize(payloadSize)` otherwise).
|
||||||
|
*/
|
||||||
|
fun build(
|
||||||
|
channel: Int,
|
||||||
|
encrypted: Boolean,
|
||||||
|
payload: ByteArray,
|
||||||
|
messageType: Int = MESSAGE_SPECIFIC,
|
||||||
|
frameType: Int = TYPE_BULK,
|
||||||
|
totalSize: Int? = null,
|
||||||
|
): ByteArray {
|
||||||
|
val flags = frameType or messageType or (if (encrypted) ENCRYPTED else 0)
|
||||||
|
val header = byteArrayOf(
|
||||||
|
channel.toByte(),
|
||||||
|
flags.toByte(),
|
||||||
|
((payload.size shr 8) and 0xFF).toByte(),
|
||||||
|
(payload.size and 0xFF).toByte(),
|
||||||
|
)
|
||||||
|
if (frameType != TYPE_FIRST || totalSize == null) return header + payload
|
||||||
|
val extended = byteArrayOf(
|
||||||
|
((totalSize shr 24) and 0xFF).toByte(),
|
||||||
|
((totalSize shr 16) and 0xFF).toByte(),
|
||||||
|
((totalSize shr 8) and 0xFF).toByte(),
|
||||||
|
(totalSize and 0xFF).toByte(),
|
||||||
|
)
|
||||||
|
return header + extended + payload
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pulls whole frames out of an accumulating buffer.
|
||||||
|
*
|
||||||
|
* A read() on the accessory descriptor returns one bulk transfer, which is usually one
|
||||||
|
* frame - but nothing guarantees it, so this never assumes alignment.
|
||||||
|
*/
|
||||||
|
class Parser {
|
||||||
|
private var pending = ByteArray(0)
|
||||||
|
|
||||||
|
fun append(data: ByteArray, length: Int) {
|
||||||
|
val grown = ByteArray(pending.size + length)
|
||||||
|
System.arraycopy(pending, 0, grown, 0, pending.size)
|
||||||
|
System.arraycopy(data, 0, grown, pending.size, length)
|
||||||
|
pending = grown
|
||||||
|
}
|
||||||
|
|
||||||
|
fun next(): Frame? {
|
||||||
|
if (pending.size < 4) return null
|
||||||
|
val channel = pending[0].toInt() and 0xFF
|
||||||
|
val flags = pending[1].toInt() and 0xFF
|
||||||
|
val frameType = flags and TYPE_BULK
|
||||||
|
// Extended size is written only for FIRST - BULK (3) keeps the short header.
|
||||||
|
val headerLength = if (frameType == TYPE_FIRST) 8 else 4
|
||||||
|
if (pending.size < headerLength) return null
|
||||||
|
|
||||||
|
val frameSize = ((pending[2].toInt() and 0xFF) shl 8) or (pending[3].toInt() and 0xFF)
|
||||||
|
val total = headerLength + frameSize
|
||||||
|
if (pending.size < total) return null
|
||||||
|
|
||||||
|
val payload = pending.copyOfRange(headerLength, total)
|
||||||
|
pending = pending.copyOfRange(total, pending.size)
|
||||||
|
return Frame(
|
||||||
|
channel, frameType, (flags and ENCRYPTED) != 0, flags and MESSAGE_CONTROL, payload,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rejoins FIRST/MIDDLE/LAST runs. Payloads arrive already decrypted. */
|
||||||
|
class Assembler {
|
||||||
|
private val partial = HashMap<Int, ByteArrayOutputStream>()
|
||||||
|
|
||||||
|
fun offer(frame: Frame, payload: ByteArray): ByteArray? = when (frame.frameType) {
|
||||||
|
TYPE_BULK -> payload
|
||||||
|
TYPE_FIRST -> {
|
||||||
|
partial[frame.channel] = ByteArrayOutputStream().apply { write(payload) }
|
||||||
|
null
|
||||||
|
}
|
||||||
|
TYPE_MIDDLE -> {
|
||||||
|
partial[frame.channel]?.write(payload)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
TYPE_LAST -> partial.remove(frame.channel)?.apply { write(payload) }?.toByteArray()
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
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)
|
||||||
|
AppSettings.applyGearslipEnabled(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
|
||||||
|
// Switched off in Settings: the system shouldn't route the car here at all, but if it
|
||||||
|
// does, leave the accessory alone.
|
||||||
|
if (!AppSettings.gearslipEnabled(this)) {
|
||||||
|
GearslipLog.i("accessory attached, but Gearslip is switched off - not connecting")
|
||||||
|
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) {
|
||||||
|
// A fresh ATTACHED intent means the system wants a new session, whatever state the old
|
||||||
|
// one is in - closing first guarantees openAccessory() below never fights a descriptor
|
||||||
|
// this same process forgot to release. The old worker thread often isn't alive by this
|
||||||
|
// point anyway (its read loop already died when the head unit dropped the transport),
|
||||||
|
// but the accessory it opened was never explicitly closed, which is what actually blocks
|
||||||
|
// a re-open - a dead thread does not release the file descriptor on its own.
|
||||||
|
closeAccessory()
|
||||||
|
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()
|
||||||
|
app.seb3thehacker.gearslip.car.CarKeys.focusSink = { code, down ->
|
||||||
|
runOnUiThread { screenProjector?.dispatchKey(code, down) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
app.seb3thehacker.gearslip.car.CarKeys.focusSink = null
|
||||||
|
screenProjector?.stop()
|
||||||
|
screenProjector = null
|
||||||
|
surface = null
|
||||||
|
CarEnvironment.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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. Always on for
|
||||||
|
* now, while the project is pre-release.
|
||||||
|
*/
|
||||||
|
@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) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,125 @@
|
|||||||
|
package app.seb3thehacker.gearslip
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.math.BigInteger
|
||||||
|
import java.security.KeyFactory
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.security.cert.CertificateFactory
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
|
import java.security.spec.RSAPrivateCrtKeySpec
|
||||||
|
import java.security.spec.RSAPublicKeySpec
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a PEM-encoded X.509 certificate and PEM-encoded RSA PKCS#1 private key into a
|
||||||
|
* PKCS#12 [KeyStore], using only standard JCA APIs — no BouncyCastle.
|
||||||
|
*
|
||||||
|
* The DER parsing of the PKCS#1 key is done by hand (matching the same approach used in
|
||||||
|
* [SelfSignedCert] for writing). Android's Conscrypt [KeyFactory] only accepts PKCS#8,
|
||||||
|
* so we translate PKCS#1 → [RSAPrivateCrtKeySpec] ourselves.
|
||||||
|
*/
|
||||||
|
object PemKeyParser {
|
||||||
|
|
||||||
|
private const val CERT_HEADER = "-----BEGIN CERTIFICATE-----"
|
||||||
|
private const val CERT_FOOTER = "-----END CERTIFICATE-----"
|
||||||
|
private const val KEY_HEADER = "-----BEGIN RSA PRIVATE KEY-----"
|
||||||
|
private const val KEY_FOOTER = "-----END RSA PRIVATE KEY-----"
|
||||||
|
|
||||||
|
class Result(val keyStore: KeyStore, val certificate: X509Certificate)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses PEM strings for the certificate and private key, verifies they match, and
|
||||||
|
* returns a PKCS#12 [KeyStore] ready for [CertProvider] to consume.
|
||||||
|
*/
|
||||||
|
fun build(certificatePem: String, privateKeyPem: String, alias: String = "phone"): Result {
|
||||||
|
val cert = parseCertificate(certificatePem)
|
||||||
|
val keySpec = parsePrivateKey(privateKeyPem)
|
||||||
|
|
||||||
|
val kf = KeyFactory.getInstance("RSA")
|
||||||
|
val pubSpec = RSAPublicKeySpec(keySpec.spec.modulus, keySpec.spec.publicExponent)
|
||||||
|
require(kf.generatePublic(pubSpec) == cert.publicKey) {
|
||||||
|
"the certificate's public key does not match the private key"
|
||||||
|
}
|
||||||
|
|
||||||
|
val keyStore = KeyStore.getInstance("PKCS12").apply {
|
||||||
|
load(null, null)
|
||||||
|
setKeyEntry(alias, keySpec.getPrivate(), CharArray(0), arrayOf(cert))
|
||||||
|
}
|
||||||
|
return Result(keyStore, cert)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strips PEM headers/footers and decodes the base64 body. */
|
||||||
|
fun parseCertificate(pem: String): X509Certificate {
|
||||||
|
val der = pemToDer(pem, CERT_HEADER, CERT_FOOTER)
|
||||||
|
return CertificateFactory.getInstance("X.509")
|
||||||
|
.generateCertificate(ByteArrayInputStream(der)) as X509Certificate
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses an RSA PKCS#1 private key (ASN.1 DER) into a key spec. */
|
||||||
|
fun parsePrivateKey(pem: String): KeySpec {
|
||||||
|
val der = pemToDer(pem, KEY_HEADER, KEY_FOOTER)
|
||||||
|
val integers = readPkcs1Sequence(der)
|
||||||
|
require(integers.size == 9) { "PKCS#1 RSA key needs 9 integers, got ${integers.size}" }
|
||||||
|
val spec = RSAPrivateCrtKeySpec(integers[1], integers[2], integers[3], integers[4], integers[5], integers[6], integers[7], integers[8])
|
||||||
|
val privateKey = KeyFactory.getInstance("RSA").generatePrivate(spec)
|
||||||
|
return KeySpec(spec, privateKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Holds both the spec and the constructed key for matching against a certificate's public key. */
|
||||||
|
class KeySpec(val spec: RSAPrivateCrtKeySpec, private val privateKey: java.security.PrivateKey) {
|
||||||
|
fun getPrivate() = privateKey
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strips headers and decodes base64. */
|
||||||
|
private fun pemToDer(pem: String, header: String, footer: String): ByteArray {
|
||||||
|
val start = pem.indexOf(header)
|
||||||
|
val end = pem.indexOf(footer)
|
||||||
|
if (start == -1 || end == -1 || end <= start) {
|
||||||
|
throw IllegalArgumentException("not a valid PEM $header block")
|
||||||
|
}
|
||||||
|
val b64 = pem.substring(start + header.length, end)
|
||||||
|
.replace(Regex("\\s+"), "")
|
||||||
|
return java.util.Base64.getDecoder().decode(b64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the inner SEQUENCE of an RSA PKCS#1 DER blob into a list of [BigInteger]s.
|
||||||
|
* Throws if the ASN.1 structure is wrong, so caller gets a clear error rather than a
|
||||||
|
* silent wrong key.
|
||||||
|
*/
|
||||||
|
private fun readPkcs1Sequence(der: ByteArray): List<BigInteger> {
|
||||||
|
var pos = 0
|
||||||
|
fun readTag(): Int {
|
||||||
|
require(pos < der.size) { "unexpected end of DER at tag" }
|
||||||
|
return der[pos++].toInt() and 0xFF
|
||||||
|
}
|
||||||
|
fun readLength(): Int {
|
||||||
|
require(pos < der.size) { "unexpected end of DER at length" }
|
||||||
|
val b0 = der[pos++].toInt() and 0xFF
|
||||||
|
if (b0 < 0x80) return b0
|
||||||
|
val numBytes = b0 and 0x7F
|
||||||
|
require(pos + numBytes <= der.size) { "truncated long-form length" }
|
||||||
|
var result = 0
|
||||||
|
repeat(numBytes) {
|
||||||
|
result = (result shl 8) or (der[pos++].toInt() and 0xFF)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
val outerTag = readTag()
|
||||||
|
require(outerTag == 0x30) { "expected SEQUENCE (0x30), got 0x${outerTag.toString(16)}" }
|
||||||
|
val outerLen = readLength()
|
||||||
|
require(pos + outerLen <= der.size) { "SEQUENCE length $outerLen exceeds remaining data" }
|
||||||
|
|
||||||
|
val integers = mutableListOf<BigInteger>()
|
||||||
|
while (pos < der.size) {
|
||||||
|
val tag = readTag()
|
||||||
|
require(tag == 0x02) { "expected INTEGER (0x02), got 0x${tag.toString(16)} at offset ${pos - 1}" }
|
||||||
|
val len = readLength()
|
||||||
|
require(pos + len <= der.size) { "INTEGER length $len exceeds remaining data" }
|
||||||
|
integers.add(BigInteger(der.copyOfRange(pos, pos + len)))
|
||||||
|
pos += len
|
||||||
|
}
|
||||||
|
require(integers.size == 9) { "PKCS#1 RSA key needs 9 integers, got ${integers.size}" }
|
||||||
|
return integers
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
package app.seb3thehacker.gearslip
|
||||||
|
|
||||||
|
import java.net.Socket
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.security.SecureRandom
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
|
import javax.net.ssl.KeyManagerFactory
|
||||||
|
import javax.net.ssl.SSLContext
|
||||||
|
import javax.net.ssl.SSLEngine
|
||||||
|
import javax.net.ssl.SSLEngineResult
|
||||||
|
import javax.net.ssl.SSLEngineResult.HandshakeStatus
|
||||||
|
import javax.net.ssl.SSLPeerUnverifiedException
|
||||||
|
import javax.net.ssl.X509ExtendedTrustManager
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The phone end of the Android Auto TLS session.
|
||||||
|
*
|
||||||
|
* Two things here are the opposite of what aasdk does, because aasdk implements the head
|
||||||
|
* unit and the head unit is the TLS *client*: it calls TLS_client_method() and
|
||||||
|
* SSL_set_connect_state (src/Transport/SSLWrapper.cpp:97-140, src/Messenger/Cryptor.cpp:87),
|
||||||
|
* and SSL_set_accept_state appears nowhere in that codebase. So the phone is the TLS
|
||||||
|
* **server** and its certificate goes out as the server certificate - sent unconditionally
|
||||||
|
* on every handshake, which is precisely what we are testing.
|
||||||
|
*
|
||||||
|
* TLS here is not layered over the socket: handshake bytes travel inside
|
||||||
|
* MESSAGE_ENCAPSULATED_SSL control messages (id 3). That rules out SSLSocket and makes
|
||||||
|
* SSLEngine - a buffer-to-buffer engine, the JSSE counterpart of aasdk's memory BIO pair -
|
||||||
|
* the right tool.
|
||||||
|
*/
|
||||||
|
class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
||||||
|
private val log = GearslipLog.tagged("TLS")
|
||||||
|
|
||||||
|
private val engine: SSLEngine
|
||||||
|
private var netIn = ByteArray(0)
|
||||||
|
private var appBuffer = ByteBuffer.allocate(32 * 1024)
|
||||||
|
private var netBuffer = ByteBuffer.allocate(32 * 1024)
|
||||||
|
|
||||||
|
var handshakeComplete: Boolean = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
init {
|
||||||
|
val keyManagers = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
|
||||||
|
.apply { init(keyStore, password) }
|
||||||
|
.keyManagers
|
||||||
|
|
||||||
|
val context = SSLContext.getInstance("TLSv1.2").apply {
|
||||||
|
init(keyManagers, arrayOf(LoggingTrustManager()), SecureRandom())
|
||||||
|
}
|
||||||
|
|
||||||
|
engine = context.createSSLEngine().apply {
|
||||||
|
useClientMode = false
|
||||||
|
// want, not need: we ask the head unit for its certificate so the run also tells
|
||||||
|
// us what real units present, but we never make the session depend on getting one.
|
||||||
|
wantClientAuth = true
|
||||||
|
enabledProtocols = arrayOf("TLSv1.2") // aasdk pins TLS 1.2
|
||||||
|
}
|
||||||
|
engine.beginHandshake()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feeds received handshake bytes in and returns whatever must be sent back, each element
|
||||||
|
* becoming one MESSAGE_ENCAPSULATED_SSL message.
|
||||||
|
*/
|
||||||
|
fun pumpHandshake(incoming: ByteArray?): List<ByteArray> {
|
||||||
|
if (incoming != null) netIn += incoming
|
||||||
|
val outgoing = mutableListOf<ByteArray>()
|
||||||
|
var source = ByteBuffer.wrap(netIn)
|
||||||
|
|
||||||
|
loop@ while (true) {
|
||||||
|
when (engine.handshakeStatus) {
|
||||||
|
HandshakeStatus.NEED_TASK -> {
|
||||||
|
var task = engine.delegatedTask
|
||||||
|
while (task != null) {
|
||||||
|
task.run()
|
||||||
|
task = engine.delegatedTask
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HandshakeStatus.NEED_WRAP -> {
|
||||||
|
netBuffer.clear()
|
||||||
|
val result = engine.wrap(EMPTY, netBuffer)
|
||||||
|
if (result.status == SSLEngineResult.Status.BUFFER_OVERFLOW) {
|
||||||
|
netBuffer = ByteBuffer.allocate(netBuffer.capacity() * 2)
|
||||||
|
continue@loop
|
||||||
|
}
|
||||||
|
netBuffer.flip()
|
||||||
|
if (netBuffer.hasRemaining()) {
|
||||||
|
outgoing += ByteArray(netBuffer.remaining()).also { netBuffer.get(it) }
|
||||||
|
}
|
||||||
|
if (result.status == SSLEngineResult.Status.CLOSED) break@loop
|
||||||
|
}
|
||||||
|
|
||||||
|
HandshakeStatus.NEED_UNWRAP -> {
|
||||||
|
appBuffer.clear()
|
||||||
|
val result = engine.unwrap(source, appBuffer)
|
||||||
|
when (result.status) {
|
||||||
|
SSLEngineResult.Status.BUFFER_UNDERFLOW -> break@loop
|
||||||
|
SSLEngineResult.Status.BUFFER_OVERFLOW -> {
|
||||||
|
appBuffer = ByteBuffer.allocate(appBuffer.capacity() * 2)
|
||||||
|
continue@loop
|
||||||
|
}
|
||||||
|
SSLEngineResult.Status.CLOSED -> break@loop
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HandshakeStatus.FINISHED, HandshakeStatus.NOT_HANDSHAKING -> break@loop
|
||||||
|
else -> break@loop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
netIn = ByteArray(source.remaining()).also { source.get(it) }
|
||||||
|
|
||||||
|
if (!handshakeComplete && engine.handshakeStatus == HandshakeStatus.NOT_HANDSHAKING &&
|
||||||
|
engine.session.cipherSuite != "SSL_NULL_WITH_NULL_NULL"
|
||||||
|
) {
|
||||||
|
handshakeComplete = true
|
||||||
|
describeSession()
|
||||||
|
}
|
||||||
|
return outgoing
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wraps a control-channel payload for an ENCRYPTED frame. */
|
||||||
|
fun encrypt(plain: ByteArray): ByteArray {
|
||||||
|
val source = ByteBuffer.wrap(plain)
|
||||||
|
val out = java.io.ByteArrayOutputStream()
|
||||||
|
while (source.hasRemaining()) {
|
||||||
|
netBuffer.clear()
|
||||||
|
val result = engine.wrap(source, netBuffer)
|
||||||
|
if (result.status == SSLEngineResult.Status.BUFFER_OVERFLOW) {
|
||||||
|
netBuffer = ByteBuffer.allocate(netBuffer.capacity() * 2)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
netBuffer.flip()
|
||||||
|
val chunk = ByteArray(netBuffer.remaining())
|
||||||
|
netBuffer.get(chunk)
|
||||||
|
out.write(chunk)
|
||||||
|
}
|
||||||
|
return out.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unwraps one ENCRYPTED frame's payload. aasdk decrypts per frame
|
||||||
|
* (MessageOutStream::compoundFrame encrypts per frame), so each frame's payload is
|
||||||
|
* self-contained ciphertext.
|
||||||
|
*/
|
||||||
|
fun decrypt(cipher: ByteArray): ByteArray {
|
||||||
|
val source = ByteBuffer.wrap(cipher)
|
||||||
|
val out = java.io.ByteArrayOutputStream()
|
||||||
|
while (source.hasRemaining()) {
|
||||||
|
appBuffer.clear()
|
||||||
|
val result = engine.unwrap(source, appBuffer)
|
||||||
|
when (result.status) {
|
||||||
|
SSLEngineResult.Status.BUFFER_OVERFLOW -> {
|
||||||
|
appBuffer = ByteBuffer.allocate(appBuffer.capacity() * 2)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
SSLEngineResult.Status.BUFFER_UNDERFLOW -> {
|
||||||
|
log.w("decrypt: record split across frames (${source.remaining()} bytes left over)")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
SSLEngineResult.Status.CLOSED -> break
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
appBuffer.flip()
|
||||||
|
val chunk = ByteArray(appBuffer.remaining())
|
||||||
|
appBuffer.get(chunk)
|
||||||
|
out.write(chunk)
|
||||||
|
if (result.bytesConsumed() == 0 && result.bytesProduced() == 0) break
|
||||||
|
}
|
||||||
|
return out.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun describeSession() {
|
||||||
|
val session = engine.session
|
||||||
|
log.i("TLS handshake COMPLETE - protocol=${session.protocol} cipher=${session.cipherSuite}")
|
||||||
|
SessionReport.tls(session.protocol, session.cipherSuite)
|
||||||
|
try {
|
||||||
|
val peers = session.peerCertificates
|
||||||
|
log.i("head unit presented ${peers.size} certificate(s):")
|
||||||
|
peers.forEachIndexed { index, certificate ->
|
||||||
|
if (certificate is X509Certificate) {
|
||||||
|
log.i(" [$index] subject=${certificate.subjectX500Principal}")
|
||||||
|
log.i(" issuer =${certificate.issuerX500Principal}")
|
||||||
|
log.i(" serial =${certificate.serialNumber} valid=${certificate.notBefore}..${certificate.notAfter}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: SSLPeerUnverifiedException) {
|
||||||
|
log.i("head unit presented NO certificate (we asked but did not require one)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Accepts everything and reports what it saw. We want the data, not a verdict. */
|
||||||
|
private class LoggingTrustManager : X509ExtendedTrustManager() {
|
||||||
|
private val log = GearslipLog.tagged("TLS")
|
||||||
|
|
||||||
|
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {
|
||||||
|
log.i("peer certificate offered (authType=$authType, chain=${chain?.size ?: 0}) - accepting")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?, socket: Socket?) =
|
||||||
|
checkClientTrusted(chain, authType)
|
||||||
|
|
||||||
|
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?, engine: SSLEngine?) =
|
||||||
|
checkClientTrusted(chain, authType)
|
||||||
|
|
||||||
|
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) =
|
||||||
|
checkClientTrusted(chain, authType)
|
||||||
|
|
||||||
|
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?, socket: Socket?) =
|
||||||
|
checkClientTrusted(chain, authType)
|
||||||
|
|
||||||
|
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?, engine: SSLEngine?) =
|
||||||
|
checkClientTrusted(chain, authType)
|
||||||
|
|
||||||
|
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val EMPTY: ByteBuffer = ByteBuffer.allocate(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package app.seb3thehacker.gearslip
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/** Decides what the car screen shows. */
|
||||||
|
object ProjectedContent {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Precedence: the URL saved in Settings, then a `url.txt` in the external files dir (the
|
||||||
|
* pre-Settings way, still honoured so existing setups keep working), then [DEFAULT_URL].
|
||||||
|
*/
|
||||||
|
fun resolve(context: Context): String {
|
||||||
|
val saved = AppSettings.startupUrl(context)
|
||||||
|
if (saved.isNotEmpty()) {
|
||||||
|
val url = normalize(saved)
|
||||||
|
GearslipLog.i("projecting startup URL from settings: $url")
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
val legacy = context.getExternalFilesDir(null)?.let { File(it, "url.txt") }
|
||||||
|
if (legacy != null && legacy.isFile) {
|
||||||
|
val url = legacy.readText().trim()
|
||||||
|
if (url.isNotEmpty()) {
|
||||||
|
GearslipLog.i("projecting url.txt override: $url")
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DEFAULT_URL
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the Web app opens until a page is chosen in Settings. */
|
||||||
|
const val DEFAULT_URL = "https://duckduckgo.com"
|
||||||
|
|
||||||
|
/** A bare "example.com" is a URL the user meant; raw HTML is passed through untouched. */
|
||||||
|
private fun normalize(value: String): String =
|
||||||
|
if (value.startsWith("<") || "://" in value) value else "https://$value"
|
||||||
|
|
||||||
|
/** Big targets, a live clock, and tap feedback - all verifiable at a glance in a car. */
|
||||||
|
val TEST_PAGE = """
|
||||||
|
<html><head><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<style>
|
||||||
|
html{height:100%}
|
||||||
|
body{margin:0;font-family:sans-serif;background:#101418;color:#fff;
|
||||||
|
display:flex;flex-direction:column;height:100%}
|
||||||
|
h1{margin:12px;font-size:28px}
|
||||||
|
#t{margin:0 12px 8px;font-size:20px;color:#8ab4f8}
|
||||||
|
.row{display:flex;flex:1;gap:8px;padding:8px}
|
||||||
|
.b{flex:1;display:flex;align-items:center;justify-content:center;
|
||||||
|
font-size:26px;border-radius:12px;background:#243043}
|
||||||
|
.hit{background:#2e7d32}
|
||||||
|
</style></head><body>
|
||||||
|
<h1>Gearslip — 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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package app.seb3thehacker.gearslip
|
||||||
|
|
||||||
|
import android.view.Surface
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lets [GearslipRunner] drive on-screen content without owning any Android UI itself.
|
||||||
|
*
|
||||||
|
* The protocol side decides *when* a surface is needed and *where* touches landed; the
|
||||||
|
* activity decides *what* to render and how to inject events, because both need a UI thread
|
||||||
|
* and a Context.
|
||||||
|
*/
|
||||||
|
interface Projection {
|
||||||
|
|
||||||
|
/** Called once the encoder's input Surface exists and content should start rendering. */
|
||||||
|
fun onSurfaceReady(surface: Surface, width: Int, height: Int, densityDpi: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A touch from the head unit, already scaled into projected-video coordinates.
|
||||||
|
* [action] is an Android MotionEvent action - Android Auto's PointerAction enum uses
|
||||||
|
* identical values (DOWN=0, UP=1, MOVED=2, POINTER_DOWN=5, POINTER_UP=6). [points] is every
|
||||||
|
* finger currently down, and [actionIndex] says which of them [action] is about, so a pinch
|
||||||
|
* arrives as it would on the phone's own screen.
|
||||||
|
*/
|
||||||
|
fun onTouch(action: Int, actionIndex: Int, points: List<TouchPoint>)
|
||||||
|
|
||||||
|
fun onProjectionStopped()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One finger on the car's screen: a stable [id] for as long as it stays down, and where it is. */
|
||||||
|
class TouchPoint(val id: Int, val x: Float, val y: Float)
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
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? = readInt64Field(data, field)?.toInt()
|
||||||
|
|
||||||
|
/** Reads a varint field whole, for int64s such as a ping's timestamp. */
|
||||||
|
fun readInt64Field(data: ByteArray, field: Int): Long? {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
2 -> {
|
||||||
|
val (len, afterLen) = readVarint(data, pos) ?: return null
|
||||||
|
pos = afterLen + len.toInt()
|
||||||
|
}
|
||||||
|
5 -> pos += 4
|
||||||
|
1 -> pos += 8
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shallow field dump. Used on ServiceDiscoveryResponse: its make/model/year strings
|
||||||
|
* (fields 2-4) and head-unit info (7-10) coming out readable is the clearest possible
|
||||||
|
* proof that the session is live and we are decrypting correctly.
|
||||||
|
*/
|
||||||
|
fun describe(data: ByteArray, indent: String = " "): String {
|
||||||
|
val sb = StringBuilder()
|
||||||
|
var pos = 0
|
||||||
|
while (pos < data.size) {
|
||||||
|
val (tag, afterTag) = readVarint(data, pos) ?: break
|
||||||
|
pos = afterTag
|
||||||
|
val number = (tag ushr 3).toInt()
|
||||||
|
when ((tag and 7L).toInt()) {
|
||||||
|
0 -> {
|
||||||
|
val (value, after) = readVarint(data, pos) ?: break
|
||||||
|
pos = after
|
||||||
|
sb.append("$indent#$number varint = $value\n")
|
||||||
|
}
|
||||||
|
1 -> {
|
||||||
|
if (pos + 8 > data.size) break
|
||||||
|
sb.append("$indent#$number fixed64\n"); pos += 8
|
||||||
|
}
|
||||||
|
2 -> {
|
||||||
|
val (len, afterLen) = readVarint(data, pos) ?: break
|
||||||
|
val end = afterLen + len.toInt()
|
||||||
|
if (end > data.size) break
|
||||||
|
val body = data.copyOfRange(afterLen, end)
|
||||||
|
pos = end
|
||||||
|
val text = body.toString(Charsets.UTF_8)
|
||||||
|
if (body.isNotEmpty() && text.all { it == '\n' || it == '\t' || it.code in 32..126 }) {
|
||||||
|
sb.append("$indent#$number string = \"$text\"\n")
|
||||||
|
} else {
|
||||||
|
sb.append("$indent#$number message/bytes (${body.size})\n")
|
||||||
|
sb.append(describe(body, "$indent "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
5 -> {
|
||||||
|
if (pos + 4 > data.size) break
|
||||||
|
sb.append("$indent#$number fixed32\n"); pos += 4
|
||||||
|
}
|
||||||
|
else -> break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readVarint(data: ByteArray, start: Int): Pair<Long, Int>? {
|
||||||
|
var result = 0L
|
||||||
|
var shift = 0
|
||||||
|
var pos = start
|
||||||
|
while (pos < data.size && shift <= 63) {
|
||||||
|
val b = data[pos].toInt() and 0xFF
|
||||||
|
result = result or ((b and 0x7F).toLong() shl shift)
|
||||||
|
pos++
|
||||||
|
if (b and 0x80 == 0) return result to pos
|
||||||
|
shift += 7
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Structured field access, for walking ServiceDiscoveryResponse without protoc. */
|
||||||
|
object Wire {
|
||||||
|
|
||||||
|
class Field(val number: Int, val wireType: Int, val varint: Long, val bytes: ByteArray?)
|
||||||
|
|
||||||
|
fun fields(data: ByteArray): List<Field> {
|
||||||
|
val out = mutableListOf<Field>()
|
||||||
|
var pos = 0
|
||||||
|
while (pos < data.size) {
|
||||||
|
val tag = readVarint(data, pos) ?: break
|
||||||
|
pos = tag.second
|
||||||
|
val number = (tag.first ushr 3).toInt()
|
||||||
|
when ((tag.first and 7L).toInt()) {
|
||||||
|
0 -> {
|
||||||
|
val v = readVarint(data, pos) ?: break
|
||||||
|
pos = v.second
|
||||||
|
out += Field(number, 0, v.first, null)
|
||||||
|
}
|
||||||
|
1 -> { if (pos + 8 > data.size) break; pos += 8 }
|
||||||
|
2 -> {
|
||||||
|
val len = readVarint(data, pos) ?: break
|
||||||
|
val end = len.second + len.first.toInt()
|
||||||
|
if (end > data.size || end < len.second) break
|
||||||
|
out += Field(number, 2, 0, data.copyOfRange(len.second, end))
|
||||||
|
pos = end
|
||||||
|
}
|
||||||
|
5 -> { if (pos + 4 > data.size) break; pos += 4 }
|
||||||
|
else -> break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
fun varint(fields: List<Field>, number: Int): Long? =
|
||||||
|
fields.firstOrNull { it.number == number && it.wireType == 0 }?.varint
|
||||||
|
|
||||||
|
fun bytes(fields: List<Field>, number: Int): ByteArray? =
|
||||||
|
fields.firstOrNull { it.number == number && it.wireType == 2 }?.bytes
|
||||||
|
|
||||||
|
fun allBytes(fields: List<Field>, number: Int): List<ByteArray> =
|
||||||
|
fields.filter { it.number == number && it.wireType == 2 }.mapNotNull { it.bytes }
|
||||||
|
|
||||||
|
private fun readVarint(data: ByteArray, start: Int): Pair<Long, Int>? {
|
||||||
|
var result = 0L
|
||||||
|
var shift = 0
|
||||||
|
var pos = start
|
||||||
|
while (pos < data.size && shift <= 63) {
|
||||||
|
val b = data[pos].toInt() and 0xFF
|
||||||
|
result = result or ((b and 0x7F).toLong() shl shift)
|
||||||
|
pos++
|
||||||
|
if (b and 0x80 == 0) return result to pos
|
||||||
|
shift += 7
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [name] names the virtual display so the two callers are told apart from outside the app
|
||||||
|
* (`dumpsys display`): the real car session keeps "gearslip-projection", the on-phone bench
|
||||||
|
* preview passes "gearslip-preview".
|
||||||
|
*/
|
||||||
|
fun start(
|
||||||
|
surface: Surface,
|
||||||
|
width: Int,
|
||||||
|
height: Int,
|
||||||
|
densityDpi: Int,
|
||||||
|
name: String = "gearslip-projection",
|
||||||
|
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(
|
||||||
|
name, 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key from the car's D-pad or knob, as a hardware key event on the car display. Compose
|
||||||
|
* moves focus between buttons on arrows and Tab, and clicks the focused one on OK/Enter.
|
||||||
|
* [keyCode] of [CarKeys.KEYCODE_SHIFT_TAB] is Tab with Shift held.
|
||||||
|
*/
|
||||||
|
fun dispatchKey(keyCode: Int, down: Boolean) {
|
||||||
|
val target = root ?: return
|
||||||
|
// Nothing on the display takes keys until something in it holds focus.
|
||||||
|
if (target.findFocus() == null) target.requestFocus()
|
||||||
|
val shift = keyCode == app.seb3thehacker.gearslip.car.CarKeys.KEYCODE_SHIFT_TAB
|
||||||
|
val code = if (shift) android.view.KeyEvent.KEYCODE_TAB else keyCode
|
||||||
|
val now = SystemClock.uptimeMillis()
|
||||||
|
val event = android.view.KeyEvent(
|
||||||
|
now, now,
|
||||||
|
if (down) android.view.KeyEvent.ACTION_DOWN else android.view.KeyEvent.ACTION_UP,
|
||||||
|
code, 0,
|
||||||
|
if (shift) android.view.KeyEvent.META_SHIFT_ON or android.view.KeyEvent.META_SHIFT_LEFT_ON else 0,
|
||||||
|
android.view.KeyCharacterMap.VIRTUAL_KEYBOARD, 0, 0, android.view.InputDevice.SOURCE_DPAD,
|
||||||
|
)
|
||||||
|
target.dispatchKeyEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
runCatching { presentation?.dismiss() }
|
||||||
|
runCatching { owner?.destroy() }
|
||||||
|
runCatching { virtualDisplay?.release() }
|
||||||
|
presentation = null
|
||||||
|
owner = null
|
||||||
|
root = null
|
||||||
|
virtualDisplay = null
|
||||||
|
downTime = 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ViewTreeOwner : LifecycleOwner, SavedStateRegistryOwner, ViewModelStoreOwner {
|
||||||
|
private val registry = LifecycleRegistry(this)
|
||||||
|
private val savedState = SavedStateRegistryController.create(this)
|
||||||
|
|
||||||
|
override val lifecycle: Lifecycle get() = registry
|
||||||
|
override val savedStateRegistry: SavedStateRegistry get() = savedState.savedStateRegistry
|
||||||
|
override val viewModelStore = ViewModelStore()
|
||||||
|
|
||||||
|
init {
|
||||||
|
savedState.performRestore(null)
|
||||||
|
registry.currentState = Lifecycle.State.RESUMED
|
||||||
|
}
|
||||||
|
|
||||||
|
fun destroy() {
|
||||||
|
registry.currentState = Lifecycle.State.DESTROYED
|
||||||
|
viewModelStore.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package app.seb3thehacker.gearslip
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.math.BigInteger
|
||||||
|
import java.security.KeyPair
|
||||||
|
import java.security.KeyPairGenerator
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.security.SecureRandom
|
||||||
|
import java.security.Signature
|
||||||
|
import java.security.cert.CertificateFactory
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.TimeZone
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a self-signed RSA-2048 certificate - the thing under test.
|
||||||
|
*
|
||||||
|
* Written by hand rather than with BouncyCastle so the spike has no dependencies and no
|
||||||
|
* second security provider sitting next to Conscrypt. A self-signed X.509 is a fixed
|
||||||
|
* structure, so this is mechanical; the risk is a silent malformation, which
|
||||||
|
* [generate] closes off by parsing its own output back through CertificateFactory and
|
||||||
|
* verifying the signature before returning. A bad certificate therefore fails loudly on the
|
||||||
|
* bench instead of looking like a head-unit rejection in the car.
|
||||||
|
*
|
||||||
|
* RSA-2048 matches aasdk's own key (cert/headunit.key) - see cert/README.md.
|
||||||
|
*/
|
||||||
|
object SelfSignedCert {
|
||||||
|
|
||||||
|
const val ALIAS = "phone"
|
||||||
|
val PASSWORD: CharArray = "spike".toCharArray()
|
||||||
|
|
||||||
|
// Pre-encoded OIDs, so there is no OID encoder to get wrong.
|
||||||
|
private val OID_SHA256_RSA = byteArrayOf(
|
||||||
|
0x06, 0x09, 0x2A, 0x86.toByte(), 0x48, 0x86.toByte(), 0xF7.toByte(), 0x0D, 0x01, 0x01, 0x0B
|
||||||
|
)
|
||||||
|
private val OID_CN = byteArrayOf(0x06, 0x03, 0x55, 0x04, 0x03)
|
||||||
|
private val OID_O = byteArrayOf(0x06, 0x03, 0x55, 0x04, 0x0A)
|
||||||
|
|
||||||
|
class Result(val keyStore: KeyStore, val certificate: X509Certificate)
|
||||||
|
|
||||||
|
fun generate(commonName: String = "Gearslip", organisation: String = "Gearslip"): Result {
|
||||||
|
val keyPair = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair()
|
||||||
|
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val notBefore = Date(now - 86_400_000L) // a day of slack for head-unit clock skew
|
||||||
|
val notAfter = Date(now + 20L * 365 * 86_400_000L) // ~2046, still inside UTCTime's pre-2050 range
|
||||||
|
|
||||||
|
val der = buildCertificate(keyPair, commonName, organisation, notBefore, notAfter)
|
||||||
|
|
||||||
|
// Self-check: malformed DER throws here, on the bench, not in the car.
|
||||||
|
val certificate = CertificateFactory.getInstance("X.509")
|
||||||
|
.generateCertificate(ByteArrayInputStream(der)) as X509Certificate
|
||||||
|
certificate.verify(keyPair.public)
|
||||||
|
certificate.checkValidity()
|
||||||
|
|
||||||
|
val keyStore = KeyStore.getInstance("PKCS12").apply {
|
||||||
|
load(null, null)
|
||||||
|
setKeyEntry(ALIAS, keyPair.private, PASSWORD, arrayOf(certificate))
|
||||||
|
}
|
||||||
|
return Result(keyStore, certificate)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildCertificate(
|
||||||
|
keyPair: KeyPair,
|
||||||
|
commonName: String,
|
||||||
|
organisation: String,
|
||||||
|
notBefore: Date,
|
||||||
|
notAfter: Date,
|
||||||
|
): ByteArray {
|
||||||
|
val algorithmId = seq(OID_SHA256_RSA, tlv(0x05, ByteArray(0))) // sha256WithRSAEncryption, NULL params
|
||||||
|
val name = seq(rdn(OID_CN, commonName), rdn(OID_O, organisation))
|
||||||
|
val serial = BigInteger(64, SecureRandom()).add(BigInteger.ONE)
|
||||||
|
|
||||||
|
val tbs = seq(
|
||||||
|
tlv(0xA0, integer(BigInteger.valueOf(2))), // [0] EXPLICIT version, v3
|
||||||
|
integer(serial),
|
||||||
|
algorithmId,
|
||||||
|
name,
|
||||||
|
seq(utcTime(notBefore), utcTime(notAfter)),
|
||||||
|
name,
|
||||||
|
keyPair.public.encoded, // already a DER SubjectPublicKeyInfo
|
||||||
|
)
|
||||||
|
|
||||||
|
val signature = Signature.getInstance("SHA256withRSA").run {
|
||||||
|
initSign(keyPair.private)
|
||||||
|
update(tbs)
|
||||||
|
sign()
|
||||||
|
}
|
||||||
|
return seq(tbs, algorithmId, bitString(signature))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- minimal DER writer ------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun length(n: Int): ByteArray {
|
||||||
|
if (n < 0x80) return byteArrayOf(n.toByte())
|
||||||
|
var value = n
|
||||||
|
val out = ArrayList<Byte>()
|
||||||
|
while (value > 0) {
|
||||||
|
out.add(0, (value and 0xFF).toByte())
|
||||||
|
value = value ushr 8
|
||||||
|
}
|
||||||
|
return byteArrayOf((0x80 or out.size).toByte()) + out.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tlv(tag: Int, body: ByteArray) = byteArrayOf(tag.toByte()) + length(body.size) + body
|
||||||
|
|
||||||
|
private fun seq(vararg parts: ByteArray) = tlv(0x30, parts.reduce { a, b -> a + b })
|
||||||
|
|
||||||
|
private fun integer(v: BigInteger) = tlv(0x02, v.toByteArray())
|
||||||
|
|
||||||
|
private fun bitString(body: ByteArray) = tlv(0x03, byteArrayOf(0x00) + body)
|
||||||
|
|
||||||
|
private fun rdn(oid: ByteArray, value: String) =
|
||||||
|
tlv(0x31, seq(oid, tlv(0x0C, value.toByteArray(Charsets.UTF_8))))
|
||||||
|
|
||||||
|
private fun utcTime(date: Date): ByteArray {
|
||||||
|
val format = SimpleDateFormat("yyMMddHHmmss", Locale.US).apply {
|
||||||
|
timeZone = TimeZone.getTimeZone("UTC")
|
||||||
|
}
|
||||||
|
return tlv(0x17, (format.format(date) + "Z").toByteArray(Charsets.US_ASCII))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
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 crops away, split evenly between the two edges, to fit the
|
||||||
|
* frame to a screen of a different shape. A matched vehicle profile's insets override them.
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
|
||||||
|
/** What's left on the car's screen once the margins are cropped off. */
|
||||||
|
fun visibleSize(): Pair<Int, Int>? = pixelSize()?.let { (w, h) -> (w - widthMargin) to (h - heightMargin) }
|
||||||
|
|
||||||
|
/** The margins as the strips of the frame the car UI keeps clear. */
|
||||||
|
fun marginInsets(): Insets = Insets(
|
||||||
|
top = heightMargin / 2, bottom = heightMargin - heightMargin / 2,
|
||||||
|
left = widthMargin / 2, right = widthMargin - widthMargin / 2,
|
||||||
|
)
|
||||||
|
|
||||||
|
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 and button set, from InputSourceService (Service field 4):
|
||||||
|
* InputSourceService { repeated int32 keycodes_supported = 1 [packed=true];
|
||||||
|
* 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. [keycodesSupported] is what the
|
||||||
|
* head unit says it will actually send over the Input channel as key events (steering-wheel
|
||||||
|
* buttons, etc.) - not every unit reports the same set, so it's kept for diagnostics and to
|
||||||
|
* know ahead of time whether a button like voice/PTT is even wired up on this hardware.
|
||||||
|
*/
|
||||||
|
class InputService(
|
||||||
|
val serviceId: Int,
|
||||||
|
val touchWidth: Int,
|
||||||
|
val touchHeight: Int,
|
||||||
|
val keycodesSupported: List<Int> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
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 inputFields = Wire.fields(input)
|
||||||
|
// A unit with only a knob and buttons has no touchscreen entry; it still has keys.
|
||||||
|
val touchFields = Wire.bytes(inputFields, 2)?.let { Wire.fields(it) }.orEmpty()
|
||||||
|
val width = Wire.varint(touchFields, 1)?.toInt() ?: 0
|
||||||
|
val height = Wire.varint(touchFields, 2)?.toInt() ?: 0
|
||||||
|
// Declared packed, but some units send each keycode as its own field.
|
||||||
|
val keycodes = inputFields.filter { it.number == 1 }.flatMap { f ->
|
||||||
|
f.bytes?.let(::packedVarints) ?: listOf(f.varint.toInt())
|
||||||
|
}
|
||||||
|
return InputService(id, width, height, keycodes)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decodes a `[packed=true]` repeated varint field's raw bytes into individual values. */
|
||||||
|
private fun packedVarints(data: ByteArray): List<Int> = buildList {
|
||||||
|
var pos = 0
|
||||||
|
while (pos < data.size) {
|
||||||
|
var result = 0L
|
||||||
|
var shift = 0
|
||||||
|
while (pos < data.size) {
|
||||||
|
val b = data[pos].toInt() and 0xFF
|
||||||
|
result = result or ((b and 0x7F).toLong() shl shift)
|
||||||
|
pos++
|
||||||
|
if (b and 0x80 == 0) break
|
||||||
|
shift += 7
|
||||||
|
}
|
||||||
|
add(result.toInt())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The head unit's sensor feed, from SensorSourceService (Service field 5):
|
||||||
|
* SensorSourceService { repeated SensorType sensors = 1; }
|
||||||
|
* SensorType { required int32 type = 1; }
|
||||||
|
*
|
||||||
|
* Field numbers for what actually rides inside each SensorEvent once subscribed are not
|
||||||
|
* pinned down here - unlike video/audio/input, there's no independent source to check them
|
||||||
|
* against, so [CarSensors] shows the raw decode instead of trusting a guessed field map.
|
||||||
|
*/
|
||||||
|
class SensorService(val serviceId: Int, val types: List<Int>)
|
||||||
|
|
||||||
|
fun findSensorService(response: ByteArray): SensorService? {
|
||||||
|
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
|
||||||
|
val service = Wire.fields(channel)
|
||||||
|
val id = Wire.varint(service, 1)?.toInt() ?: continue
|
||||||
|
val sensorSource = Wire.bytes(service, 5) ?: continue
|
||||||
|
val types = Wire.allBytes(Wire.fields(sensorSource), 1).mapNotNull {
|
||||||
|
Wire.varint(Wire.fields(it), 1)?.toInt()
|
||||||
|
}
|
||||||
|
if (types.isEmpty()) continue
|
||||||
|
return SensorService(id, types)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who the head unit says it is, from the top-level strings of ServiceDiscoveryResponse.
|
||||||
|
* Field numbers as seen from a 2018 Dodge Challenger: 2="Uconnect", 3="Dodge Challenger",
|
||||||
|
* 4="18", 7="Delphi", 8="VP2_R Uconnect 7".
|
||||||
|
*/
|
||||||
|
class HeadUnitInfo(
|
||||||
|
val headUnitName: String,
|
||||||
|
val carModel: String,
|
||||||
|
val carYear: String,
|
||||||
|
val headUnitMake: String,
|
||||||
|
val headUnitModel: String,
|
||||||
|
) {
|
||||||
|
override fun toString() =
|
||||||
|
"name=\"$headUnitName\" car=\"$carModel\" year=\"$carYear\" " +
|
||||||
|
"make=\"$headUnitMake\" model=\"$headUnitModel\""
|
||||||
|
}
|
||||||
|
|
||||||
|
fun findHeadUnitInfo(response: ByteArray): HeadUnitInfo {
|
||||||
|
val fields = Wire.fields(response)
|
||||||
|
fun text(number: Int) = Wire.bytes(fields, number)?.toString(Charsets.UTF_8).orEmpty()
|
||||||
|
return HeadUnitInfo(text(2), text(3), text(4), text(7), text(8))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
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"),
|
||||||
|
BYEBYE("head unit ended the session"),
|
||||||
|
CRASH("app crash"),
|
||||||
|
UNKNOWN("unclassified"),
|
||||||
|
}
|
||||||
|
|
||||||
|
private var appVersion = "?"
|
||||||
|
private var appCode = 0L
|
||||||
|
|
||||||
|
@Volatile private var startedAt = 0L
|
||||||
|
@Volatile var category = Category.NONE; private set
|
||||||
|
@Volatile private var failure = ""
|
||||||
|
@Volatile private var protocolVersion = ""
|
||||||
|
@Volatile private var certSource = ""
|
||||||
|
@Volatile private var certIssuer = ""
|
||||||
|
@Volatile private var tlsProtocol = ""
|
||||||
|
@Volatile private var tlsCipher = ""
|
||||||
|
@Volatile private var authStatus: Int? = null
|
||||||
|
@Volatile private var headUnit = ""
|
||||||
|
@Volatile private var profile = ""
|
||||||
|
@Volatile private var video = ""
|
||||||
|
@Volatile private var audio = ""
|
||||||
|
@Volatile private var lastStream = ""
|
||||||
|
@Volatile private var lastState = ""
|
||||||
|
private var printed = false
|
||||||
|
|
||||||
|
fun init(context: Context) {
|
||||||
|
val info = runCatching { context.packageManager.getPackageInfo(context.packageName, 0) }.getOrNull()
|
||||||
|
appVersion = info?.versionName ?: "?"
|
||||||
|
appCode = info?.longVersionCode ?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fun begin() {
|
||||||
|
startedAt = System.currentTimeMillis()
|
||||||
|
category = Category.NONE
|
||||||
|
failure = ""; protocolVersion = ""; certSource = ""; certIssuer = ""
|
||||||
|
tlsProtocol = ""; tlsCipher = ""; authStatus = null
|
||||||
|
headUnit = ""; profile = ""; video = ""; lastStream = ""; lastState = ""
|
||||||
|
printed = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun protocolVersion(major: Int, minor: Int) { protocolVersion = "$major.$minor" }
|
||||||
|
fun certificate(source: String, issuer: String) { certSource = source; certIssuer = issuer }
|
||||||
|
fun tls(protocol: String, cipher: String) { tlsProtocol = protocol; tlsCipher = cipher }
|
||||||
|
fun auth(status: Int) { authStatus = status }
|
||||||
|
fun headUnit(info: String) { headUnit = info }
|
||||||
|
fun profile(name: String?) { profile = name ?: "none matched" }
|
||||||
|
fun video(summary: String) { video = summary }
|
||||||
|
fun audio(summary: String) { audio = summary }
|
||||||
|
fun stream(summary: String) { lastStream = summary }
|
||||||
|
|
||||||
|
/** Records the first failure only: later ones are usually fallout from it. */
|
||||||
|
fun fail(category: Category, detail: String, state: String = "") {
|
||||||
|
if (this.category != Category.NONE) return
|
||||||
|
this.category = category
|
||||||
|
failure = detail
|
||||||
|
lastState = state
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fail(category: Category, detail: String, t: Throwable, state: String = "") =
|
||||||
|
fail(category, "$detail (${GearslipLog.rootCause(t)})", state)
|
||||||
|
|
||||||
|
/** The report as plain text, for the log file and for anything that gets sent. */
|
||||||
|
fun render(): String {
|
||||||
|
val seconds = if (startedAt == 0L) 0 else (System.currentTimeMillis() - startedAt) / 1000
|
||||||
|
fun row(name: String, value: String) = if (value.isEmpty()) "" else " ${name.padEnd(16)}$value\n"
|
||||||
|
return buildString {
|
||||||
|
append("=".repeat(60)).append('\n')
|
||||||
|
append("SESSION REPORT\n")
|
||||||
|
append(row("result", if (category == Category.NONE) "ok / no failure recorded" else "FAILED - ${category.label}"))
|
||||||
|
append(row("detail", failure))
|
||||||
|
append(row("failed in state", lastState))
|
||||||
|
append(row("duration", "${seconds}s"))
|
||||||
|
append("\n")
|
||||||
|
append(row("gearslip", "$appVersion (code $appCode)"))
|
||||||
|
append(row("phone", "${Build.MANUFACTURER} ${Build.MODEL}"))
|
||||||
|
append(row("android", "${Build.VERSION.RELEASE} (sdk ${Build.VERSION.SDK_INT}, patch ${Build.VERSION.SECURITY_PATCH})"))
|
||||||
|
append("\n")
|
||||||
|
append(row("head unit", headUnit))
|
||||||
|
append(row("AA protocol", protocolVersion))
|
||||||
|
append(row("profile", profile))
|
||||||
|
append("\n")
|
||||||
|
append(row("phone cert", certSource))
|
||||||
|
append(row("cert issuer", certIssuer))
|
||||||
|
append(row("TLS", listOf(tlsProtocol, tlsCipher).filter { it.isNotEmpty() }.joinToString(" / ")))
|
||||||
|
append(row("auth status", authStatus?.let { "$it (${authName(it)})" } ?: ""))
|
||||||
|
append("\n")
|
||||||
|
append(row("video", video))
|
||||||
|
append(row("audio", audio))
|
||||||
|
append(row("last stream stat", lastStream))
|
||||||
|
append("=".repeat(60))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prints the report into the log, once per session. */
|
||||||
|
fun print() {
|
||||||
|
if (printed || startedAt == 0L) return
|
||||||
|
printed = true
|
||||||
|
render().lines().forEach { GearslipLog.i(it) }
|
||||||
|
GearslipLog.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun authName(status: Int) = when (status) {
|
||||||
|
0 -> "success"
|
||||||
|
-2 -> "certificate error"
|
||||||
|
-3 -> "authentication failure"
|
||||||
|
else -> "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package app.seb3thehacker.gearslip
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the phone UI shows about the current car session.
|
||||||
|
*
|
||||||
|
* [GearslipRunner] reports progress here as it moves through the handshake, so the UI reads
|
||||||
|
* a plain state instead of parsing log lines. A failure stays on screen until the next
|
||||||
|
* attach starts, so the reason is still there when the user picks the phone back up.
|
||||||
|
*/
|
||||||
|
object SessionStatus {
|
||||||
|
|
||||||
|
enum class Phase { IDLE, CONNECTING, PROJECTING, FAILED, DISCONNECTED }
|
||||||
|
|
||||||
|
data class Snapshot(val phase: Phase, val headline: String, val detail: String)
|
||||||
|
|
||||||
|
private val flow = MutableStateFlow(
|
||||||
|
Snapshot(
|
||||||
|
Phase.IDLE,
|
||||||
|
"Not connected",
|
||||||
|
"Plug the phone into the car's USB port. Gearslip opens by itself when the head unit connects.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val state: StateFlow<Snapshot> = flow
|
||||||
|
|
||||||
|
fun connecting(stage: String) {
|
||||||
|
flow.value = Snapshot(Phase.CONNECTING, "Connecting", stage)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun projecting() {
|
||||||
|
flow.value = Snapshot(Phase.PROJECTING, "Projecting", "Streaming to the head unit.")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun failed(headline: String, detail: String) {
|
||||||
|
flow.value = Snapshot(Phase.FAILED, headline, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps an earlier failure visible: a rejection is followed by a disconnect every time. */
|
||||||
|
fun disconnected() {
|
||||||
|
if (flow.value.phase == Phase.FAILED) return
|
||||||
|
flow.value = Snapshot(Phase.DISCONNECTED, "Disconnected", "The head unit is no longer connected.")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package app.seb3thehacker.gearslip
|
||||||
|
|
||||||
|
import java.net.Socket
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.security.SecureRandom
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
|
import javax.net.ssl.SSLContext
|
||||||
|
import javax.net.ssl.SSLEngine
|
||||||
|
import javax.net.ssl.SSLEngineResult
|
||||||
|
import javax.net.ssl.SSLEngineResult.HandshakeStatus
|
||||||
|
import javax.net.ssl.X509ExtendedTrustManager
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs [PhoneTls] against an in-process TLS client and checks that a full handshake plus a
|
||||||
|
* data round trip works.
|
||||||
|
*
|
||||||
|
* This exists because of one specific hazard: a bug in the certificate, the key manager or
|
||||||
|
* the engine wiring produces a dead connection in the car that is indistinguishable from a
|
||||||
|
* head unit rejecting us - and the whole point of the spike is a trustworthy go/no-go
|
||||||
|
* signal. Run this on the bench first; if it passes, a failure in the car is about the head
|
||||||
|
* unit, not about this code.
|
||||||
|
*/
|
||||||
|
object TlsSelfTest {
|
||||||
|
private val log = GearslipLog.tagged("TLS")
|
||||||
|
|
||||||
|
fun run(identity: CertProvider.Identity): Boolean {
|
||||||
|
log.i("=== TLS self-test starting ===")
|
||||||
|
log.i("cert source: ${identity.source}")
|
||||||
|
log.i(" subject = ${identity.certificate.subjectX500Principal}")
|
||||||
|
log.i(" issuer = ${identity.certificate.issuerX500Principal}")
|
||||||
|
return try {
|
||||||
|
val server = PhoneTls(identity.keyStore, identity.password)
|
||||||
|
val client = Driver(clientEngine())
|
||||||
|
|
||||||
|
var outbound = client.pump(null) // ClientHello
|
||||||
|
var rounds = 0
|
||||||
|
while (rounds++ < 20) {
|
||||||
|
if (outbound.isEmpty()) break
|
||||||
|
val fromServer = mutableListOf<ByteArray>()
|
||||||
|
outbound.forEach { fromServer += server.pumpHandshake(it) }
|
||||||
|
if (server.handshakeComplete && fromServer.isEmpty()) break
|
||||||
|
val fromClient = mutableListOf<ByteArray>()
|
||||||
|
fromServer.forEach { fromClient += client.pump(it) }
|
||||||
|
outbound = fromClient
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!server.handshakeComplete) {
|
||||||
|
log.e("self-test FAILED: handshake did not complete after $rounds rounds")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data round trip, exercising the same encrypt path the spike uses for
|
||||||
|
// ServiceDiscoveryRequest.
|
||||||
|
val plaintext = "gearslip round trip".toByteArray()
|
||||||
|
val decrypted = client.decrypt(server.encrypt(plaintext))
|
||||||
|
if (!decrypted.contentEquals(plaintext)) {
|
||||||
|
log.e("self-test FAILED: round trip mismatch (${decrypted.size} bytes back)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
log.i("=== TLS self-test PASSED - certificate, key manager and engine are sound ===")
|
||||||
|
true
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
log.e("self-test FAILED with an exception", t)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clientEngine(): SSLEngine {
|
||||||
|
val context = SSLContext.getInstance("TLSv1.2")
|
||||||
|
context.init(null, arrayOf(TrustAll()), SecureRandom())
|
||||||
|
return context.createSSLEngine().apply {
|
||||||
|
useClientMode = true
|
||||||
|
enabledProtocols = arrayOf("TLSv1.2")
|
||||||
|
beginHandshake()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deliberately a second, independent implementation - it should not share PhoneTls's bugs. */
|
||||||
|
private class Driver(private val engine: SSLEngine) {
|
||||||
|
private var netIn = ByteArray(0)
|
||||||
|
private var app = ByteBuffer.allocate(32 * 1024)
|
||||||
|
private var net = ByteBuffer.allocate(32 * 1024)
|
||||||
|
|
||||||
|
fun pump(incoming: ByteArray?): List<ByteArray> {
|
||||||
|
if (incoming != null) netIn += incoming
|
||||||
|
val out = mutableListOf<ByteArray>()
|
||||||
|
val source = ByteBuffer.wrap(netIn)
|
||||||
|
loop@ while (true) {
|
||||||
|
when (engine.handshakeStatus) {
|
||||||
|
HandshakeStatus.NEED_TASK -> {
|
||||||
|
var task = engine.delegatedTask
|
||||||
|
while (task != null) { task.run(); task = engine.delegatedTask }
|
||||||
|
}
|
||||||
|
HandshakeStatus.NEED_WRAP -> {
|
||||||
|
net.clear()
|
||||||
|
val r = engine.wrap(ByteBuffer.allocate(0), net)
|
||||||
|
if (r.status == SSLEngineResult.Status.BUFFER_OVERFLOW) {
|
||||||
|
net = ByteBuffer.allocate(net.capacity() * 2); continue@loop
|
||||||
|
}
|
||||||
|
net.flip()
|
||||||
|
if (net.hasRemaining()) out += ByteArray(net.remaining()).also { net.get(it) }
|
||||||
|
if (r.status == SSLEngineResult.Status.CLOSED) break@loop
|
||||||
|
}
|
||||||
|
HandshakeStatus.NEED_UNWRAP -> {
|
||||||
|
app.clear()
|
||||||
|
val r = engine.unwrap(source, app)
|
||||||
|
when (r.status) {
|
||||||
|
SSLEngineResult.Status.BUFFER_UNDERFLOW -> break@loop
|
||||||
|
SSLEngineResult.Status.BUFFER_OVERFLOW -> {
|
||||||
|
app = ByteBuffer.allocate(app.capacity() * 2); continue@loop
|
||||||
|
}
|
||||||
|
SSLEngineResult.Status.CLOSED -> break@loop
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> break@loop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
netIn = ByteArray(source.remaining()).also { source.get(it) }
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decrypt(cipher: ByteArray): ByteArray {
|
||||||
|
val source = ByteBuffer.wrap(cipher)
|
||||||
|
val out = java.io.ByteArrayOutputStream()
|
||||||
|
while (source.hasRemaining()) {
|
||||||
|
app.clear()
|
||||||
|
val r = engine.unwrap(source, app)
|
||||||
|
if (r.status == SSLEngineResult.Status.BUFFER_OVERFLOW) {
|
||||||
|
app = ByteBuffer.allocate(app.capacity() * 2); continue
|
||||||
|
}
|
||||||
|
app.flip()
|
||||||
|
out.write(ByteArray(app.remaining()).also { app.get(it) })
|
||||||
|
if (r.bytesConsumed() == 0 && r.bytesProduced() == 0) break
|
||||||
|
}
|
||||||
|
return out.toByteArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TrustAll : X509ExtendedTrustManager() {
|
||||||
|
override fun checkClientTrusted(c: Array<out X509Certificate>?, a: String?) = Unit
|
||||||
|
override fun checkClientTrusted(c: Array<out X509Certificate>?, a: String?, s: Socket?) = Unit
|
||||||
|
override fun checkClientTrusted(c: Array<out X509Certificate>?, a: String?, e: SSLEngine?) = Unit
|
||||||
|
override fun checkServerTrusted(c: Array<out X509Certificate>?, a: String?) = Unit
|
||||||
|
override fun checkServerTrusted(c: Array<out X509Certificate>?, a: String?, s: Socket?) = Unit
|
||||||
|
override fun checkServerTrusted(c: Array<out X509Certificate>?, a: String?, e: SSLEngine?) = Unit
|
||||||
|
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package app.seb3thehacker.gearslip
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import java.io.File
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
/** Pixels of the video frame the car UI keeps clear on each side (the frame is painted, just not used). */
|
||||||
|
data class Insets(val top: Int = 0, val bottom: Int = 0, val left: Int = 0, val right: Int = 0) {
|
||||||
|
companion object { val NONE = Insets() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-vehicle display tuning. Head units disagree about how they crop, offset and scale the
|
||||||
|
* projected frame, so nothing here is applied unless a profile matches the connected unit.
|
||||||
|
*
|
||||||
|
* Profiles live in `vehicles.json` in the app's external files dir, so they can be edited and
|
||||||
|
* pushed with adb without a rebuild:
|
||||||
|
*
|
||||||
|
* adb push vehicles.json /sdcard/Android/data/app.seb3thehacker.gearslip/files/vehicles.json
|
||||||
|
*
|
||||||
|
* A profile matches when every key in [match] equals the head unit's value (case-insensitive);
|
||||||
|
* keys it leaves out match anything. The first matching profile wins.
|
||||||
|
*/
|
||||||
|
data class VehicleProfile(
|
||||||
|
val name: String,
|
||||||
|
val match: Map<String, String>,
|
||||||
|
/** Preferred video resolution, e.g. "800x480". Null keeps the default choice. */
|
||||||
|
val resolution: String?,
|
||||||
|
val insets: Insets,
|
||||||
|
)
|
||||||
|
|
||||||
|
object VehicleProfiles {
|
||||||
|
|
||||||
|
private const val FILE = "vehicles.json"
|
||||||
|
|
||||||
|
private val DEFAULT_JSON = """
|
||||||
|
{
|
||||||
|
"_comment": "Per-vehicle display tuning. match keys: headUnitName, carModel, carYear, headUnitMake, headUnitModel (see the log line 'head unit: ...'). insets are pixels of the video frame to keep clear.",
|
||||||
|
"vehicles": [
|
||||||
|
{
|
||||||
|
"name": "2018 Dodge Challenger (Uconnect 4)",
|
||||||
|
"match": { "carModel": "Dodge Challenger", "headUnitName": "Uconnect" },
|
||||||
|
"resolution": "800x480",
|
||||||
|
"insets": { "top": 30, "bottom": 30, "left": 0, "right": 0 }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
/** Reads the file, writing the default one first if there is none so there is something to edit. */
|
||||||
|
fun load(context: Context): List<VehicleProfile> {
|
||||||
|
val file = context.getExternalFilesDir(null)?.let { File(it, FILE) } ?: return emptyList()
|
||||||
|
if (!file.isFile) runCatching { file.writeText(DEFAULT_JSON) }
|
||||||
|
return runCatching { parse(file.readText()) }
|
||||||
|
.onFailure { GearslipLog.e("could not read $FILE - ignoring vehicle profiles", it) }
|
||||||
|
.getOrDefault(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes [insets] into the profile called [name] in the file, leaving everything else as it was. */
|
||||||
|
fun saveInsets(context: Context, name: String, insets: Insets): Boolean {
|
||||||
|
val file = context.getExternalFilesDir(null)?.let { File(it, FILE) } ?: return false
|
||||||
|
return runCatching {
|
||||||
|
val root = JSONObject(file.readText())
|
||||||
|
val vehicles = root.getJSONArray("vehicles")
|
||||||
|
var found = false
|
||||||
|
for (i in 0 until vehicles.length()) {
|
||||||
|
val v = vehicles.getJSONObject(i)
|
||||||
|
if (v.optString("name") == name) {
|
||||||
|
v.put(
|
||||||
|
"insets",
|
||||||
|
JSONObject()
|
||||||
|
.put("top", insets.top).put("bottom", insets.bottom)
|
||||||
|
.put("left", insets.left).put("right", insets.right),
|
||||||
|
)
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (found) file.writeText(root.toString(2))
|
||||||
|
found
|
||||||
|
}.onFailure { GearslipLog.e("could not save insets to $FILE", it) }.getOrDefault(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parse(json: String): List<VehicleProfile> {
|
||||||
|
val vehicles: JSONArray = JSONObject(json).optJSONArray("vehicles") ?: return emptyList()
|
||||||
|
return (0 until vehicles.length()).map { i ->
|
||||||
|
val v = vehicles.getJSONObject(i)
|
||||||
|
val match = v.optJSONObject("match")?.let { m -> m.keys().asSequence().associateWith { m.getString(it) } }
|
||||||
|
?: emptyMap()
|
||||||
|
val ins = v.optJSONObject("insets")
|
||||||
|
VehicleProfile(
|
||||||
|
name = v.optString("name", "vehicle $i"),
|
||||||
|
match = match,
|
||||||
|
resolution = v.optString("resolution").ifEmpty { null },
|
||||||
|
insets = if (ins == null) Insets.NONE else Insets(
|
||||||
|
ins.optInt("top"), ins.optInt("bottom"), ins.optInt("left"), ins.optInt("right"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun find(profiles: List<VehicleProfile>, info: ServiceDiscovery.HeadUnitInfo): VehicleProfile? {
|
||||||
|
val values = mapOf(
|
||||||
|
"headUnitName" to info.headUnitName,
|
||||||
|
"carModel" to info.carModel,
|
||||||
|
"carYear" to info.carYear,
|
||||||
|
"headUnitMake" to info.headUnitMake,
|
||||||
|
"headUnitModel" to info.headUnitModel,
|
||||||
|
)
|
||||||
|
return profiles.firstOrNull { p ->
|
||||||
|
p.match.isNotEmpty() && p.match.all { (k, want) -> values[k].equals(want, ignoreCase = true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
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 bitRate = (width.toLong() * height * frameRate * BITS_PER_PIXEL).toInt().coerceIn(MIN_BIT_RATE, MAX_BIT_RATE)
|
||||||
|
fun format(tuned: Boolean) = 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, bitRate)
|
||||||
|
setInteger(MediaFormat.KEY_FRAME_RATE, frameRate)
|
||||||
|
// Drops already ask for a fresh keyframe, so a long interval costs nothing in recovery
|
||||||
|
// and spares the link a big frame every second.
|
||||||
|
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, I_FRAME_INTERVAL_S)
|
||||||
|
setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline)
|
||||||
|
setInteger(MediaFormat.KEY_LEVEL, MediaCodecInfo.CodecProfileLevel.AVCLevel31)
|
||||||
|
if (tuned) {
|
||||||
|
// Hints only: a car screen wants each frame now, not the best frame a moment later.
|
||||||
|
setInteger(MediaFormat.KEY_BITRATE_MODE, MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR)
|
||||||
|
setInteger(MediaFormat.KEY_PRIORITY, 0)
|
||||||
|
setInteger(MediaFormat.KEY_LATENCY, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var encoder = MediaCodec.createEncoderByType(MIME)
|
||||||
|
try {
|
||||||
|
encoder.configure(format(tuned = true), null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Some encoders refuse a hint they don't know. The plain format is what always worked.
|
||||||
|
log.w("encoder refused the tuned format (${e.message}) - using the plain one")
|
||||||
|
encoder.release()
|
||||||
|
encoder = MediaCodec.createEncoderByType(MIME)
|
||||||
|
encoder.configure(format(tuned = false), 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, ${bitRate / 1000} kbps, 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++
|
||||||
|
}
|
||||||
|
|
||||||
|
// A surface-fed encoder has nothing to do between frames, so block until one is ready
|
||||||
|
// instead of sleeping a fixed time: less delay on screen, fewer wakeups for the CPU.
|
||||||
|
val wait = if (mode == Mode.SURFACE) SURFACE_TIMEOUT_US else TIMEOUT_US
|
||||||
|
var outIndex = encoder.dequeueOutputBuffer(info, wait)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode != Mode.SURFACE) 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
|
||||||
|
const val SURFACE_TIMEOUT_US = 100_000L
|
||||||
|
|
||||||
|
/** Enough for sharp text and map lines; 800x480 at 30fps comes out at 4 Mbps. */
|
||||||
|
const val BITS_PER_PIXEL = 0.3
|
||||||
|
const val MIN_BIT_RATE = 4_000_000
|
||||||
|
const val MAX_BIT_RATE = 12_000_000
|
||||||
|
const val I_FRAME_INTERVAL_S = 5
|
||||||
|
|
||||||
|
/** (Y, Cb, Cr) - white, yellow, cyan, green, magenta, red, blue. */
|
||||||
|
val BAR_COLOURS = listOf(
|
||||||
|
Triple(180, 128, 128),
|
||||||
|
Triple(162, 44, 142),
|
||||||
|
Triple(131, 156, 44),
|
||||||
|
Triple(112, 72, 58),
|
||||||
|
Triple(84, 184, 198),
|
||||||
|
Triple(65, 100, 212),
|
||||||
|
Triple(35, 212, 114),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
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.PackageManager
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Android silences the microphone for apps in the background unless a running service
|
||||||
|
// claims it. Claiming it here lets voice replies hear the driver with the phone locked.
|
||||||
|
// Without the permission, claiming it would crash, so it's left off.
|
||||||
|
val micGranted = checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
|
||||||
|
val type = ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION or
|
||||||
|
(if (micGranted) ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE else 0)
|
||||||
|
startForeground(NOTIFICATION_ID, notification(), type)
|
||||||
|
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 * BUFFER_CHUNKS))
|
||||||
|
.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
|
||||||
|
/** About half a second: room to wait out a slow ack from the car without losing sound. */
|
||||||
|
private const val BUFFER_CHUNKS = 24
|
||||||
|
private const val LEVEL_LOG_MS = 3_000L
|
||||||
|
private const val SILENCE_MS = 5_000L
|
||||||
|
|
||||||
|
private var appContext: Context? = null
|
||||||
|
|
||||||
|
/** True between onCreate and onDestroy; lets the activity tell a live capture from a crashed one. */
|
||||||
|
@Volatile var running = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun start(context: Context, resultCode: Int, data: Intent) {
|
||||||
|
appContext = context.applicationContext
|
||||||
|
context.startForegroundService(
|
||||||
|
Intent(context, AudioCaptureService::class.java)
|
||||||
|
.putExtra(EXTRA_RESULT_CODE, resultCode)
|
||||||
|
.putExtra(EXTRA_RESULT_DATA, data),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
val context = appContext ?: return
|
||||||
|
context.startService(Intent(context, AudioCaptureService::class.java).setAction(ACTION_STOP))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
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 ackLock = Object()
|
||||||
|
|
||||||
|
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
|
||||||
|
synchronized(ackLock) {
|
||||||
|
inFlight = maxOf(0, inFlight - maxOf(1, acked))
|
||||||
|
lastAckAt = System.currentTimeMillis()
|
||||||
|
ackLock.notifyAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
synchronized(ackLock) { ackLock.notifyAll() }
|
||||||
|
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
|
||||||
|
// Wait for the car to catch up rather than throw the chunk away: a dropped chunk is a gap
|
||||||
|
// the driver hears, while a short wait is soaked up by the capture buffer behind us.
|
||||||
|
synchronized(ackLock) {
|
||||||
|
while (started && inFlight >= maxUnacked) {
|
||||||
|
val waited = System.currentTimeMillis() - lastAckAt
|
||||||
|
if (waited > STALL_MS) {
|
||||||
|
// A lost ack must not silence the stream for good; after a stall assume the window cleared.
|
||||||
|
GearslipLog.w("audio: no ack for ${STALL_MS}ms with $inFlight in flight - reopening the window")
|
||||||
|
inFlight = 0
|
||||||
|
break
|
||||||
|
}
|
||||||
|
ackLock.wait(STALL_MS - waited + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!started) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
synchronized(ackLock) { inFlight++ }
|
||||||
|
chunks++
|
||||||
|
bytes += size
|
||||||
|
// The first chunk proves the whole path; after that, an occasional line is enough to show
|
||||||
|
// the car is still taking audio and to catch a window that has quietly closed.
|
||||||
|
if (chunks == 1 || chunks % CHUNK_LOG_EVERY == 0) {
|
||||||
|
GearslipLog.i(
|
||||||
|
"audio: -> $chunks chunks / $bytes bytes, inFlight=$inFlight/$maxUnacked, " +
|
||||||
|
"dropped=$dropped, focus=${if (focusGranted) "granted" else "not granted"}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close() {
|
||||||
|
configTimer?.interrupt()
|
||||||
|
configTimer = null
|
||||||
|
stop()
|
||||||
|
ready = false
|
||||||
|
CarAudio.detach(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val MSG_FOCUS_REQUEST = 18
|
||||||
|
const val MSG_FOCUS_RESPONSE = 19
|
||||||
|
private const val MSG_CHANNEL_OPEN_RESPONSE = 8
|
||||||
|
private const val MSG_DATA = 0
|
||||||
|
private const val MSG_SETUP = 32768
|
||||||
|
private const val MSG_START = 32769
|
||||||
|
private const val MSG_STOP = 32770
|
||||||
|
private const val MSG_CONFIG = 32771
|
||||||
|
private const val MSG_ACK = 32772
|
||||||
|
private const val STATUS_READY = 2
|
||||||
|
private const val FOCUS_GAIN = 1
|
||||||
|
private const val FOCUS_RELEASE = 4
|
||||||
|
private const val FOCUS_STATE_GAIN = 1
|
||||||
|
private const val FOCUS_STATE_GAIN_TRANSIENT = 2
|
||||||
|
private const val SESSION_ID = 2
|
||||||
|
private const val STALL_MS = 2_000L
|
||||||
|
private const val CHUNK_LOG_EVERY = 200 // about every four seconds
|
||||||
|
private const val CONFIG_WAIT_MS = 2_500L
|
||||||
|
|
||||||
|
private val FOCUS_STATES = mapOf(
|
||||||
|
1 to "GAIN", 2 to "GAIN_TRANSIENT", 3 to "LOSS", 4 to "LOSS_TRANSIENT",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package app.seb3thehacker.gearslip.audio
|
||||||
|
|
||||||
|
import app.seb3thehacker.gearslip.GearslipLog
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Joins the two ends of the audio path: [AudioCaptureService] on the phone, which hears what the
|
||||||
|
* phone is playing, and the car's audio channel, which is where it has to end up.
|
||||||
|
*
|
||||||
|
* Capture belongs to the car session, not to any one app. Android grants it through a consent
|
||||||
|
* dialog that cannot be answered in advance or remembered, so Gearslip asks once, when a head unit
|
||||||
|
* first offers a sink to play through, and then holds that grant for as long as the car is
|
||||||
|
* connected. Switching between media apps, or starting playback from the phone, costs nothing
|
||||||
|
* further: the capture is by audio usage rather than by app, so whatever the phone plays as media
|
||||||
|
* goes to the car.
|
||||||
|
*/
|
||||||
|
object CarAudio {
|
||||||
|
|
||||||
|
/** What the head unit's media sink accepts, and how to hand it audio. */
|
||||||
|
interface Link {
|
||||||
|
val sampleRate: Int
|
||||||
|
val channels: Int
|
||||||
|
fun start()
|
||||||
|
fun stop()
|
||||||
|
fun write(pcm: ByteArray, size: Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class Capture { OFF, ASKING, ON, DENIED }
|
||||||
|
|
||||||
|
private val _capture = MutableStateFlow(Capture.OFF)
|
||||||
|
val capture: StateFlow<Capture> = _capture.asStateFlow()
|
||||||
|
|
||||||
|
/** "-12 dB" style summary of what was last heard, for the media screen. */
|
||||||
|
private val _level = MutableStateFlow(0f)
|
||||||
|
val level: StateFlow<Float> = _level.asStateFlow()
|
||||||
|
|
||||||
|
/** Set by the activity: shows the permission and consent dialogs, which only it can. */
|
||||||
|
var onConsentRequested: (() -> Unit)? = null
|
||||||
|
|
||||||
|
/** Whether the driver has asked for the car to be fed at all; see CarSettings.pipeAudio. */
|
||||||
|
@Volatile private var wanted = false
|
||||||
|
|
||||||
|
@Volatile private var link: Link? = null
|
||||||
|
|
||||||
|
/** Rate and channel count the capture should use: the car's, or a sensible default. */
|
||||||
|
val sampleRate get() = link?.sampleRate ?: 48_000
|
||||||
|
val channels get() = link?.channels ?: 2
|
||||||
|
|
||||||
|
fun attach(link: Link) {
|
||||||
|
this.link = link
|
||||||
|
GearslipLog.i("audio: the car offered a sink at ${link.sampleRate}Hz x${link.channels}")
|
||||||
|
when {
|
||||||
|
_capture.value == Capture.ON -> link.start()
|
||||||
|
wanted -> ask()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun detach(link: Link) {
|
||||||
|
if (this.link === link) this.link = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The car wants sound. Safe to call as often as the session likes: the consent dialog appears
|
||||||
|
* at most once, and only once there is a sink to play through - asking on the bench, where the
|
||||||
|
* sound has nowhere to go, would put a system dialog in front of the driver for nothing.
|
||||||
|
*/
|
||||||
|
fun request() {
|
||||||
|
wanted = true
|
||||||
|
when (_capture.value) {
|
||||||
|
Capture.ON, Capture.ASKING, Capture.DENIED -> return
|
||||||
|
Capture.OFF -> if (link == null) {
|
||||||
|
GearslipLog.i("audio: waiting for the car to offer an audio sink")
|
||||||
|
} else {
|
||||||
|
ask()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ask() {
|
||||||
|
_capture.value = Capture.ASKING
|
||||||
|
val request = onConsentRequested
|
||||||
|
if (request == null) {
|
||||||
|
GearslipLog.w("audio: nothing on the phone can ask for capture consent")
|
||||||
|
_capture.value = Capture.DENIED
|
||||||
|
} else {
|
||||||
|
request()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The car went away. Everything is torn down; the next connection asks again. */
|
||||||
|
fun release() {
|
||||||
|
wanted = false
|
||||||
|
AudioCaptureService.stop()
|
||||||
|
_capture.value = Capture.OFF
|
||||||
|
}
|
||||||
|
|
||||||
|
fun denied() {
|
||||||
|
_capture.value = Capture.DENIED
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun captureStarted() {
|
||||||
|
_capture.value = Capture.ON
|
||||||
|
link?.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun captureStopped() {
|
||||||
|
link?.stop()
|
||||||
|
_level.value = 0f
|
||||||
|
if (_capture.value == Capture.ON) _capture.value = Capture.OFF
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun deliver(pcm: ByteArray, size: Int, peak: Float) {
|
||||||
|
_level.value = peak
|
||||||
|
link?.write(pcm, size)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package app.seb3thehacker.gearslip.audio
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.provider.Settings
|
||||||
|
import app.seb3thehacker.gearslip.GearslipLog
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the phone's notifications readable while audio is being sent to the car.
|
||||||
|
*
|
||||||
|
* Playback capture hangs off a MediaProjection, and since Android 15 any full-screen projection
|
||||||
|
* makes SystemUI hide notification contents, on the assumption that someone is watching the
|
||||||
|
* screen. Nobody is: the audio projection never makes a display. The one exemption an ordinary app
|
||||||
|
* can reach is the developer setting that switches the protection off, so Gearslip turns it off for
|
||||||
|
* the length of the capture and back on afterwards.
|
||||||
|
*
|
||||||
|
* Writing it needs WRITE_SECURE_SETTINGS, granted once over adb. Without the grant this does
|
||||||
|
* nothing and notifications stay hidden while music plays, which is the platform's default.
|
||||||
|
*
|
||||||
|
* Phone mirroring deliberately does not use this: there the screen really is being shown.
|
||||||
|
*/
|
||||||
|
internal object ScreenShareGuard {
|
||||||
|
|
||||||
|
/** Settings.Global.DISABLE_SCREEN_SHARE_PROTECTIONS_FOR_APPS_AND_NOTIFICATIONS, hidden API. */
|
||||||
|
private const val SETTING = "disable_screen_share_protections_for_apps_and_notifications"
|
||||||
|
private const val PREFS = "gearslip_screen_share_guard"
|
||||||
|
|
||||||
|
/** Set while Gearslip has the protection switched off, so a crash can be undone at next launch. */
|
||||||
|
private const val KEY_LIFTED = "lifted"
|
||||||
|
|
||||||
|
fun canLift(context: Context): Boolean =
|
||||||
|
context.checkSelfPermission(Manifest.permission.WRITE_SECURE_SETTINGS) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
/** Call before the projection starts: the system decides what to hide at that moment. */
|
||||||
|
fun lift(context: Context) {
|
||||||
|
if (!canLift(context)) {
|
||||||
|
GearslipLog.i("audio: notifications will be hidden while capturing (no WRITE_SECURE_SETTINGS)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val resolver = context.contentResolver
|
||||||
|
// Already off, whether by the driver in Developer options or by us: leave it as found.
|
||||||
|
if (Settings.Global.getInt(resolver, SETTING, 0) != 0) return
|
||||||
|
runCatching {
|
||||||
|
prefs(context).edit().putBoolean(KEY_LIFTED, true).commit()
|
||||||
|
Settings.Global.putInt(resolver, SETTING, 1)
|
||||||
|
GearslipLog.i("audio: screen share protection off for the drive, notifications stay visible")
|
||||||
|
}.onFailure { GearslipLog.w("audio: could not lift screen share protection: ${it.message}") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Put the protection back, if Gearslip is the one that took it away. */
|
||||||
|
fun restore(context: Context) {
|
||||||
|
if (!prefs(context).getBoolean(KEY_LIFTED, false)) return
|
||||||
|
runCatching {
|
||||||
|
Settings.Global.putInt(context.contentResolver, SETTING, 0)
|
||||||
|
prefs(context).edit().remove(KEY_LIFTED).commit()
|
||||||
|
GearslipLog.i("audio: screen share protection back on")
|
||||||
|
}.onFailure { GearslipLog.w("audio: could not restore screen share protection: ${it.message}") }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun prefs(context: Context) =
|
||||||
|
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package app.seb3thehacker.gearslip.call
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.provider.ContactsContract
|
||||||
|
import android.telecom.TelecomManager
|
||||||
|
import android.telephony.TelephonyCallback
|
||||||
|
import android.telephony.TelephonyManager
|
||||||
|
import app.seb3thehacker.gearslip.GearslipLog
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
/** A caller as the car shows it: whatever the contacts app knows, or just the number. */
|
||||||
|
data class Caller(val number: String, val name: String?) {
|
||||||
|
val label: String get() = name ?: number.ifBlank { "Unknown number" }
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface CallUiState {
|
||||||
|
data object None : CallUiState
|
||||||
|
data class Ringing(val caller: Caller, val canDecline: Boolean) : CallUiState
|
||||||
|
data class Active(val caller: Caller?) : CallUiState
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks the phone's call state for the car screen, and answers or declines through the two
|
||||||
|
* doors a sideloaded app can use: [android.telecom.TelecomManager.acceptRingingCall] to answer,
|
||||||
|
* and [GearslipCallScreening]'s response to decline one that is still ringing.
|
||||||
|
*
|
||||||
|
* Ending a call already answered needs MODIFY_PHONE_STATE, a privileged permission Gearslip
|
||||||
|
* cannot hold, so a call taken here is finished the way it would be without Gearslip at all -
|
||||||
|
* from the phone itself, a paired headset, or the car's own Bluetooth if it has any. Call audio
|
||||||
|
* stays on the phone's normal voice path too; nothing here sends it over the USB link, which
|
||||||
|
* only ever carries media playback.
|
||||||
|
*/
|
||||||
|
object CarCalls {
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow<CallUiState>(CallUiState.None)
|
||||||
|
val state: StateFlow<CallUiState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private var telephony: TelephonyManager? = null
|
||||||
|
private var watcher: TelephonyCallback? = null
|
||||||
|
private var pendingRespond: ((allow: Boolean) -> Unit)? = null
|
||||||
|
private var ringingCaller: Caller? = null
|
||||||
|
|
||||||
|
fun start(context: Context) {
|
||||||
|
if (watcher != null) return
|
||||||
|
if (context.checkSelfPermission(Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) return
|
||||||
|
val manager = context.getSystemService(TelephonyManager::class.java) ?: return
|
||||||
|
val callback = object : TelephonyCallback(), TelephonyCallback.CallStateListener {
|
||||||
|
override fun onCallStateChanged(state: Int) = onPhoneState(state)
|
||||||
|
}
|
||||||
|
runCatching { manager.registerTelephonyCallback(context.mainExecutor, callback) }
|
||||||
|
.onSuccess { telephony = manager; watcher = callback }
|
||||||
|
.onFailure { GearslipLog.w("calls: could not watch call state: ${it.message}") }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
val callback = watcher ?: return
|
||||||
|
runCatching { telephony?.unregisterTelephonyCallback(callback) }
|
||||||
|
telephony = null
|
||||||
|
watcher = null
|
||||||
|
pendingRespond = null
|
||||||
|
ringingCaller = null
|
||||||
|
_state.value = CallUiState.None
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onPhoneState(state: Int) {
|
||||||
|
when (state) {
|
||||||
|
TelephonyManager.CALL_STATE_IDLE -> {
|
||||||
|
pendingRespond = null
|
||||||
|
ringingCaller = null
|
||||||
|
_state.value = CallUiState.None
|
||||||
|
}
|
||||||
|
TelephonyManager.CALL_STATE_OFFHOOK -> {
|
||||||
|
pendingRespond = null
|
||||||
|
_state.value = CallUiState.Active(ringingCaller)
|
||||||
|
}
|
||||||
|
TelephonyManager.CALL_STATE_RINGING -> {
|
||||||
|
// The number itself only ever arrives through the screening service; if this
|
||||||
|
// fires first, the ringing card appears once incomingCall() catches up.
|
||||||
|
ringingCaller?.let { _state.value = CallUiState.Ringing(it, pendingRespond != null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called by [GearslipCallScreening] the moment a call comes in - the only place with a number. */
|
||||||
|
fun incomingCall(context: Context, number: String?, respond: (allow: Boolean) -> Unit) {
|
||||||
|
val caller = Caller(number.orEmpty(), number?.let { lookUpName(context, it) })
|
||||||
|
ringingCaller = caller
|
||||||
|
pendingRespond = respond
|
||||||
|
_state.value = CallUiState.Ringing(caller, canDecline = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The screening window closed by itself - the call rings normally now, and can't be declined from here. */
|
||||||
|
fun screeningExpired() {
|
||||||
|
pendingRespond = null
|
||||||
|
val caller = ringingCaller ?: return
|
||||||
|
if (_state.value is CallUiState.Ringing) _state.value = CallUiState.Ringing(caller, canDecline = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decline() {
|
||||||
|
val respond = pendingRespond ?: return
|
||||||
|
pendingRespond = null
|
||||||
|
runCatching { respond(false) }.onFailure { GearslipLog.w("calls: decline failed: ${it.message}") }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun answer(context: Context) {
|
||||||
|
if (context.checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
GearslipLog.w("calls: answer needs ANSWER_PHONE_CALLS, which was never granted")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val telecom = context.getSystemService(TelecomManager::class.java) ?: return
|
||||||
|
runCatching { telecom.acceptRingingCall() }
|
||||||
|
.onFailure { GearslipLog.w("calls: answer failed: ${it.message}") }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun lookUpName(context: Context, number: String): String? {
|
||||||
|
if (context.checkSelfPermission(Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) return null
|
||||||
|
return runCatching {
|
||||||
|
val uri = Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI, Uri.encode(number))
|
||||||
|
context.contentResolver.query(uri, arrayOf(ContactsContract.PhoneLookup.DISPLAY_NAME), null, null, null)
|
||||||
|
?.use { c -> if (c.moveToFirst()) c.getString(0) else null }
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Posts to the main thread after a delay - used to give up on a screening response Gearslip never sent. */
|
||||||
|
internal fun mainThreadDelay(ms: Long, action: () -> Unit) {
|
||||||
|
Handler(Looper.getMainLooper()).postDelayed(action, ms)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package app.seb3thehacker.gearslip.call
|
||||||
|
|
||||||
|
import android.telecom.Call
|
||||||
|
import android.telecom.CallScreeningService
|
||||||
|
import app.seb3thehacker.gearslip.GearslipLog
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sees every incoming call before it rings, so the car screen can show who is calling and
|
||||||
|
* decline it in the same window a spam-blocker would use. Inert until the driver sets Gearslip
|
||||||
|
* as the phone's caller ID and spam app - the same on-device consent Truecaller-style apps ask
|
||||||
|
* for, not a privileged permission.
|
||||||
|
*
|
||||||
|
* The system gives an app a few seconds to answer before it lets the call ring on its own, so a
|
||||||
|
* response is sent either when the driver taps a button or when that window is about to close,
|
||||||
|
* whichever comes first. Answering a call that has already passed this point still works, since
|
||||||
|
* [CarCalls.answer] goes through the ordinary ringing-call API rather than through screening.
|
||||||
|
*/
|
||||||
|
class GearslipCallScreening : CallScreeningService() {
|
||||||
|
|
||||||
|
override fun onScreenCall(callDetails: Call.Details) {
|
||||||
|
val number = callDetails.handle?.schemeSpecificPart
|
||||||
|
var responded = false
|
||||||
|
|
||||||
|
val respond: (Boolean) -> Unit = { allow ->
|
||||||
|
if (!responded) {
|
||||||
|
responded = true
|
||||||
|
runCatching {
|
||||||
|
respondToCall(
|
||||||
|
callDetails,
|
||||||
|
CallResponse.Builder()
|
||||||
|
.setDisallowCall(!allow)
|
||||||
|
.setRejectCall(!allow)
|
||||||
|
.setSkipNotification(false)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}.onFailure { GearslipLog.w("calls: screening response failed: ${it.message}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CarCalls.incomingCall(applicationContext, number, respond)
|
||||||
|
|
||||||
|
// Answer for the system if nobody has by the time its own patience runs out, so a call
|
||||||
|
// Gearslip is slow to show never gets silently blocked.
|
||||||
|
mainThreadDelay(SCREENING_WINDOW_MS) {
|
||||||
|
respond(true)
|
||||||
|
CarCalls.screeningExpired()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val SCREENING_WINDOW_MS = 3_500L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import java.text.DateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns what [CarAssistant] heard into an action plus something to say back.
|
||||||
|
*
|
||||||
|
* Deliberately small and keyword-matched rather than run through any NLU or bundled model: a
|
||||||
|
* fixed set of things a driver actually wants hands-free (media, calling, opening an app,
|
||||||
|
* getting back Home), covering the same ground the rest of the car UI already does rather than
|
||||||
|
* trying to be a general assistant.
|
||||||
|
*/
|
||||||
|
object AssistantCommands {
|
||||||
|
|
||||||
|
suspend fun handle(context: Context, navigator: CarNavigator, frame: CarEnvironment.Frame, heardRaw: String): String {
|
||||||
|
val heard = heardRaw.lowercase().trim()
|
||||||
|
|
||||||
|
navigateCommand(context, navigator, frame, heard)?.let { return it }
|
||||||
|
mediaCommand(heard)?.let { return it }
|
||||||
|
navCommand(navigator, heard)?.let { return it }
|
||||||
|
infoCommand(heard)?.let { return it }
|
||||||
|
callCommand(context, heard)?.let { return it }
|
||||||
|
openAppCommand(context, navigator, frame, heard)?.let { return it }
|
||||||
|
|
||||||
|
return "I don't know how to do that yet."
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "navigate to X [with|using|on|via Y]" - Y picks the nav app by (partial) label; without it,
|
||||||
|
* whichever nav app was used last, or the only one installed, or the first one found. Starts
|
||||||
|
* it straight into navigating there via [CarAppConnection]'s `ACTION_NAVIGATE` deep link -
|
||||||
|
* the same one Android Auto's own Assistant integration uses - rather than opening the app
|
||||||
|
* and leaving the driver to type the address in on the car keyboard.
|
||||||
|
*/
|
||||||
|
private suspend fun navigateCommand(
|
||||||
|
context: Context,
|
||||||
|
navigator: CarNavigator,
|
||||||
|
frame: CarEnvironment.Frame,
|
||||||
|
heard: String,
|
||||||
|
): String? {
|
||||||
|
val trigger = NAVIGATE_TRIGGERS.firstOrNull { heard.startsWith(it) } ?: return null
|
||||||
|
var destination = heard.removePrefix(trigger).trim()
|
||||||
|
if (destination.isBlank()) return "Where to?"
|
||||||
|
|
||||||
|
var appName: String? = null
|
||||||
|
for (sep in listOf(" with ", " using ", " on ", " via ")) {
|
||||||
|
val at = destination.lastIndexOf(sep)
|
||||||
|
if (at > 0) {
|
||||||
|
appName = destination.substring(at + sep.length).trim()
|
||||||
|
destination = destination.substring(0, at).trim()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (destination.isBlank()) return "Where to?"
|
||||||
|
|
||||||
|
val entries = LauncherCache.entries ?: withContext(Dispatchers.IO) { LauncherCache.load(context) }
|
||||||
|
val navApps = entries.filter { it.template?.isNavigation == true }
|
||||||
|
if (navApps.isEmpty()) return "You don't have any navigation apps installed."
|
||||||
|
|
||||||
|
val requestedApp = appName
|
||||||
|
val chosen = when {
|
||||||
|
requestedApp != null -> navApps.firstOrNull { it.label.lowercase().contains(requestedApp) }
|
||||||
|
?: return "I couldn't find a navigation app called $requestedApp."
|
||||||
|
navApps.size == 1 -> navApps.first()
|
||||||
|
else -> navApps.firstOrNull { it.componentId == CarSettings.lastNav.value } ?: navApps.first()
|
||||||
|
}
|
||||||
|
val template = chosen.template ?: return "That's not a navigation app."
|
||||||
|
|
||||||
|
CarServices.connectNav(template, frame, destination)
|
||||||
|
navigator.home()
|
||||||
|
return "Navigating to $destination with ${chosen.label}."
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mediaCommand(heard: String): String? {
|
||||||
|
val media = CarServices.media
|
||||||
|
return when {
|
||||||
|
"pause" in heard || heard.startsWith("stop") -> {
|
||||||
|
if (media.now.value.playing) media.togglePlay()
|
||||||
|
"Paused."
|
||||||
|
}
|
||||||
|
"resume" in heard || (heard.startsWith("play") && "playing" !in heard) -> {
|
||||||
|
if (!media.now.value.playing) media.togglePlay()
|
||||||
|
"Playing."
|
||||||
|
}
|
||||||
|
"next" in heard || "skip" in heard -> {
|
||||||
|
media.next()
|
||||||
|
"Skipping."
|
||||||
|
}
|
||||||
|
"previous" in heard || "last song" in heard || "back song" in heard -> {
|
||||||
|
media.previous()
|
||||||
|
"Playing the previous track."
|
||||||
|
}
|
||||||
|
"what" in heard && "playing" in heard -> {
|
||||||
|
val now = media.now.value
|
||||||
|
if (now.isActive) listOf(now.title, now.artist).filter { it.isNotBlank() }.joinToString(" by ")
|
||||||
|
else "Nothing's playing."
|
||||||
|
}
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun navCommand(navigator: CarNavigator, heard: String): String? = when {
|
||||||
|
"go home" in heard || heard == "home" -> {
|
||||||
|
navigator.home()
|
||||||
|
"Heading home."
|
||||||
|
}
|
||||||
|
"open apps" in heard || heard == "apps" || "app list" in heard -> {
|
||||||
|
navigator.apps()
|
||||||
|
"Here are your apps."
|
||||||
|
}
|
||||||
|
"settings" in heard -> {
|
||||||
|
navigator.settings()
|
||||||
|
"Opening settings."
|
||||||
|
}
|
||||||
|
"vehicle data" in heard || "car data" in heard -> {
|
||||||
|
if (CarSettings.experimentalFeaturesEnabled.value && CarSettings.carSensorsEnabled.value) {
|
||||||
|
navigator.vehicleData()
|
||||||
|
"Opening vehicle data."
|
||||||
|
} else {
|
||||||
|
"Vehicle data is off. Turn it on under experimental features in settings."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun infoCommand(heard: String): String? = when {
|
||||||
|
"what time" in heard || "what's the time" in heard ->
|
||||||
|
"It's ${DateFormat.getTimeInstance(DateFormat.SHORT).format(Date())}."
|
||||||
|
"weather" in heard -> {
|
||||||
|
val data = (Weather.state.value as? WeatherState.Ready)?.data
|
||||||
|
if (data != null) "${data.temp}${data.tempUnit} and ${Weather.describe(data.code)} in ${data.place}."
|
||||||
|
else "I don't have the weather yet."
|
||||||
|
}
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun callCommand(context: Context, heard: String): String? {
|
||||||
|
val name = when {
|
||||||
|
heard.startsWith("call ") -> heard.removePrefix("call ")
|
||||||
|
heard.startsWith("phone ") -> heard.removePrefix("phone ")
|
||||||
|
heard.startsWith("dial ") -> heard.removePrefix("dial ")
|
||||||
|
else -> return null
|
||||||
|
}.trim()
|
||||||
|
if (name.isBlank()) return "Who should I call?"
|
||||||
|
if (!hasCallPermissions(context)) return "Gearslip needs the phone and contacts permissions first, from the phone screen."
|
||||||
|
|
||||||
|
val match = withContext(Dispatchers.IO) {
|
||||||
|
loadContacts(context).firstOrNull { it.name.lowercase().contains(name) }
|
||||||
|
} ?: return "I couldn't find $name in your contacts."
|
||||||
|
|
||||||
|
placeCall(context, match.number)
|
||||||
|
return "Calling ${match.name}."
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun openAppCommand(
|
||||||
|
context: Context,
|
||||||
|
navigator: CarNavigator,
|
||||||
|
frame: CarEnvironment.Frame,
|
||||||
|
heard: String,
|
||||||
|
): String? {
|
||||||
|
val name = when {
|
||||||
|
heard.startsWith("open ") -> heard.removePrefix("open ")
|
||||||
|
heard.startsWith("launch ") -> heard.removePrefix("launch ")
|
||||||
|
heard.startsWith("start ") -> heard.removePrefix("start ")
|
||||||
|
else -> return null
|
||||||
|
}.trim()
|
||||||
|
if (name.isBlank()) return null
|
||||||
|
|
||||||
|
val entries = LauncherCache.entries ?: withContext(Dispatchers.IO) { LauncherCache.load(context) }
|
||||||
|
val match = entries.firstOrNull { it.label.lowercase().contains(name) }
|
||||||
|
?: return "I couldn't find an app called $name."
|
||||||
|
|
||||||
|
launchEntry(match, navigator, frame)
|
||||||
|
return "Opening ${match.label}."
|
||||||
|
}
|
||||||
|
|
||||||
|
private val NAVIGATE_TRIGGERS = listOf("navigate to ", "directions to ", "take me to ", "drive to ")
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.*
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.Settings
|
||||||
|
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.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
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.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.res.vectorResource
|
||||||
|
import app.seb3thehacker.gearslip.R
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The band that appears while [CarAssistant] is listening, thinking or speaking - a thin strip
|
||||||
|
* across the top, same idea as [CallOverlay]'s active-call banner, so it never blocks the screen
|
||||||
|
* the driver was already looking at.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AssistantOverlay(modifier: Modifier = Modifier) {
|
||||||
|
val state by CarAssistant.state.collectAsState()
|
||||||
|
if (state.phase == CarAssistant.Phase.IDLE) return
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
PulsingMic(state.phase == CarAssistant.Phase.LISTENING)
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
when (state.phase) {
|
||||||
|
CarAssistant.Phase.LISTENING -> "Listening…"
|
||||||
|
CarAssistant.Phase.THINKING -> "…"
|
||||||
|
CarAssistant.Phase.SPEAKING -> state.reply
|
||||||
|
CarAssistant.Phase.ERROR -> state.reply
|
||||||
|
CarAssistant.Phase.IDLE -> ""
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 2,
|
||||||
|
)
|
||||||
|
if (state.heard.isNotBlank() && state.phase != CarAssistant.Phase.SPEAKING) {
|
||||||
|
Text(
|
||||||
|
"\"${state.heard}\"",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.missingPermission) {
|
||||||
|
GsButton(tone = GsTone.Tonal, onClick = {
|
||||||
|
context.startActivity(
|
||||||
|
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)),
|
||||||
|
)
|
||||||
|
}) { Text("Grant") }
|
||||||
|
}
|
||||||
|
GsIconButton(Icons.Filled.Close, "Dismiss", CarAssistant::cancel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A filled circle with a mic glyph, pulsing gently while actually listening. */
|
||||||
|
@Composable
|
||||||
|
private fun PulsingMic(listening: Boolean, modifier: Modifier = Modifier) {
|
||||||
|
Surface(
|
||||||
|
shape = CircleShape,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
modifier = modifier.size(36.dp),
|
||||||
|
) {
|
||||||
|
Row(horizontalArrangement = Arrangement.Center) {
|
||||||
|
MicGlyph(Modifier.padding(8.dp).size(20.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// `listening` only changes what's drawn elsewhere in the row (the "Listening…" label);
|
||||||
|
// the glyph itself doesn't need to animate for this to read clearly on a glance.
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The assistant's mic, from `R.drawable.mic_24`, tinted with the current content colour. */
|
||||||
|
@Composable
|
||||||
|
fun MicGlyph(modifier: Modifier = Modifier) {
|
||||||
|
Icon(ImageVector.vectorResource(R.drawable.mic_24), contentDescription = "Assistant", modifier = modifier)
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.*
|
||||||
|
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.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.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) {
|
||||||
|
GsIconButton(
|
||||||
|
icon, label, onClick,
|
||||||
|
colors = GsColors(color, Color.White),
|
||||||
|
enabled = enabled,
|
||||||
|
size = 72.dp,
|
||||||
|
iconSize = 30.dp,
|
||||||
|
shape = CircleShape,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(label, style = MaterialTheme.typography.labelLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ActiveCallBanner(s: CallUiState.Active, modifier: Modifier = Modifier) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.padding(horizontal = 20.dp, vertical = 10.dp),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Call, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||||
|
Spacer(Modifier.height(0.dp))
|
||||||
|
Text(
|
||||||
|
" On a call" + (s.caller?.let { " with ${it.label}" } ?: ""),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.*
|
||||||
|
import android.view.SurfaceHolder
|
||||||
|
import android.view.SurfaceView
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||||
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
|
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
||||||
|
import kotlin.math.hypot
|
||||||
|
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.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
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) 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fresh SurfaceView - and so a fresh Surface - for every size, the way Android Auto hands an
|
||||||
|
// app a new surface rather than resizing the old one. A resized SurfaceView keeps the same
|
||||||
|
// BufferQueue, and apps that draw through their own VirtualDisplay build the
|
||||||
|
// new one on it before releasing the old: the old display's disconnect then takes the new one's
|
||||||
|
// connection with it and the map freezes for good. The nav bar hiding for the search keyboard
|
||||||
|
// and the lyrics pane opening both resize this area.
|
||||||
|
androidx.compose.foundation.layout.BoxWithConstraints(modifier) {
|
||||||
|
val epoch by connection.surfaceEpoch.collectAsState()
|
||||||
|
androidx.compose.runtime.key(constraints.maxWidth, constraints.maxHeight, epoch) {
|
||||||
|
SurfaceHost(connection, frame, onSize = { size = it }, Modifier.fillMaxSize())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(connection) { onDispose { connection.detachSurface() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SurfaceHost(
|
||||||
|
connection: CarAppConnection,
|
||||||
|
frame: CarEnvironment.Frame,
|
||||||
|
onSize: (Pair<Int, Int>) -> Unit,
|
||||||
|
modifier: Modifier,
|
||||||
|
) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pointerInput(connection) {
|
||||||
|
// detectTransformGestures has no velocity, so a fling is measured here alongside
|
||||||
|
// it: one finger lifting while still moving lets the map glide on, as on a car.
|
||||||
|
// Velocity is the finger's own, which is GestureDetector's onFling convention.
|
||||||
|
awaitEachGesture {
|
||||||
|
val down = awaitFirstDown(requireUnconsumed = false)
|
||||||
|
val tracker = VelocityTracker()
|
||||||
|
tracker.addPosition(down.uptimeMillis, down.position)
|
||||||
|
var pinched = false
|
||||||
|
while (true) {
|
||||||
|
val event = awaitPointerEvent(PointerEventPass.Final)
|
||||||
|
if (event.changes.size > 1) pinched = true
|
||||||
|
val change = event.changes.firstOrNull { it.id == down.id } ?: break
|
||||||
|
tracker.addPosition(change.uptimeMillis, change.position)
|
||||||
|
if (!change.pressed) {
|
||||||
|
val v = tracker.calculateVelocity()
|
||||||
|
if (!pinched && hypot(v.x, v.y) >= FLING_MIN_PX_S) {
|
||||||
|
connection.surfaceFling(v.x, v.y)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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")
|
||||||
|
onSize(width to height)
|
||||||
|
connection.attachSurface(holder.surface, width, height, frame.densityDpi)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||||||
|
GearslipLog.i("stage: surface destroyed")
|
||||||
|
// Only this view's own surface: a replaced view's teardown can land after
|
||||||
|
// its successor has already lent the app a new one.
|
||||||
|
connection.detachSurface(holder.surface)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@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)
|
||||||
|
}
|
||||||
|
GsButton(onClick = onDisconnect, tone = GsTone.Tonal) { Text("Stop") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Height of [ChromeBar]; the template's own chrome starts below it. */
|
||||||
|
private val CHROME_BAR = 44.dp
|
||||||
|
|
||||||
|
/** Slower than this when the finger lifts and it was a drag that stopped, not a fling. */
|
||||||
|
private const val FLING_MIN_PX_S = 400f
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Call
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material.icons.filled.Share
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import app.seb3thehacker.gearslip.ProjectedContent
|
||||||
|
|
||||||
|
/** One tile in the launcher and the screen it opens. */
|
||||||
|
class CarApp(
|
||||||
|
val id: String,
|
||||||
|
val label: String,
|
||||||
|
val icon: ImageVector,
|
||||||
|
val content: @Composable () -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Built-in apps. Third-party (Android Auto) apps are a later step and will register here too. */
|
||||||
|
object CarApps {
|
||||||
|
|
||||||
|
val all: List<CarApp> = listOf(
|
||||||
|
CarApp("web", "Web", Icons.Filled.Search) {
|
||||||
|
WebApp(content = ProjectedContent.resolve(LocalContext.current))
|
||||||
|
},
|
||||||
|
CarApp("phone", "Screen sharing", Icons.Filled.Share) {
|
||||||
|
PhoneAppsScreen()
|
||||||
|
},
|
||||||
|
CarApp("dialer", "Phone", Icons.Filled.Call) {
|
||||||
|
PhoneDialerScreen()
|
||||||
|
},
|
||||||
|
CarApp("settings", "Settings", Icons.Filled.Settings) {
|
||||||
|
CarSettingsScreen()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
fun find(id: String): CarApp? = all.firstOrNull { it.id == id }
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.speech.RecognitionListener
|
||||||
|
import android.speech.RecognizerIntent
|
||||||
|
import android.speech.SpeechRecognizer
|
||||||
|
import android.speech.tts.TextToSpeech
|
||||||
|
import android.speech.tts.UtteranceProgressListener
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import app.seb3thehacker.gearslip.call.CallUiState
|
||||||
|
import app.seb3thehacker.gearslip.call.CarCalls
|
||||||
|
import app.seb3thehacker.gearslip.speech.SpeechEngine
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A voice assistant: Gearslip's own [SpeechEngine] (whisper.cpp) to listen, the system's
|
||||||
|
* [TextToSpeech] to answer. Listening used to go through the system's [SpeechRecognizer] alone,
|
||||||
|
* but many phones have no recognizer at all (GrapheneOS ships only text-to-speech), so the
|
||||||
|
* system one is now just the fallback for when the bundled engine can't run.
|
||||||
|
*
|
||||||
|
* Listening and speaking live here; deciding what a command *means* is [AssistantCommands], kept
|
||||||
|
* separate because it needs a [CarNavigator] and a frame to act on, neither of which this object
|
||||||
|
* has - see [pendingCommand].
|
||||||
|
*/
|
||||||
|
object CarAssistant {
|
||||||
|
|
||||||
|
enum class Phase { IDLE, LISTENING, THINKING, SPEAKING, ERROR }
|
||||||
|
|
||||||
|
data class State(
|
||||||
|
val phase: Phase = Phase.IDLE,
|
||||||
|
val heard: String = "",
|
||||||
|
val reply: String = "",
|
||||||
|
val missingPermission: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(State())
|
||||||
|
val state: StateFlow<State> = _state
|
||||||
|
|
||||||
|
/** Set once a recognition result comes in; [reply] clears it once handled. */
|
||||||
|
private val _pendingCommand = MutableStateFlow<String?>(null)
|
||||||
|
val pendingCommand: StateFlow<String?> = _pendingCommand
|
||||||
|
|
||||||
|
private var appContext: Context? = null
|
||||||
|
private var recognizer: SpeechRecognizer? = null
|
||||||
|
private var tts: TextToSpeech? = null
|
||||||
|
private var ttsReady = false
|
||||||
|
|
||||||
|
fun init(context: Context) {
|
||||||
|
appContext = context.applicationContext
|
||||||
|
if (tts != null) return
|
||||||
|
tts = TextToSpeech(appContext) { status -> ttsReady = status == TextToSpeech.SUCCESS }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts listening, or does nothing if the assistant is already busy with the last request.
|
||||||
|
* [SpeechRecognizer] must be driven from a thread with a Looper, but this is also called from
|
||||||
|
* the raw wire-protocol worker thread ([GearslipRunner]'s key-event handling for the head
|
||||||
|
* unit's own PTT button) which has none - so this always hops to the main thread itself
|
||||||
|
* rather than asking every caller to remember to.
|
||||||
|
*/
|
||||||
|
fun start() {
|
||||||
|
if (Looper.myLooper() == Looper.getMainLooper()) startOnMainThread() else mainHandler.post(::startOnMainThread)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startOnMainThread() {
|
||||||
|
val context = appContext ?: return
|
||||||
|
if (_state.value.phase == Phase.LISTENING || _state.value.phase == Phase.THINKING) return
|
||||||
|
|
||||||
|
if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.RECORD_AUDIO) !=
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
_state.value = State(Phase.ERROR, reply = "Needs the microphone permission.", missingPermission = true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Android hands every app but the dialer a silent microphone during a call.
|
||||||
|
if (CarCalls.state.value is CallUiState.Active) {
|
||||||
|
_state.value = State(Phase.ERROR, reply = "Voice commands work once the call ends.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tts?.stop()
|
||||||
|
_state.value = State(Phase.LISTENING)
|
||||||
|
listenOnce(
|
||||||
|
onPartial = { text -> _state.value = _state.value.copy(heard = text) },
|
||||||
|
onHeard = { _state.value = _state.value.copy(phase = Phase.THINKING) },
|
||||||
|
onResult = { text ->
|
||||||
|
_state.value = _state.value.copy(phase = Phase.THINKING, heard = text)
|
||||||
|
_pendingCommand.value = text
|
||||||
|
},
|
||||||
|
onError = { message -> _state.value = State(Phase.ERROR, reply = message) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closes the overlay from wherever it's showing: mid-listen, mid-think, or after speaking. */
|
||||||
|
fun cancel() {
|
||||||
|
releaseRecognizer()
|
||||||
|
tts?.stop()
|
||||||
|
_pendingCommand.value = null
|
||||||
|
_state.value = State(Phase.IDLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called once [pendingCommand] has been turned into an answer - from [CarUi], the one place
|
||||||
|
* that actually has a [CarNavigator] to have acted on it with.
|
||||||
|
*/
|
||||||
|
fun reply(text: String) {
|
||||||
|
_pendingCommand.value = null
|
||||||
|
_state.value = _state.value.copy(phase = Phase.SPEAKING, reply = text)
|
||||||
|
|
||||||
|
val engine = tts
|
||||||
|
if (engine == null || !ttsReady || text.isBlank()) {
|
||||||
|
_state.value = _state.value.copy(phase = Phase.IDLE)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
engine.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
|
||||||
|
override fun onStart(utteranceId: String?) {}
|
||||||
|
override fun onDone(utteranceId: String?) {
|
||||||
|
_state.value = _state.value.copy(phase = Phase.IDLE)
|
||||||
|
}
|
||||||
|
@Deprecated("Deprecated in Java, no replacement on this API level")
|
||||||
|
override fun onError(utteranceId: String?) {
|
||||||
|
_state.value = _state.value.copy(phase = Phase.IDLE)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
engine.speak(text, TextToSpeech.QUEUE_FLUSH, null, "gearslip-assistant")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speaks [text] for someone other than the assistant (a message read aloud), then runs
|
||||||
|
* [onDone] on the main thread - straight away if speech isn't available.
|
||||||
|
*/
|
||||||
|
fun say(text: String, onDone: () -> Unit) {
|
||||||
|
val engine = tts
|
||||||
|
if (engine == null || !ttsReady || text.isBlank()) {
|
||||||
|
mainHandler.post(onDone)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
engine.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
|
||||||
|
override fun onStart(utteranceId: String?) {}
|
||||||
|
override fun onDone(utteranceId: String?) { mainHandler.post(onDone) }
|
||||||
|
@Deprecated("Deprecated in Java, no replacement on this API level")
|
||||||
|
override fun onError(utteranceId: String?) { mainHandler.post(onDone) }
|
||||||
|
// Stopped part way: whoever stopped it has already moved on.
|
||||||
|
override fun onStop(utteranceId: String?, interrupted: Boolean) {}
|
||||||
|
})
|
||||||
|
engine.speak(text, TextToSpeech.QUEUE_FLUSH, null, "gearslip-say")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopSpeaking() { tts?.stop() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listens once, the same way the assistant does, and hands back what was said without acting
|
||||||
|
* on it. Callbacks arrive on the main thread; [onError] gets a sentence fit to show.
|
||||||
|
*/
|
||||||
|
fun dictate(
|
||||||
|
onPartial: (String) -> Unit,
|
||||||
|
onResult: (String) -> Unit,
|
||||||
|
onError: (String) -> Unit,
|
||||||
|
cue: Boolean = true,
|
||||||
|
onSpeaking: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
cancel()
|
||||||
|
listenOnce(onPartial, onHeard = {}, onResult, onError, cue, onSpeaking)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One phrase, through Gearslip's own [SpeechEngine]. The phone's recognizer is only a fallback
|
||||||
|
* for when the engine itself can't run, since plenty of phones (GrapheneOS among them) have none.
|
||||||
|
*/
|
||||||
|
private fun listenOnce(
|
||||||
|
onPartial: (String) -> Unit,
|
||||||
|
onHeard: () -> Unit,
|
||||||
|
onResult: (String) -> Unit,
|
||||||
|
onError: (String) -> Unit,
|
||||||
|
cue: Boolean = true,
|
||||||
|
onSpeaking: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
val context = appContext ?: return onError("Speech isn't ready yet.")
|
||||||
|
SpeechEngine.listen(
|
||||||
|
context,
|
||||||
|
cue = cue,
|
||||||
|
onSpeaking = onSpeaking,
|
||||||
|
onHeard = onHeard,
|
||||||
|
onResult = onResult,
|
||||||
|
onError = { message ->
|
||||||
|
if (message == SpeechEngine.NOT_LOADED && SpeechRecognizer.isRecognitionAvailable(context)) {
|
||||||
|
systemListen(context, onPartial, onHeard, onResult, onError)
|
||||||
|
} else {
|
||||||
|
onError(message)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun systemListen(
|
||||||
|
context: Context,
|
||||||
|
onPartial: (String) -> Unit,
|
||||||
|
onHeard: () -> Unit,
|
||||||
|
onResult: (String) -> Unit,
|
||||||
|
onError: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
val r = SpeechRecognizer.createSpeechRecognizer(context)
|
||||||
|
recognizer = r
|
||||||
|
r.setRecognitionListener(object : RecognitionListener {
|
||||||
|
override fun onReadyForSpeech(params: Bundle?) {}
|
||||||
|
override fun onBeginningOfSpeech() {}
|
||||||
|
override fun onRmsChanged(rmsdB: Float) {}
|
||||||
|
override fun onBufferReceived(buffer: ByteArray?) {}
|
||||||
|
override fun onEndOfSpeech() = onHeard()
|
||||||
|
override fun onEvent(eventType: Int, params: Bundle?) {}
|
||||||
|
|
||||||
|
override fun onError(error: Int) {
|
||||||
|
releaseRecognizer()
|
||||||
|
onError(errorMessage(error))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResults(results: Bundle?) {
|
||||||
|
releaseRecognizer()
|
||||||
|
val text = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)?.firstOrNull().orEmpty()
|
||||||
|
if (text.isBlank()) onError("Didn't catch that.") else onResult(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPartialResults(partialResults: Bundle?) {
|
||||||
|
partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)?.firstOrNull()?.let(onPartial)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
r.startListening(
|
||||||
|
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
||||||
|
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
|
||||||
|
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
|
||||||
|
putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, context.packageName)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopListening() = releaseRecognizer()
|
||||||
|
|
||||||
|
private fun releaseRecognizer() {
|
||||||
|
SpeechEngine.cancel()
|
||||||
|
recognizer?.let { runCatching { it.cancel() }; it.destroy() }
|
||||||
|
recognizer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun errorMessage(code: Int): String = when (code) {
|
||||||
|
SpeechRecognizer.ERROR_NO_MATCH, SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "Didn't catch that."
|
||||||
|
SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "Needs the microphone permission."
|
||||||
|
SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "The speech recognizer is busy."
|
||||||
|
SpeechRecognizer.ERROR_NETWORK, SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "The speech recognizer needs a network it doesn't have."
|
||||||
|
else -> "Speech recognition failed."
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shutdown() {
|
||||||
|
releaseRecognizer()
|
||||||
|
tts?.shutdown()
|
||||||
|
tts = null
|
||||||
|
ttsReady = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
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 app.seb3thehacker.gearslip.car.theme.GsColors
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsIconBox
|
||||||
|
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 current weather, the week's calendar and the phone's
|
||||||
|
* notifications, at a glance. The first two
|
||||||
|
* 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)
|
||||||
|
NotificationsSection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WeatherCard(context: Context) {
|
||||||
|
val state by Weather.state.collectAsState()
|
||||||
|
val navigator = LocalCarNavigator.current
|
||||||
|
LaunchedEffect(Unit) { Weather.refresh(context) }
|
||||||
|
|
||||||
|
GsIconBox(
|
||||||
|
onClick = { navigator.weather() },
|
||||||
|
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface),
|
||||||
|
shape = MaterialTheme.shapes.extraLarge,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(20.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
when (val s = state) {
|
||||||
|
WeatherState.Loading -> Text("Getting weather…", style = MaterialTheme.typography.bodyLarge)
|
||||||
|
is WeatherState.Unavailable -> Text(
|
||||||
|
s.reason, style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
is WeatherState.Ready -> {
|
||||||
|
val w = s.data
|
||||||
|
WeatherIcon(w.code, w.isDay, size = 44.dp)
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
"${w.temp}${w.tempUnit} · ${Weather.describe(w.code)}",
|
||||||
|
style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Text(w.place, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AgendaCard(context: Context) {
|
||||||
|
val events by produceState<List<AgendaEvent>?>(cachedAgenda) {
|
||||||
|
value = withContext(Dispatchers.IO) { fetchAgenda(context) }.also { cachedAgenda = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text("Coming up", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||||
|
val list = events
|
||||||
|
when {
|
||||||
|
list == null -> Text("Loading calendar…", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
list.isEmpty() -> Text(
|
||||||
|
"Nothing in the next week.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
else -> list.forEach { EventRow(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EventRow(event: AgendaEvent) {
|
||||||
|
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.large) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
eventWhen(event),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
event.title,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Today" events show just a time; later days get a weekday so a week's list stays readable. */
|
||||||
|
private fun eventWhen(event: AgendaEvent): String {
|
||||||
|
val zone = if (event.allDay) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||||
|
val start = Calendar.getInstance(zone).apply { timeInMillis = event.startMillis }
|
||||||
|
val today = Calendar.getInstance()
|
||||||
|
val sameDay = start.get(Calendar.YEAR) == today.get(Calendar.YEAR) &&
|
||||||
|
start.get(Calendar.DAY_OF_YEAR) == today.get(Calendar.DAY_OF_YEAR)
|
||||||
|
val time = if (event.allDay) "All day" else DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(event.startMillis))
|
||||||
|
if (sameDay || (event.allDay && start.before(today))) return time
|
||||||
|
return SimpleDateFormat("EEE", Locale.getDefault()).apply { timeZone = zone }.format(Date(event.startMillis)) + " " + time
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class AgendaEvent(val title: String, val startMillis: Long, val allDay: Boolean)
|
||||||
|
|
||||||
|
@Volatile private var cachedAgenda: List<AgendaEvent>? = null
|
||||||
|
|
||||||
|
/** Loads the weather and the agenda ahead of time, so the dashboard opens with both in place. */
|
||||||
|
fun warmDashboard(context: Context) {
|
||||||
|
Weather.refresh(context, force = true)
|
||||||
|
cachedAgenda = fetchAgenda(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty list means no permission or nothing scheduled. Looks from the start of today so an
|
||||||
|
* event already under way still shows, a week ahead so tomorrow's does too, and skips ones
|
||||||
|
* that have finished.
|
||||||
|
*/
|
||||||
|
private fun fetchAgenda(context: Context): List<AgendaEvent> {
|
||||||
|
if (context.checkSelfPermission(Manifest.permission.READ_CALENDAR) != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val startOfToday = Calendar.getInstance().apply {
|
||||||
|
set(Calendar.HOUR_OF_DAY, 0); set(Calendar.MINUTE, 0); set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0)
|
||||||
|
}.timeInMillis
|
||||||
|
val end = startOfToday + 7 * 24 * 60 * 60 * 1000L
|
||||||
|
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon()
|
||||||
|
.appendPath(startOfToday.toString())
|
||||||
|
.appendPath(end.toString())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val events = mutableListOf<AgendaEvent>()
|
||||||
|
val projection = arrayOf(
|
||||||
|
CalendarContract.Instances.TITLE, CalendarContract.Instances.BEGIN,
|
||||||
|
CalendarContract.Instances.END, CalendarContract.Instances.ALL_DAY,
|
||||||
|
)
|
||||||
|
runCatching {
|
||||||
|
context.contentResolver.query(uri, projection, null, null, "${CalendarContract.Instances.BEGIN} ASC")
|
||||||
|
?.use { cursor: Cursor ->
|
||||||
|
while (cursor.moveToNext() && events.size < 10) {
|
||||||
|
val allDay = cursor.getInt(3) == 1
|
||||||
|
// All-day events are stored in UTC midnight, so "ended" means the day is over, not the instant.
|
||||||
|
if (!allDay && cursor.getLong(2) < now) continue
|
||||||
|
events.add(AgendaEvent(cursor.getString(0) ?: "(untitled)", cursor.getLong(1), allDay))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFailure { GearslipLog.w("calendar query failed: ${it.message}") }
|
||||||
|
return events
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import android.os.BatteryManager
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import java.util.Calendar
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the car UI and the apps inside it can know about the world outside the UI.
|
||||||
|
*
|
||||||
|
* [phoneDark] is the phone's own theme and picks the car UI's colours. [darkOutside] is a
|
||||||
|
* separate signal, "is it dark out", for apps that want to adapt (a map, a web page).
|
||||||
|
*
|
||||||
|
* Where [darkOutside] comes from is deliberately swappable. Today it is a time-of-day guess.
|
||||||
|
* The right source is the head unit's own night-mode sensor, which arrives on the AA sensor
|
||||||
|
* channel once that exists; feed it in through [setSensorNight] and it takes over.
|
||||||
|
*/
|
||||||
|
object CarEnvironment {
|
||||||
|
|
||||||
|
data class Battery(val percent: Int, val charging: Boolean)
|
||||||
|
|
||||||
|
private val phoneDarkFlow = MutableStateFlow(false)
|
||||||
|
private val darkOutsideFlow = MutableStateFlow(false)
|
||||||
|
private val batteryFlow = MutableStateFlow(Battery(-1, false))
|
||||||
|
private val displayFlow = MutableStateFlow("")
|
||||||
|
|
||||||
|
/** The projected frame's real geometry, for anything that has to lay out against it. */
|
||||||
|
data class Frame(val width: Int, val height: Int, val densityDpi: Int)
|
||||||
|
|
||||||
|
private val frameFlow = MutableStateFlow(Frame(800, 480, 160))
|
||||||
|
val frame: StateFlow<Frame> = frameFlow
|
||||||
|
|
||||||
|
val phoneDark: StateFlow<Boolean> = phoneDarkFlow
|
||||||
|
val darkOutside: StateFlow<Boolean> = darkOutsideFlow
|
||||||
|
val battery: StateFlow<Battery> = batteryFlow
|
||||||
|
|
||||||
|
/** Human-readable projected resolution, for the connection info in car Settings. */
|
||||||
|
val display: StateFlow<String> = displayFlow
|
||||||
|
|
||||||
|
private val vehicleFlow = MutableStateFlow("")
|
||||||
|
private val insetsFlow = MutableStateFlow(app.seb3thehacker.gearslip.Insets.NONE)
|
||||||
|
|
||||||
|
/** Name of the matched vehicle profile, for the connection info in car Settings. */
|
||||||
|
val vehicle: StateFlow<String> = vehicleFlow
|
||||||
|
|
||||||
|
/** How much of the video frame the car UI keeps clear, from the matched vehicle profile. */
|
||||||
|
val insets: StateFlow<app.seb3thehacker.gearslip.Insets> = insetsFlow
|
||||||
|
|
||||||
|
/** Live adjustment from car Settings; not persisted until saved to the vehicle profile. */
|
||||||
|
fun setInsets(insets: app.seb3thehacker.gearslip.Insets) {
|
||||||
|
insetsFlow.value = insets
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setVehicle(name: String, insets: app.seb3thehacker.gearslip.Insets) {
|
||||||
|
vehicleFlow.value = name
|
||||||
|
insetsFlow.value = insets
|
||||||
|
}
|
||||||
|
|
||||||
|
@Volatile private var sensorNight: Boolean? = null
|
||||||
|
private var appContext: Context? = null
|
||||||
|
private var running = false
|
||||||
|
|
||||||
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
|
private val tick = object : Runnable {
|
||||||
|
override fun run() {
|
||||||
|
refresh()
|
||||||
|
handler.postDelayed(this, 60_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val batteryReceiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
|
intent?.let(::readBattery)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPhoneTheme(configuration: Configuration) {
|
||||||
|
phoneDarkFlow.value =
|
||||||
|
(configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
||||||
|
}
|
||||||
|
|
||||||
|
/** For the AA sensor channel: null goes back to the time-of-day guess. */
|
||||||
|
fun setSensorNight(night: Boolean?) {
|
||||||
|
sensorNight = night
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDisplay(width: Int, height: Int, densityDpi: Int) {
|
||||||
|
displayFlow.value = "${width}x$height @ ${densityDpi}dpi"
|
||||||
|
frameFlow.value = Frame(width, height, densityDpi)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun start(context: Context) {
|
||||||
|
if (running) return
|
||||||
|
running = true
|
||||||
|
appContext = context.applicationContext
|
||||||
|
setPhoneTheme(context.resources.configuration)
|
||||||
|
val sticky = context.applicationContext.registerReceiver(
|
||||||
|
batteryReceiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED), Context.RECEIVER_NOT_EXPORTED,
|
||||||
|
)
|
||||||
|
sticky?.let(::readBattery)
|
||||||
|
handler.post(tick)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
if (!running) return
|
||||||
|
running = false
|
||||||
|
handler.removeCallbacks(tick)
|
||||||
|
runCatching { appContext?.unregisterReceiver(batteryReceiver) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refresh() {
|
||||||
|
darkOutsideFlow.value = when (CarSettings.nightMode.value) {
|
||||||
|
NightMode.DAY -> false
|
||||||
|
NightMode.NIGHT -> true
|
||||||
|
NightMode.AUTO -> sensorNight ?: guessNightFromClock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun guessNightFromClock(): Boolean {
|
||||||
|
val hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY)
|
||||||
|
return hour >= 20 || hour < 6
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readBattery(intent: Intent) {
|
||||||
|
val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
|
||||||
|
val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, 100)
|
||||||
|
val status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1)
|
||||||
|
val charging = status == BatteryManager.BATTERY_STATUS_CHARGING ||
|
||||||
|
status == BatteryManager.BATTERY_STATUS_FULL
|
||||||
|
batteryFlow.value = Battery(if (level >= 0 && scale > 0) level * 100 / scale else -1, charging)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.*
|
||||||
|
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.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material.icons.filled.Favorite
|
||||||
|
import androidx.compose.material.icons.filled.FavoriteBorder
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.res.vectorResource
|
||||||
|
import android.support.v4.media.session.PlaybackStateCompat
|
||||||
|
import app.seb3thehacker.gearslip.R
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import android.media.session.PlaybackState
|
||||||
|
import app.seb3thehacker.gearslip.media.MediaArt
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.luminance
|
||||||
|
import app.seb3thehacker.gearslip.media.ArtColor
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
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.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.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, the docked player on the right when asked for.
|
||||||
|
*
|
||||||
|
* The map is whatever navigation app was used last, brought back on its own. The player sits in
|
||||||
|
* the nav bar's pill until its dock button moves it here; the column 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 panel = navigator.sidePanel
|
||||||
|
|
||||||
|
// Coming back to the map from another screen: the map stayed loaded underneath, so its surface
|
||||||
|
// is only replaced if it died meanwhile. The first composition is skipped, since the surface
|
||||||
|
// is only just being lent.
|
||||||
|
val onHome = navigator.current == CarScreen.Home
|
||||||
|
val seenHome = remember { booleanArrayOf(false) }
|
||||||
|
LaunchedEffect(onHome) {
|
||||||
|
if (onHome && seenHome[0]) CarServices.nav.replaceDeadSurface()
|
||||||
|
if (onHome) seenHome[0] = true
|
||||||
|
}
|
||||||
|
val splitScreen = phase == CarMedia.Phase.READY && now.isActive && panel != SidePanel.NONE
|
||||||
|
|
||||||
|
// No outer padding on any side, split screen or not - only the gap between the two panes
|
||||||
|
// when the player is sharing the screen with the map.
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxSize(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.weight(if (splitScreen) 2.1f else 1f)
|
||||||
|
.fillMaxHeight(),
|
||||||
|
) {
|
||||||
|
MapPane(navStatus, frame)
|
||||||
|
}
|
||||||
|
if (splitScreen) {
|
||||||
|
val side = Modifier.weight(1f).fillMaxHeight()
|
||||||
|
if (panel == SidePanel.LYRICS) SideLyrics(now, side) else DockedPlayer(now, side)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 hasNavApps by CarServices.hasNavApps.collectAsStateWithLifecycle()
|
||||||
|
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) }
|
||||||
|
GsButton(onClick = navigator::apps, tone = GsTone.Tonal) { Text("Choose another app") }
|
||||||
|
}
|
||||||
|
else -> if (!hasNavApps) {
|
||||||
|
// Nothing to auto-select and nothing to pick from Car apps either - this is the
|
||||||
|
// only case worth a distinct screen, since every other IDLE case resolves itself.
|
||||||
|
Text("No mapping apps found.", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
"Install a navigation app that supports Android Auto and it will start here on its own.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
} else if (hasLast) {
|
||||||
|
// 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("Map stopped.", style = MaterialTheme.typography.titleMedium)
|
||||||
|
GsButton(onClick = { scope.launch { CarServices.reconnectNav(frame) } }, tone = GsTone.Tonal) { Text("Reopen") }
|
||||||
|
GsButton(onClick = { navigator.apps() }, tone = GsTone.Tonal) { Text("Car apps") }
|
||||||
|
} else {
|
||||||
|
// Autostart is still picking one - a navigation app is installed, so this resolves
|
||||||
|
// to a "Starting..." state itself, in the next frame or two.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The player, docked beside the map: art, title, progress, transport, then like, shuffle, repeat
|
||||||
|
* and lyrics. While it's here the nav bar's pill steps aside on Home, so the controls never show
|
||||||
|
* twice. The X sends it back to the nav bar.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun DockedPlayer(now: NowPlaying, modifier: Modifier) {
|
||||||
|
val navigator = LocalCarNavigator.current
|
||||||
|
val media = CarServices.media
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
|
||||||
|
|
||||||
|
Surface(modifier = modifier, color = rememberArtColor(art, MaterialTheme.colorScheme.surfaceVariant)) {
|
||||||
|
BoxWithConstraints(Modifier.fillMaxSize().padding(12.dp)) {
|
||||||
|
// Whatever height the controls below leave over, so the art grows on a taller
|
||||||
|
// screen and shrinks rather than pushing the buttons off a short one.
|
||||||
|
val artSize = minOf(maxWidth - 44.dp, maxHeight - 250.dp).coerceIn(56.dp, 200.dp)
|
||||||
|
Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Box(Modifier.fillMaxWidth()) {
|
||||||
|
// Art opens the full media screen, same as on the pill.
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.size(artSize)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(scheme.surface)
|
||||||
|
.clickable { navigator.media() },
|
||||||
|
) {
|
||||||
|
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
||||||
|
}
|
||||||
|
GsIconButton(
|
||||||
|
Icons.Filled.Close, "Back to the nav bar", navigator::closeSidePanel,
|
||||||
|
Modifier.align(Alignment.TopEnd), size = 36.dp, iconSize = 20.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
Text(
|
||||||
|
now.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
now.artist, style = MaterialTheme.typography.bodyMedium, color = scheme.onSurfaceVariant,
|
||||||
|
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
if (now.durationMs > 0) {
|
||||||
|
PlayerProgress(now, media, Modifier.fillMaxWidth().padding(vertical = 2.dp), showTimes = false)
|
||||||
|
} else {
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
}
|
||||||
|
TransportRow(now, media, skip = 48.dp, modifier = Modifier.fillMaxWidth())
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
PlayModeRow(now, media, size = 42.dp, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
GsIconButton(
|
||||||
|
ImageVector.vectorResource(R.drawable.lyrics_24), "Lyrics", navigator::showSideLyrics,
|
||||||
|
size = 42.dp, iconSize = 22.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lyrics alone beside the map, big enough to read at a glance. No art: that space goes to a
|
||||||
|
* larger title and artist instead. The player goes back to the nav bar meanwhile, whose dock
|
||||||
|
* button swaps these back out for the controls.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SideLyrics(now: NowPlaying, modifier: Modifier) {
|
||||||
|
val navigator = LocalCarNavigator.current
|
||||||
|
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, color = rememberArtColor(art, MaterialTheme.colorScheme.surfaceVariant)) {
|
||||||
|
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.Top) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
now.title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
now.artist, style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
GsIconButton(Icons.Filled.Close, "Hide lyrics", navigator::closeSidePanel, size = 36.dp, iconSize = 20.dp)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
LyricsPanel(now, Modifier.weight(1f).fillMaxWidth(), lineStyle = MaterialTheme.typography.headlineSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.*
|
||||||
|
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.Close
|
||||||
|
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.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.vector.ImageVector
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a [CarKeyboard] is on screen right now, so [CarUi] can give it the row of height the
|
||||||
|
* bottom nav bar normally holds instead of squeezing it in above - a driver typing has no use
|
||||||
|
* for Home/Apps/Settings shortcuts anyway, and the screen is small enough that the space matters.
|
||||||
|
*/
|
||||||
|
object CarKeyboardVisibility {
|
||||||
|
val state = mutableStateOf(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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. Laid out like a PC's QWERTY keyboard rather than a phone's: shift and
|
||||||
|
* backspace flank the letter block (top-right and bottom-left of it, same as a physical board),
|
||||||
|
* not the space bar, so a fumbled reach for space never lands on delete instead.
|
||||||
|
*
|
||||||
|
* [onDismiss] is null wherever there's no keyboard-shown state independent of the screen itself
|
||||||
|
* (a search template, sign-in) - there, the header's own back action is the only way out and no
|
||||||
|
* dismiss button is drawn.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CarKeyboard(
|
||||||
|
text: String,
|
||||||
|
onTextChange: (String) -> Unit,
|
||||||
|
onSubmit: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
submitIcon: Boolean = true,
|
||||||
|
submitImage: ImageVector? = null,
|
||||||
|
onDismiss: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
var symbols by remember { mutableStateOf(false) }
|
||||||
|
var shifted by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
CarKeyboardVisibility.state.value = true
|
||||||
|
onDispose { CarKeyboardVisibility.state.value = 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.dropLast(1).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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bottom letter row: shift and backspace bookend it, exactly where a physical
|
||||||
|
// keyboard puts them relative to the letter block, not down beside the space bar.
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
if (!symbols) {
|
||||||
|
Key(if (shifted) "SHIFT" else "shift", Modifier.weight(1.6f)) { shifted = !shifted }
|
||||||
|
}
|
||||||
|
rows.last().forEach { key ->
|
||||||
|
val label = if (shifted && !symbols) key.uppercase() else key
|
||||||
|
Key(label, Modifier.weight(1f)) {
|
||||||
|
onTextChange(text + label)
|
||||||
|
shifted = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Key("⌫", Modifier.weight(1.6f)) {
|
||||||
|
if (text.isNotEmpty()) onTextChange(text.dropLast(1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Control row: no backspace here - it stays with the letters above, not next to the
|
||||||
|
// key most likely to be hit right after it by mistake. Dismiss (when offered) is the
|
||||||
|
// last key, bottom-right corner of the keyboard.
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Key(if (symbols) "abc" else "?123", Modifier.weight(1.4f)) { symbols = !symbols }
|
||||||
|
Key("space", Modifier.weight(if (onDismiss != null) 3.2f else 4f)) { onTextChange("$text ") }
|
||||||
|
SubmitKey(Modifier.weight(1.6f), submitIcon, submitImage, onSubmit)
|
||||||
|
if (onDismiss != null) {
|
||||||
|
DismissKey(onDismiss, Modifier.weight(1.4f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Key(label: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||||
|
GsIconBox(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier.height(KEY_HEIGHT),
|
||||||
|
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DismissKey(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
|
GsIconBox(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier.height(KEY_HEIGHT),
|
||||||
|
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurfaceVariant),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Close, contentDescription = "Hide keyboard")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SubmitKey(modifier: Modifier, search: Boolean, image: ImageVector?, onClick: () -> Unit) {
|
||||||
|
GsIconBox(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier.height(KEY_HEIGHT),
|
||||||
|
colors = GsColors(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.onPrimary),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
image ?: if (search) Icons.Filled.Search else Icons.Filled.ArrowBack,
|
||||||
|
contentDescription = "Submit",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Big enough to hit on a bumpy road, small enough that four rows plus a field plus a few
|
||||||
|
* results all fit a 480px-tall screen - which is the real constraint here, not the key size.
|
||||||
|
*/
|
||||||
|
private val KEY_HEIGHT = 34.dp
|
||||||
|
|
||||||
|
private val LETTER_ROWS = listOf(
|
||||||
|
"qwertyuiop".map { it.toString() },
|
||||||
|
"asdfghjkl".map { it.toString() },
|
||||||
|
"zxcvbnm".map { it.toString() },
|
||||||
|
)
|
||||||
|
|
||||||
|
private val SYMBOL_ROWS = listOf(
|
||||||
|
"1234567890".map { it.toString() },
|
||||||
|
listOf("-", "/", ":", ";", "(", ")", "$", "&", "@", "\""),
|
||||||
|
listOf(".", ",", "?", "!", "'", "#", "%", "*", "+", "="),
|
||||||
|
)
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import app.seb3thehacker.gearslip.GearslipLog
|
||||||
|
import app.seb3thehacker.gearslip.call.CallUiState
|
||||||
|
import app.seb3thehacker.gearslip.call.CarCalls
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The car's own buttons: steering wheel, rotary knob, D-pad and the hard keys around the screen.
|
||||||
|
*
|
||||||
|
* The head unit sends Android keycodes as they are, so the numbers here are android.view.KeyEvent's.
|
||||||
|
* Media, voice and call keys act straight away. Keys that move around the car UI (back, home, the
|
||||||
|
* knob) need a [CarNavigator], which only [CarUi] has, so they go out on [screenKeys]. Focus keys
|
||||||
|
* (D-pad, knob turns, OK) become real key events on the car's display through [focusSink], where
|
||||||
|
* Compose moves focus between buttons the same way it would with a keyboard.
|
||||||
|
*/
|
||||||
|
object CarKeys {
|
||||||
|
|
||||||
|
/** A key that changes which screen is showing. */
|
||||||
|
enum class ScreenKey { BACK, HOME, MAP, MEDIA, PHONE }
|
||||||
|
|
||||||
|
private val _screenKeys = MutableSharedFlow<ScreenKey>(extraBufferCapacity = 8)
|
||||||
|
val screenKeys: SharedFlow<ScreenKey> = _screenKeys
|
||||||
|
|
||||||
|
/** Delivers an Android key event to the car display; set while the car UI is showing. */
|
||||||
|
@Volatile var focusSink: ((keyCode: Int, down: Boolean) -> Unit)? = null
|
||||||
|
|
||||||
|
private var appContext: Context? = null
|
||||||
|
private val main = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
|
fun init(context: Context) { appContext = context.applicationContext }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every key worth asking the head unit for. The binding request lists what the head unit
|
||||||
|
* offered; when it offers nothing (some units leave the list empty and send keys anyway),
|
||||||
|
* this is what's asked for instead.
|
||||||
|
*/
|
||||||
|
val WANTED = listOf(
|
||||||
|
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_MEDIA_PAUSE,
|
||||||
|
KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_MEDIA_PREVIOUS, KeyEvent.KEYCODE_MEDIA_STOP,
|
||||||
|
KeyEvent.KEYCODE_SEARCH, KeyEvent.KEYCODE_VOICE_ASSIST,
|
||||||
|
KeyEvent.KEYCODE_CALL, KeyEvent.KEYCODE_ENDCALL,
|
||||||
|
KeyEvent.KEYCODE_BACK, KeyEvent.KEYCODE_HOME,
|
||||||
|
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_LEFT,
|
||||||
|
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER,
|
||||||
|
KEYCODE_ROTARY_CONTROLLER, KEYCODE_MEDIA, KEYCODE_NAVIGATION, KEYCODE_TEL,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One key edge from the head unit. Most keys act on the press; focus keys pass both edges on,
|
||||||
|
* so a held D-pad key repeats the way it would anywhere else. Returns false for a key nothing
|
||||||
|
* here knows, for the caller to log.
|
||||||
|
*/
|
||||||
|
fun onKey(keyCode: Int, down: Boolean, longPress: Boolean): Boolean {
|
||||||
|
if (keyCode in FOCUS_KEYS) {
|
||||||
|
focusSink?.invoke(keyCode, down)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (!down) return keyCode in WANTED
|
||||||
|
val media = CarServices.media
|
||||||
|
when (keyCode) {
|
||||||
|
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> media.togglePlay()
|
||||||
|
KeyEvent.KEYCODE_MEDIA_PLAY -> if (!media.now.value.playing) media.togglePlay()
|
||||||
|
KeyEvent.KEYCODE_MEDIA_PAUSE, KeyEvent.KEYCODE_MEDIA_STOP -> if (media.now.value.playing) media.togglePlay()
|
||||||
|
KeyEvent.KEYCODE_MEDIA_NEXT -> media.next()
|
||||||
|
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> media.previous()
|
||||||
|
KeyEvent.KEYCODE_SEARCH, KeyEvent.KEYCODE_VOICE_ASSIST -> voice()
|
||||||
|
KeyEvent.KEYCODE_CALL -> call()
|
||||||
|
KeyEvent.KEYCODE_ENDCALL -> endCall()
|
||||||
|
KeyEvent.KEYCODE_BACK -> screen(ScreenKey.BACK)
|
||||||
|
KeyEvent.KEYCODE_HOME -> screen(ScreenKey.HOME)
|
||||||
|
KEYCODE_NAVIGATION -> screen(ScreenKey.MAP)
|
||||||
|
KEYCODE_MEDIA -> screen(ScreenKey.MEDIA)
|
||||||
|
KEYCODE_TEL -> screen(ScreenKey.PHONE)
|
||||||
|
else -> return false
|
||||||
|
}
|
||||||
|
if (longPress) GearslipLog.i("keys: long press on $keyCode")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A turn of the rotary knob: each click moves focus one step, like Tab or Shift+Tab. */
|
||||||
|
fun onRotate(delta: Int) {
|
||||||
|
val sink = focusSink ?: return
|
||||||
|
val code = if (delta > 0) KeyEvent.KEYCODE_TAB else KEYCODE_SHIFT_TAB
|
||||||
|
repeat(kotlin.math.abs(delta).coerceAtMost(MAX_ROTARY_STEPS)) {
|
||||||
|
sink(code, true)
|
||||||
|
sink(code, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The voice key: answers the message on screen if there is one, otherwise starts the
|
||||||
|
* assistant when it's turned on.
|
||||||
|
*/
|
||||||
|
private fun voice() = main.post {
|
||||||
|
if (VoiceReply.onVoiceKey()) return@post
|
||||||
|
if (CarSettings.experimentalFeaturesEnabled.value && CarSettings.voiceAssistantEnabled.value) {
|
||||||
|
CarAssistant.start()
|
||||||
|
} else {
|
||||||
|
GearslipLog.i("keys: voice pressed, but the assistant is off in settings")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Answers a ringing call; with nothing ringing, opens the phone screen. */
|
||||||
|
private fun call() = main.post {
|
||||||
|
val context = appContext ?: return@post
|
||||||
|
if (CarCalls.state.value is CallUiState.Ringing) CarCalls.answer(context) else screen(ScreenKey.PHONE)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun endCall() = main.post {
|
||||||
|
if (CarCalls.state.value is CallUiState.Ringing) CarCalls.decline()
|
||||||
|
else GearslipLog.i("keys: end call pressed with no ringing call; hanging up needs the dialer")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun screen(key: ScreenKey) { _screenKeys.tryEmit(key) }
|
||||||
|
|
||||||
|
// aap_protobuf's KeyCode.proto: car-only keys past the end of Android's own range.
|
||||||
|
const val KEYCODE_ROTARY_CONTROLLER = 65536
|
||||||
|
const val KEYCODE_MEDIA = 65537
|
||||||
|
const val KEYCODE_NAVIGATION = 65538
|
||||||
|
const val KEYCODE_TEL = 65540
|
||||||
|
|
||||||
|
/** Stands in for Shift+Tab: the sink sends Tab with the shift meta state. */
|
||||||
|
const val KEYCODE_SHIFT_TAB = -KeyEvent.KEYCODE_TAB
|
||||||
|
|
||||||
|
private const val MAX_ROTARY_STEPS = 5
|
||||||
|
|
||||||
|
private val FOCUS_KEYS = setOf(
|
||||||
|
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_LEFT,
|
||||||
|
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.*
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
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.Info
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
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.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
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 androidx.compose.material.icons.filled.Warning
|
||||||
|
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 app.seb3thehacker.gearslip.notify.MessagingApp
|
||||||
|
import app.seb3thehacker.gearslip.notify.MessagingCatalog
|
||||||
|
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,
|
||||||
|
/** Starts and can be used, but rough enough not to call working; drawn with a yellow mark. */
|
||||||
|
val partial: Boolean = false,
|
||||||
|
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,
|
||||||
|
val builtIn: BuiltInApp? = null,
|
||||||
|
val messaging: MessagingApp? = null,
|
||||||
|
) {
|
||||||
|
/** What a pin or a nav-bar shortcut remembers this entry as; null for nothing pinnable. */
|
||||||
|
val componentId: String?
|
||||||
|
get() = builtIn?.let { BUILT_IN_PREFIX + it.id }
|
||||||
|
?: messaging?.let { MESSAGING_PREFIX + it.packageName }
|
||||||
|
?: (template?.component ?: media?.component)?.flattenToString()
|
||||||
|
|
||||||
|
val packageName: String?
|
||||||
|
get() = template?.component?.packageName ?: media?.component?.packageName ?: messaging?.packageName
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A messaging app has no component of its own to open, so its pin carries the package instead. */
|
||||||
|
internal const val MESSAGING_PREFIX = "messages:"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
/** Observed, not read once: the nav bar is drawn before the car session's rescan finishes,
|
||||||
|
* and an app installed since the last scan has to show up there when it does. */
|
||||||
|
private val flow = MutableStateFlow<List<Entry>?>(null)
|
||||||
|
val state: StateFlow<List<Entry>?> = flow.asStateFlow()
|
||||||
|
|
||||||
|
val entries: List<Entry>? get() = flow.value
|
||||||
|
|
||||||
|
/** 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 navApps = CarAppCatalog.installed(context)
|
||||||
|
val mediaApps = MediaCatalog.installed(context)
|
||||||
|
// Some apps (Spotify among them) ship both a Car App Library template and a legacy
|
||||||
|
// MediaBrowserService, which otherwise land as two identically-labeled tiles with no
|
||||||
|
// way to tell them apart. They're genuinely different screens - browsing versus Now
|
||||||
|
// Playing - so only the label needs disambiguating, not the tile itself.
|
||||||
|
val mediaPackages = mediaApps.map { it.component.packageName }.toSet()
|
||||||
|
|
||||||
|
val messagingApps = MessagingCatalog.installed(context)
|
||||||
|
// Google Messages ships its own car app, which won't start under Gearslip. Gearslip's
|
||||||
|
// own Messages screen does the same job, so a messenger gets only that tile.
|
||||||
|
val messagingPackages = messagingApps.map { it.packageName }.toSet()
|
||||||
|
|
||||||
|
val nav = navApps
|
||||||
|
.filter { it.component.packageName !in messagingPackages }
|
||||||
|
// An app whose template screen is rejected but whose player works through Gearslip
|
||||||
|
// (Spotify) shows only its player tile - a "· Browse" tile beside it would just report
|
||||||
|
// that the car screen isn't supported.
|
||||||
|
.filter { !(KnownApps.screenBroken(it.component.packageName) && KnownApps.playerWorks(it.component.packageName)) }
|
||||||
|
.map {
|
||||||
|
val label = if (it.component.packageName in mediaPackages) "${it.label} · Browse" else it.label
|
||||||
|
Entry(label, iconOf(it.component.packageName), template = it)
|
||||||
|
}
|
||||||
|
val media = mediaApps.map { Entry(it.label, iconOf(it.component.packageName), media = it) }
|
||||||
|
// A messenger that is also a car media app keeps only its player tile.
|
||||||
|
val messaging = messagingApps
|
||||||
|
.filter { it.packageName !in mediaPackages }
|
||||||
|
.map { Entry(it.label, iconOf(it.packageName), messaging = it) }
|
||||||
|
return (nav + media + messaging).sortedBy { it.label.lowercase() }.also { flow.value = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every app the launcher knows: Gearslip's own and the installed ones, rescans included. */
|
||||||
|
@Composable
|
||||||
|
internal fun rememberAllEntries(): List<Entry> {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val installed by LauncherCache.state.collectAsState()
|
||||||
|
LaunchedEffect(context) {
|
||||||
|
if (LauncherCache.entries == null) withContext(Dispatchers.IO) { LauncherCache.load(context) }
|
||||||
|
}
|
||||||
|
return remember(installed) { BuiltInApps.entries + installed.orEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The launcher's groups, in the order they're laid out. */
|
||||||
|
private enum class AppGroup { PHONE, MAPS, MUSIC, MESSAGING, WEB, SCREEN_SHARING, OTHER, SETTINGS }
|
||||||
|
|
||||||
|
private fun groupOf(entry: Entry, mediaPackages: Set<String>): AppGroup {
|
||||||
|
entry.builtIn?.let {
|
||||||
|
return when (it) {
|
||||||
|
BuiltInApps.Phone -> AppGroup.PHONE
|
||||||
|
BuiltInApps.Web -> AppGroup.WEB
|
||||||
|
BuiltInApps.ScreenSharing -> AppGroup.SCREEN_SHARING
|
||||||
|
else -> AppGroup.SETTINGS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (entry.media != null) return AppGroup.MUSIC
|
||||||
|
if (entry.messaging != null) return AppGroup.MESSAGING
|
||||||
|
val app = entry.template ?: return AppGroup.OTHER
|
||||||
|
// A media app's "· Browse" tile sits next to its player.
|
||||||
|
if (app.component.packageName in mediaPackages) return AppGroup.MUSIC
|
||||||
|
return when (app.kind) {
|
||||||
|
"calling" -> AppGroup.PHONE
|
||||||
|
"navigation", "poi", "parking", "charging" -> AppGroup.MAPS
|
||||||
|
"messaging" -> AppGroup.MESSAGING
|
||||||
|
"settings" -> AppGroup.SETTINGS
|
||||||
|
else -> AppGroup.OTHER
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The launcher's tiles in the order they're drawn: grouped by [AppGroup], alphabetical inside a
|
||||||
|
* group with Settings last of all, then with the driver's own moves from [custom] laid over it.
|
||||||
|
* Moved apps only trade places among themselves, so an app installed later still lands in its group.
|
||||||
|
*/
|
||||||
|
internal fun launcherOrder(all: List<Entry>, showVehicleData: Boolean, custom: List<String>): List<Entry> {
|
||||||
|
val shown = all.filter { it.builtIn !== BuiltInApps.VehicleData || showVehicleData }
|
||||||
|
val mediaPackages = shown.mapNotNull { it.media?.component?.packageName }.toSet()
|
||||||
|
val grouped = shown.sortedWith(
|
||||||
|
compareBy<Entry>({ groupOf(it, mediaPackages) }, { it.builtIn === BuiltInApps.Settings }, { it.label.lowercase() }),
|
||||||
|
)
|
||||||
|
if (custom.isEmpty()) return grouped
|
||||||
|
val rank = custom.withIndex().associate { (i, id) -> id to i }
|
||||||
|
fun Entry.rank() = componentId?.let { rank[it] }
|
||||||
|
val moved = grouped.filter { it.rank() != null }.sortedBy { it.rank() }.iterator()
|
||||||
|
return grouped.map { if (it.rank() != null) moved.next() else it }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An app by the id a pin or the running list knows it by, built-in or installed. */
|
||||||
|
internal fun findEntry(id: String): Entry? =
|
||||||
|
BuiltInApps.entries.find { it.componentId == id } ?: LauncherCache.entries?.find { it.componentId == id }
|
||||||
|
|
||||||
|
/** 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.builtIn?.let { it.open(navigator); return }
|
||||||
|
entry.template?.let {
|
||||||
|
if (it.isNavigation) {
|
||||||
|
CarServices.connectNav(it, frame)
|
||||||
|
navigator.home()
|
||||||
|
} else {
|
||||||
|
CarServices.connectBrowse(it, frame)
|
||||||
|
navigator.browse(entry.label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entry.media?.let { CarServices.openMedia(it); navigator.media() }
|
||||||
|
entry.messaging?.let { navigator.messages(it.packageName, it.label) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The app launcher: every app as an icon grid, grouped Phone, Maps, Music, Messaging, Web, Screen sharing, Settings. */
|
||||||
|
@Composable
|
||||||
|
fun CarLauncher() {
|
||||||
|
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 all = rememberAllEntries()
|
||||||
|
val running = rememberRunningApps()
|
||||||
|
val showVehicleData = rememberVehicleDataShown()
|
||||||
|
var showBadgeKey by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
fun tileOf(entry: Entry): Tile {
|
||||||
|
// The badges rate an app's own car screens; a messenger shown on Gearslip's own Messages
|
||||||
|
// screen has none, so it gets no badge.
|
||||||
|
val pkg = entry.packageName?.takeIf { entry.messaging == null }
|
||||||
|
val id = entry.componentId
|
||||||
|
return Tile(
|
||||||
|
entry.label, entry.icon, entry.builtIn?.glyph,
|
||||||
|
verified = pkg != null && (KnownApps.works(pkg) || (entry.media != null && KnownApps.playerWorks(pkg))),
|
||||||
|
broken = pkg != null && (KnownApps.isBroken(pkg) || (entry.template != null && KnownApps.screenBroken(pkg))),
|
||||||
|
partial = pkg != null && KnownApps.isPartial(pkg),
|
||||||
|
onLongClick = id?.let { { navigator.showAppMenu(it) } },
|
||||||
|
) { openApp(entry, id?.let { running[it] }, navigator, frame) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val appOrder by CarSettings.appOrder.collectAsState()
|
||||||
|
val tiles = launcherOrder(all, showVehicleData, appOrder).map(::tileOf)
|
||||||
|
|
||||||
|
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 || it.broken || it.partial }) {
|
||||||
|
// Folded away behind one button: it's reference, read once, not something every
|
||||||
|
// visit to the launcher needs spelled out under the grid.
|
||||||
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
GsButton(onClick = { showBadgeKey = !showBadgeKey }, tone = GsTone.Neutral) {
|
||||||
|
Icon(Icons.Filled.Info, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(if (showBadgeKey) "Hide badge key" else "What the badges mean")
|
||||||
|
}
|
||||||
|
if (showBadgeKey) {
|
||||||
|
BadgeLegend(
|
||||||
|
verified = tiles.any { it.verified },
|
||||||
|
partial = tiles.any { it.partial },
|
||||||
|
broken = tiles.any { it.broken },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Says what each badge on a tile means, once, under the grid - only the ones actually in use. */
|
||||||
|
@Composable
|
||||||
|
private fun BadgeLegend(verified: Boolean, partial: Boolean, broken: Boolean) {
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
if (verified) LegendRow(size = 16.dp, badge = { VerifiedBadge(it) }, text = "Tested and working with Gearslip")
|
||||||
|
if (partial) LegendRow(size = 16.dp, badge = { PartialBadge(it) }, text = "Runs, but not cleanly, with Gearslip")
|
||||||
|
if (broken) LegendRow(size = 16.dp, badge = { BrokenBadge(it) }, text = "Doesn't work with Gearslip yet")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LegendRow(size: androidx.compose.ui.unit.Dp, badge: @Composable (androidx.compose.ui.unit.Dp) -> Unit, text: String) {
|
||||||
|
Row(horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
badge(size)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(text, 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A yellow disc with a warning mark: neither the check nor the X, since this app runs but isn't clean about it. */
|
||||||
|
@Composable
|
||||||
|
private fun PartialBadge(size: androidx.compose.ui.unit.Dp, modifier: Modifier = Modifier) {
|
||||||
|
Box(
|
||||||
|
modifier.size(size).background(Color(0xFFF9A825), CircleShape),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Warning, contentDescription = "Works, but not cleanly, with Gearslip", tint = Color.White, modifier = Modifier.size(size * 0.6f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun AppTile(tile: Tile) {
|
||||||
|
val shape = MaterialTheme.shapes.extraLarge
|
||||||
|
// A long press opens the app's menu (pin, close) - the same gesture everywhere apps are
|
||||||
|
// shown, here and on the nav bar.
|
||||||
|
GsIconBox(
|
||||||
|
onClick = tile.onClick,
|
||||||
|
onLongClick = tile.onLongClick,
|
||||||
|
modifier = Modifier.height(120.dp),
|
||||||
|
colors = GsColors(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant),
|
||||||
|
shape = shape,
|
||||||
|
) {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
if (tile.partial) {
|
||||||
|
PartialBadge(24.dp, Modifier.align(Alignment.TopEnd).padding(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
/** Everything the head unit's sensor channel has advertised and sent, raw. */
|
||||||
|
data object VehicleData : CarScreen
|
||||||
|
|
||||||
|
/** The current media app, full size: browse, lyrics and controls. */
|
||||||
|
data object Media : CarScreen
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A templated app that isn't a navigation app - Spotify's own Car App Library service, among
|
||||||
|
* others - opened on its own screen rather than the Home map slot, which only a genuine
|
||||||
|
* navigation app can claim.
|
||||||
|
*/
|
||||||
|
data object Browse : CarScreen
|
||||||
|
|
||||||
|
/** A messaging app's recent conversations, read from the notifications it posts. */
|
||||||
|
data class Messages(val packageName: String) : 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
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class SidePanel { NONE, CONTROLS, LYRICS }
|
||||||
|
|
||||||
|
/** 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"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the column beside the map on Home is showing, if anything. The player lives in one
|
||||||
|
* place at a time: with [SidePanel.CONTROLS] it moves into the column and the nav bar's pill
|
||||||
|
* steps aside; with [SidePanel.LYRICS] the column is all lyrics and the pill comes back.
|
||||||
|
* Every other screen has no room for the column, so there the pill stays in the nav bar.
|
||||||
|
*/
|
||||||
|
var sidePanel by mutableStateOf(SidePanel.NONE)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pill's dock button: moves the player beside the map, going Home first if need be. With
|
||||||
|
* lyrics already there it swaps them for the controls; with the controls there it closes.
|
||||||
|
*/
|
||||||
|
fun togglePlayerDock() {
|
||||||
|
if (current != CarScreen.Home) {
|
||||||
|
sidePanel = SidePanel.CONTROLS
|
||||||
|
home()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sidePanel = if (sidePanel == SidePanel.CONTROLS) SidePanel.NONE else SidePanel.CONTROLS
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showSideLyrics() { sidePanel = SidePanel.LYRICS }
|
||||||
|
|
||||||
|
fun closeSidePanel() { sidePanel = SidePanel.NONE }
|
||||||
|
|
||||||
|
/** Component id of the app whose long-press menu (pin, close) is open, or null. */
|
||||||
|
var appMenu by mutableStateOf<String?>(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun showAppMenu(componentId: String) { appMenu = componentId }
|
||||||
|
|
||||||
|
fun dismissAppMenu() { appMenu = null }
|
||||||
|
|
||||||
|
fun media() { push(CarScreen.Media, "Media") }
|
||||||
|
|
||||||
|
fun browse(label: String) { push(CarScreen.Browse, label) }
|
||||||
|
|
||||||
|
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 vehicleData() { push(CarScreen.VehicleData, "Vehicle data") }
|
||||||
|
|
||||||
|
fun messages(packageName: String, label: String) { push(CarScreen.Messages(packageName), label) }
|
||||||
|
|
||||||
|
/** The list lives on the dashboard; only a reply gets a screen of its own. */
|
||||||
|
fun notifications(replyTo: String? = null) {
|
||||||
|
if (replyTo == null) dashboard() else push(CarScreen.Notifications(replyTo), "Reply")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun open(id: String) { push(CarScreen.App(id), CarApps.find(id)?.label ?: id) }
|
||||||
|
|
||||||
|
/** Jumps straight to a step already on the trail, dropping everything after it - what
|
||||||
|
* tapping a breadcrumb does. Index 0 (Home) is always on the trail, so this always succeeds. */
|
||||||
|
fun jumpTo(index: Int) {
|
||||||
|
while (stack.size > index + 1) stack.removeAt(stack.lastIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One step back, same as tapping the crumb before the current one. */
|
||||||
|
fun back() {
|
||||||
|
if (backInterceptor?.invoke() == true) return
|
||||||
|
if (stack.size > 1) stack.removeAt(stack.lastIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalCarNavigator = staticCompositionLocalOf<CarNavigator> { error("no CarNavigator provided") }
|
||||||
|
|
||||||
|
/** "Is it dark outside", for apps that adapt to it. See [CarEnvironment]. */
|
||||||
|
val LocalDarkOutside = staticCompositionLocalOf { false }
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import app.seb3thehacker.gearslip.Protobuf
|
||||||
|
import app.seb3thehacker.gearslip.Wire
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the head unit's sensor channel has offered and sent, for the "Vehicle data" screen.
|
||||||
|
*
|
||||||
|
* [ServiceDiscovery.findSensorService] tells us which numeric sensor types this head unit is
|
||||||
|
* willing to stream; [SensorType] names the ones publicly documented by other AA reverse-engineering
|
||||||
|
* projects (aasdk, openauto). That mapping is carried over from other projects, not verified
|
||||||
|
* against this exact unit, so it's a label, not a guarantee - the raw field dump next to it is
|
||||||
|
* the ground truth. Once [onEvent] starts decoding real traffic from the 2018 Uconnect, correct
|
||||||
|
* any type that turns out to be mislabeled.
|
||||||
|
*/
|
||||||
|
object CarSensors {
|
||||||
|
|
||||||
|
data class Reading(val typeId: Int, val typeName: String, val raw: String, val updatedAtMs: Long)
|
||||||
|
|
||||||
|
private val readingsFlow = MutableStateFlow<List<Reading>>(emptyList())
|
||||||
|
val readings: StateFlow<List<Reading>> = readingsFlow
|
||||||
|
|
||||||
|
/** Seeds one row per sensor type the head unit advertised, so the screen shows what's on
|
||||||
|
* offer even before the first event for it arrives. */
|
||||||
|
fun onDiscovered(types: List<Int>) {
|
||||||
|
readingsFlow.value = types.map { id ->
|
||||||
|
Reading(id, SensorType.name(id), "(waiting for data)", 0L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One SensorEventIndication. Every reverse-engineered version of this message multiplexes
|
||||||
|
* every sensor type into one shared set of message-level field numbers (field N = "the data
|
||||||
|
* for sensor type N", roughly) rather than tagging events with the type id from
|
||||||
|
* SensorType.type - so field number is read here as a best-effort type id, same as the
|
||||||
|
* other services in this file.
|
||||||
|
*/
|
||||||
|
fun onEvent(body: ByteArray) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val fields = Wire.fields(body)
|
||||||
|
val byType = fields.groupBy { it.number }
|
||||||
|
val updated = byType.mapValues { (number, group) ->
|
||||||
|
Reading(
|
||||||
|
number,
|
||||||
|
SensorType.name(number),
|
||||||
|
group.joinToString("; ") { field ->
|
||||||
|
field.bytes?.let { Protobuf.describe(it, indent = "").trim().replace("\n", ", ") }
|
||||||
|
?: "varint=${field.varint}"
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val existing = readingsFlow.value.associateBy { it.typeId }
|
||||||
|
readingsFlow.value = (existing + updated).values.sortedBy { it.typeId }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
readingsFlow.value = emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sensor type ids as named across public Android Auto reverse-engineering references. Treated as
|
||||||
|
* a label, not a fact - see [CarSensors] doc.
|
||||||
|
*/
|
||||||
|
object SensorType {
|
||||||
|
private val NAMES = mapOf(
|
||||||
|
1 to "Location (GPS)",
|
||||||
|
2 to "Compass",
|
||||||
|
3 to "Speed",
|
||||||
|
4 to "RPM",
|
||||||
|
5 to "Odometer",
|
||||||
|
6 to "Fuel",
|
||||||
|
7 to "Parking brake",
|
||||||
|
8 to "Gear",
|
||||||
|
9 to "Diagnostics",
|
||||||
|
10 to "Night mode",
|
||||||
|
11 to "Environment",
|
||||||
|
12 to "HVAC",
|
||||||
|
13 to "Driving status",
|
||||||
|
14 to "Dead reckoned location",
|
||||||
|
15 to "Passenger",
|
||||||
|
16 to "Door",
|
||||||
|
17 to "Light",
|
||||||
|
18 to "Tire pressure",
|
||||||
|
19 to "Accelerometer",
|
||||||
|
20 to "Gyroscope",
|
||||||
|
21 to "Gravity",
|
||||||
|
22 to "Wheel tick / distance",
|
||||||
|
)
|
||||||
|
|
||||||
|
fun name(id: Int): String = NAMES[id]?.let { "$it (#$id)" } ?: "Sensor #$id"
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.session.MediaSessionManager
|
||||||
|
import android.media.session.PlaybackState
|
||||||
|
import app.seb3thehacker.gearslip.audio.CarAudio
|
||||||
|
import app.seb3thehacker.gearslip.host.CarAppCatalog
|
||||||
|
import app.seb3thehacker.gearslip.host.CarAppConnection
|
||||||
|
import app.seb3thehacker.gearslip.host.KnownApps
|
||||||
|
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 app.seb3thehacker.gearslip.notify.GearslipNotificationListener
|
||||||
|
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)) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A second, independent connection for templated apps that aren't navigation apps - Spotify's
|
||||||
|
* own Car App Library service is the first of these. Kept apart from [nav] so opening one
|
||||||
|
* can never bump the map off Home: this one gets its own screen ([CarScreen.Browse]) instead.
|
||||||
|
*/
|
||||||
|
val browse: CarAppConnection by lazy { CarAppConnection(requireNotNull(context)) }
|
||||||
|
|
||||||
|
private val _mediaApp = MutableStateFlow<MediaApp?>(null)
|
||||||
|
val mediaApp: StateFlow<MediaApp?> = _mediaApp.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the phone has any navigation app installed at all. True until [autostart] has
|
||||||
|
* actually checked, so the home screen never flashes "none found" before it knows.
|
||||||
|
*/
|
||||||
|
private val _hasNavApps = MutableStateFlow(true)
|
||||||
|
val hasNavApps: StateFlow<Boolean> = _hasNavApps.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)
|
||||||
|
CarAssistant.init(appContext)
|
||||||
|
CarKeys.init(appContext)
|
||||||
|
VoiceReply.init(appContext)
|
||||||
|
// Loaded now, while nothing's waiting on it, so the first reply isn't slower than the rest.
|
||||||
|
app.seb3thehacker.gearslip.speech.SpeechEngine.warm(appContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- map -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [destination] is a search query to start navigating to right away - "navigate to X with Y"
|
||||||
|
* from the voice assistant - left null for a plain app launch. An app that's already the one
|
||||||
|
* running gets the destination sent to its live session instead of being torn down and
|
||||||
|
* rebuilt, same as [CarAppConnection.navigateTo] documents.
|
||||||
|
*/
|
||||||
|
fun connectNav(app: TemplateApp, frame: CarEnvironment.Frame, destination: String? = null) {
|
||||||
|
navStopped = false
|
||||||
|
CarSettings.setLastNav(app.component.flattenToString())
|
||||||
|
if (destination != null && nav.connectedComponent == app.component) {
|
||||||
|
nav.navigateTo(destination)
|
||||||
|
} else {
|
||||||
|
nav.connect(app, frame.width, frame.height, frame.densityDpi, destination)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- browse (non-navigation templated apps) -------------------------------------------
|
||||||
|
|
||||||
|
/** Opens a templated app that isn't navigation, on its own screen - never on Home. */
|
||||||
|
fun connectBrowse(app: TemplateApp, frame: CarEnvironment.Frame) {
|
||||||
|
browse.connect(app, frame.width, frame.height, frame.densityDpi)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopBrowse() {
|
||||||
|
browse.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- media ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fun openMedia(app: MediaApp, autoplay: Boolean = false) {
|
||||||
|
CarSettings.setLastMedia(app.component.flattenToString())
|
||||||
|
val phase = media.phase.value
|
||||||
|
val live = phase == CarMedia.Phase.READY || phase == CarMedia.Phase.CONNECTING
|
||||||
|
if (_mediaApp.value?.component == app.component && live) return
|
||||||
|
|
||||||
|
_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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closes the media app from the car: stops what it's playing and drops the connection. */
|
||||||
|
fun closeMedia() {
|
||||||
|
if (media.now.value.playing) media.togglePlay()
|
||||||
|
media.disconnect()
|
||||||
|
_mediaApp.value = null
|
||||||
|
autoplayPending = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 navApps = withContext(Dispatchers.IO) { CarAppCatalog.installed(ctx) }
|
||||||
|
.filter { it.isNavigation }
|
||||||
|
_hasNavApps.value = navApps.isNotEmpty()
|
||||||
|
// Never auto-pick one already known to fail outright - a driver who plugs in should
|
||||||
|
// land on a map, not on the one tile marked with a red X.
|
||||||
|
val candidates = navApps.filterNot { KnownApps.isBroken(it.component.packageName) }
|
||||||
|
val last = CarSettings.lastNav.value
|
||||||
|
// The one used last, or - first run, nothing on record yet - whichever comes first:
|
||||||
|
// a driver who plugs in for the first time shouldn't have to go pick one by hand
|
||||||
|
// before the map shows up at all.
|
||||||
|
val lastComponent = last?.let(ComponentName::unflattenFromString)
|
||||||
|
val app = candidates.firstOrNull { it.component == lastComponent } ?: candidates.firstOrNull()
|
||||||
|
if (app != null) connectNav(app, frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_mediaApp.value == null) {
|
||||||
|
val catalog = withContext(Dispatchers.IO) { MediaCatalog.installed(ctx) }
|
||||||
|
// Something already playing on the phone wins over whatever was last used in the car -
|
||||||
|
// a driver who started a song before plugging in shouldn't have to go open its tile by
|
||||||
|
// hand just to see what's already going.
|
||||||
|
val playing = withContext(Dispatchers.IO) { currentlyPlaying(ctx, catalog) }
|
||||||
|
if (playing != null) {
|
||||||
|
openMedia(playing)
|
||||||
|
} else {
|
||||||
|
val last = CarSettings.lastMedia.value
|
||||||
|
val app = last?.let { id -> catalog.firstOrNull { it.component == ComponentName.unflattenFromString(id) } }
|
||||||
|
if (app != null) openMedia(app, autoplay = CarSettings.autoplay.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whichever known car media app has an actively playing session right now, system-wide - not
|
||||||
|
* just the one Gearslip itself last connected to. Reading another app's session needs
|
||||||
|
* notification access, the same permission [app.seb3thehacker.gearslip.media.CarMedia]
|
||||||
|
* already asks for to control apps that won't share their library.
|
||||||
|
*/
|
||||||
|
private fun currentlyPlaying(ctx: Context, catalog: List<MediaApp>): MediaApp? {
|
||||||
|
val manager = ctx.getSystemService(MediaSessionManager::class.java)
|
||||||
|
val listener = ComponentName(ctx, GearslipNotificationListener::class.java)
|
||||||
|
val sessions = try {
|
||||||
|
manager.getActiveSessions(listener)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val playingPackages = sessions
|
||||||
|
.filter { it.playbackState?.state == PlaybackState.STATE_PLAYING }
|
||||||
|
.map { it.packageName }
|
||||||
|
return playingPackages.firstNotNullOfOrNull { pkg -> catalog.firstOrNull { it.component.packageName == pkg } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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() }
|
||||||
|
runCatching { browse.disconnect() }
|
||||||
|
CarAssistant.cancel()
|
||||||
|
CarAudio.release()
|
||||||
|
_mediaApp.value = null
|
||||||
|
navStopped = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import app.seb3thehacker.gearslip.AppSettings
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsTheme
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsThemes
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
enum class NightMode { AUTO, DAY, NIGHT }
|
||||||
|
|
||||||
|
/** Distances and temperatures. [AUTO] goes by the phone's region. */
|
||||||
|
enum class Units { AUTO, IMPERIAL, METRIC }
|
||||||
|
|
||||||
|
/** Where a new message shows: [NAV_BAR] turns the clock button into it, [CARD] floats a card top right. */
|
||||||
|
|
||||||
|
/** 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_PLAYER_TITLE = "car_player_title"
|
||||||
|
private const val KEY_THEME = "car_app_theme"
|
||||||
|
private const val KEY_UI_THEME = "car_ui_theme"
|
||||||
|
private const val KEY_PINNED = "car_pinned_apps"
|
||||||
|
private const val KEY_APP_ORDER = "car_app_order"
|
||||||
|
private const val KEY_EXPERIMENTAL_FEATURES = "car_experimental_features"
|
||||||
|
private const val KEY_VOICE_ASSISTANT = "car_voice_assistant"
|
||||||
|
private const val KEY_CAR_SENSORS = "car_sensors_enabled"
|
||||||
|
private const val KEY_SEEN_SAFETY_WARNING = "car_seen_safety_warning"
|
||||||
|
private const val KEY_UNITS = "car_units"
|
||||||
|
private const val KEY_WHATS_NEW = "car_whats_new_after_updates"
|
||||||
|
private const val KEY_WHATS_NEW_SEEN = "car_whats_new_seen_code"
|
||||||
|
|
||||||
|
/** 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 playerTitleFlow = MutableStateFlow(false)
|
||||||
|
private val themeFlow = MutableStateFlow(AppTheme.PHONE)
|
||||||
|
val appTheme: StateFlow<AppTheme> = themeFlow
|
||||||
|
private val uiThemeFlow = MutableStateFlow(GsThemes.default.id)
|
||||||
|
|
||||||
|
/** Id of the [GsTheme] controls are drawn in; an unknown id falls back to the default. */
|
||||||
|
val uiTheme: StateFlow<String> = uiThemeFlow
|
||||||
|
private val pinnedFlow = MutableStateFlow<Set<String>>(emptySet())
|
||||||
|
private val appOrderFlow = MutableStateFlow<List<String>>(emptyList())
|
||||||
|
private val experimentalFeaturesFlow = MutableStateFlow(false)
|
||||||
|
private val voiceAssistantFlow = MutableStateFlow(false)
|
||||||
|
private val carSensorsFlow = MutableStateFlow(false)
|
||||||
|
private val seenSafetyWarningFlow = MutableStateFlow(false)
|
||||||
|
private val unitsFlow = MutableStateFlow(Units.AUTO)
|
||||||
|
|
||||||
|
val units: StateFlow<Units> = unitsFlow
|
||||||
|
|
||||||
|
|
||||||
|
private val whatsNewFlow = MutableStateFlow(true)
|
||||||
|
private val whatsNewSeenFlow = MutableStateFlow(0)
|
||||||
|
private val whatsNewOpenFlow = MutableStateFlow(false)
|
||||||
|
|
||||||
|
/** Whether the car screen shows what's new once after each update. */
|
||||||
|
val whatsNewAfterUpdates: StateFlow<Boolean> = whatsNewFlow
|
||||||
|
|
||||||
|
/** Version code whose notes the car screen last showed. */
|
||||||
|
val whatsNewSeen: StateFlow<Int> = whatsNewSeenFlow
|
||||||
|
|
||||||
|
/** Opened by hand from Settings, whatever the two above say. Not saved. */
|
||||||
|
val whatsNewOpen: StateFlow<Boolean> = whatsNewOpenFlow
|
||||||
|
|
||||||
|
fun setWhatsNewAfterUpdates(on: Boolean) {
|
||||||
|
whatsNewFlow.value = on
|
||||||
|
AppSettings.putString(app, KEY_WHATS_NEW, on.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openWhatsNew() { whatsNewOpenFlow.value = true }
|
||||||
|
|
||||||
|
/** Closes the notes and marks [versionCode]'s as read. */
|
||||||
|
fun closeWhatsNew(versionCode: Int) {
|
||||||
|
whatsNewOpenFlow.value = false
|
||||||
|
whatsNewSeenFlow.value = versionCode
|
||||||
|
AppSettings.putString(app, KEY_WHATS_NEW_SEEN, versionCode.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/** Whether to show miles, feet and °F; [Units.AUTO] follows the countries that still use them. */
|
||||||
|
fun imperial(): Boolean = when (unitsFlow.value) {
|
||||||
|
Units.IMPERIAL -> true
|
||||||
|
Units.METRIC -> false
|
||||||
|
Units.AUTO -> java.util.Locale.getDefault().country in setOf("US", "LR", "MM")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setUnits(units: Units) {
|
||||||
|
unitsFlow.value = units
|
||||||
|
AppSettings.putString(app, KEY_UNITS, units.name)
|
||||||
|
Weather.refresh(app, force = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flattened component names of the apps long-pressed onto the nav bar as shortcuts. */
|
||||||
|
val pinnedApps: StateFlow<Set<String>> = pinnedFlow
|
||||||
|
|
||||||
|
/** The launcher's order once the driver has moved a tile; empty keeps the grouped default. */
|
||||||
|
val appOrder: StateFlow<List<String>> = appOrderFlow
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The nav bar's player keeps the song title up, with play/pause and the dock button, instead of
|
||||||
|
* settling to the skip buttons once a new song has announced itself.
|
||||||
|
*/
|
||||||
|
val playerTitle: StateFlow<Boolean> = playerTitleFlow
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Off by default: a driver has to opt into experimental features as a whole before any of
|
||||||
|
* them - the voice assistant included - show up to turn on individually.
|
||||||
|
*/
|
||||||
|
val experimentalFeaturesEnabled: StateFlow<Boolean> = experimentalFeaturesFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The voice assistant is experimental and off by default; a driver has to turn it on
|
||||||
|
* knowingly, from the settings screen, after reading the warning shown there. This flag
|
||||||
|
* alone does not run the assistant - [experimentalFeaturesEnabled] must also be on.
|
||||||
|
*/
|
||||||
|
val voiceAssistantEnabled: StateFlow<Boolean> = voiceAssistantFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vehicle data is experimental and off by default: the launcher's tile for it, and the
|
||||||
|
* voice assistant's "vehicle data" command, only show up once this and
|
||||||
|
* [experimentalFeaturesEnabled] are both on. See [app.seb3thehacker.gearslip.car.CarSensors]
|
||||||
|
* for why the raw sensor feed it shows is worth gating - the field labels are unverified.
|
||||||
|
*/
|
||||||
|
val carSensorsEnabled: StateFlow<Boolean> = carSensorsFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the car screen's own "drive safely" warning has already been shown and dismissed
|
||||||
|
* once, from this same install. Separate from the phone side's own compat/safety dialogs -
|
||||||
|
* a driver who only ever plugs in and never opens the phone app should still see this once,
|
||||||
|
* on the screen they are actually about to use while driving.
|
||||||
|
*/
|
||||||
|
val hasSeenSafetyWarning: StateFlow<Boolean> = seenSafetyWarningFlow
|
||||||
|
|
||||||
|
fun markSafetyWarningSeen() {
|
||||||
|
seenSafetyWarningFlow.value = true
|
||||||
|
AppSettings.putString(app, KEY_SEEN_SAFETY_WARNING, "true")
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
uiThemeFlow.value = AppSettings.getString(app, KEY_UI_THEME, GsThemes.default.id)
|
||||||
|
autoplayFlow.value = AppSettings.getString(app, KEY_AUTOPLAY, "false") == "true"
|
||||||
|
playerTitleFlow.value = AppSettings.getString(app, KEY_PLAYER_TITLE, "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()
|
||||||
|
appOrderFlow.value = AppSettings.getString(app, KEY_APP_ORDER, "")
|
||||||
|
.split(",").filter { it.isNotEmpty() }
|
||||||
|
experimentalFeaturesFlow.value = AppSettings.getString(app, KEY_EXPERIMENTAL_FEATURES, "false") == "true"
|
||||||
|
voiceAssistantFlow.value = AppSettings.getString(app, KEY_VOICE_ASSISTANT, "false") == "true"
|
||||||
|
carSensorsFlow.value = AppSettings.getString(app, KEY_CAR_SENSORS, "false") == "true"
|
||||||
|
seenSafetyWarningFlow.value = AppSettings.getString(app, KEY_SEEN_SAFETY_WARNING, "false") == "true"
|
||||||
|
unitsFlow.value = runCatching {
|
||||||
|
Units.valueOf(AppSettings.getString(app, KEY_UNITS, Units.AUTO.name))
|
||||||
|
}.getOrDefault(Units.AUTO)
|
||||||
|
whatsNewFlow.value = AppSettings.getString(app, KEY_WHATS_NEW, "true") == "true"
|
||||||
|
whatsNewSeenFlow.value = AppSettings.getString(app, KEY_WHATS_NEW_SEEN, "0").toIntOrNull() ?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
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 setUiTheme(id: String) {
|
||||||
|
uiThemeFlow.value = id
|
||||||
|
AppSettings.putString(app, KEY_UI_THEME, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPlayerTitle(on: Boolean) {
|
||||||
|
playerTitleFlow.value = on
|
||||||
|
AppSettings.putString(app, KEY_PLAYER_TITLE, on.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
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 setExperimentalFeaturesEnabled(on: Boolean) {
|
||||||
|
experimentalFeaturesFlow.value = on
|
||||||
|
AppSettings.putString(app, KEY_EXPERIMENTAL_FEATURES, on.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setVoiceAssistantEnabled(on: Boolean) {
|
||||||
|
voiceAssistantFlow.value = on
|
||||||
|
AppSettings.putString(app, KEY_VOICE_ASSISTANT, on.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCarSensorsEnabled(on: Boolean) {
|
||||||
|
carSensorsFlow.value = on
|
||||||
|
AppSettings.putString(app, KEY_CAR_SENSORS, 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(","))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAppOrder(ids: List<String>) {
|
||||||
|
appOrderFlow.value = ids
|
||||||
|
AppSettings.putString(app, KEY_APP_ORDER, ids.joinToString(","))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.*
|
||||||
|
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.layout.width
|
||||||
|
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.Switch
|
||||||
|
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.car.theme.GsColors
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsIconBox
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsThemes
|
||||||
|
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 units by CarSettings.units.collectAsState()
|
||||||
|
val whatsNew by CarSettings.whatsNewAfterUpdates.collectAsState()
|
||||||
|
val pipeAudio by CarSettings.pipeAudio.collectAsState()
|
||||||
|
val autoplay by CarSettings.autoplay.collectAsState()
|
||||||
|
val playerTitle by CarSettings.playerTitle.collectAsState()
|
||||||
|
val theme by CarSettings.appTheme.collectAsState()
|
||||||
|
val uiTheme by CarSettings.uiTheme.collectAsState()
|
||||||
|
val experimentalFeatures by CarSettings.experimentalFeaturesEnabled.collectAsState()
|
||||||
|
val voiceAssistant by CarSettings.voiceAssistantEnabled.collectAsState()
|
||||||
|
val carSensors by CarSettings.carSensorsEnabled.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(
|
||||||
|
"Theme",
|
||||||
|
GsThemes.builtIn.map { it.id to it.name },
|
||||||
|
uiTheme,
|
||||||
|
) { CarSettings.setUiTheme(it) }
|
||||||
|
|
||||||
|
ChoiceRow(
|
||||||
|
"Map day/night",
|
||||||
|
listOf(NightMode.AUTO to "Auto", NightMode.DAY to "Day", NightMode.NIGHT to "Night"),
|
||||||
|
night,
|
||||||
|
) { CarSettings.setNightMode(it) }
|
||||||
|
|
||||||
|
ChoiceRow(
|
||||||
|
"Units",
|
||||||
|
listOf(Units.AUTO to "Auto", Units.IMPERIAL to "Imperial (mi, ft, °F)", Units.METRIC to "Metric (km, m, °C)"),
|
||||||
|
units,
|
||||||
|
) { CarSettings.setUnits(it) }
|
||||||
|
|
||||||
|
ChoiceRow(
|
||||||
|
"Player in the nav bar",
|
||||||
|
listOf(false to "Skip buttons", true to "Song title"),
|
||||||
|
playerTitle,
|
||||||
|
) { CarSettings.setPlayerTitle(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)
|
||||||
|
|
||||||
|
ChoiceRow(
|
||||||
|
"Experimental features",
|
||||||
|
listOf(false to "Hide", true to "Show"),
|
||||||
|
experimentalFeatures,
|
||||||
|
) { CarSettings.setExperimentalFeaturesEnabled(it) }
|
||||||
|
if (experimentalFeatures) {
|
||||||
|
ExperimentalFeaturesList(voiceAssistant, carSensors)
|
||||||
|
}
|
||||||
|
|
||||||
|
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…")
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text("About", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||||
|
Info("Version", app.seb3thehacker.gearslip.BuildConfig.VERSION_NAME)
|
||||||
|
Info("Build", app.seb3thehacker.gearslip.BuildConfig.VERSION_CODE.toString())
|
||||||
|
ExperimentalFeatureRow(
|
||||||
|
"What's new after updates",
|
||||||
|
"Show the list of changes once, the first time the car connects after an update.",
|
||||||
|
whatsNew,
|
||||||
|
) { CarSettings.setWhatsNewAfterUpdates(it) }
|
||||||
|
GsButton(onClick = CarSettings::openWhatsNew, tone = GsTone.Tonal, modifier = Modifier.height(56.dp)) {
|
||||||
|
Text("What's new", style = MaterialTheme.typography.titleMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
GsIconBox(
|
||||||
|
onClick = { onSelect(value) },
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(64.dp),
|
||||||
|
colors = if (on) GsColors(scheme.primaryContainer, scheme.onPrimaryContainer)
|
||||||
|
else GsColors(scheme.surfaceContainerHigh, scheme.onSurface),
|
||||||
|
latched = on,
|
||||||
|
) {
|
||||||
|
Text(label, style = MaterialTheme.typography.titleMedium, fontWeight = if (on) FontWeight.Bold else FontWeight.Normal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Individually-switched experimental features, shown once the driver has opted into
|
||||||
|
* experimental features as a whole. Each is its own row - a title, what it does, and a switch -
|
||||||
|
* so the list reads fine whether there's one entry or several.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ExperimentalFeaturesList(voiceAssistant: Boolean, carSensors: Boolean) {
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
ExperimentalFeatureRow(
|
||||||
|
title = "Voice assistant",
|
||||||
|
description = "Push-to-talk and the car's voice button start listening: navigate, " +
|
||||||
|
"play media, call, or open an app by saying so. Can mishear you or act on the " +
|
||||||
|
"wrong command - keep your eyes on the road, and confirm anything it does " +
|
||||||
|
"before you rely on it.",
|
||||||
|
warning = true,
|
||||||
|
checked = voiceAssistant,
|
||||||
|
) { CarSettings.setVoiceAssistantEnabled(it) }
|
||||||
|
|
||||||
|
ExperimentalFeatureRow(
|
||||||
|
title = "Vehicle data",
|
||||||
|
description = "Adds a Vehicle data tile: a live, raw dump of the head unit's sensor " +
|
||||||
|
"channel. Field labels (speed, RPM, fuel...) are carried over from other " +
|
||||||
|
"reverse-engineering projects and unverified against this car - treat them as a " +
|
||||||
|
"guess, not a fact, until you've watched one change for yourself.",
|
||||||
|
checked = carSensors,
|
||||||
|
) { CarSettings.setCarSensorsEnabled(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ExperimentalFeatureRow(
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
checked: Boolean,
|
||||||
|
warning: Boolean = false,
|
||||||
|
onCheckedChange: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
description,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (warning) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
GsSwitch(checked, onCheckedChange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 },
|
||||||
|
onDismiss = { 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,
|
||||||
|
)
|
||||||
|
GsButton(
|
||||||
|
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) {
|
||||||
|
GsIconBox(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier.height(64.dp),
|
||||||
|
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface),
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
) {
|
||||||
|
Text(text, style = MaterialTheme.typography.titleLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,875 @@
|
|||||||
|
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.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.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.ui.graphics.lerp
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
import androidx.car.app.model.CarIcon
|
||||||
|
import androidx.car.app.navigation.model.NavigationTemplate
|
||||||
|
import androidx.car.app.navigation.model.RoutingInfo
|
||||||
|
import app.seb3thehacker.gearslip.host.CarAppConnection
|
||||||
|
import app.seb3thehacker.gearslip.host.display
|
||||||
|
import app.seb3thehacker.gearslip.host.text
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.animation.animateContentSize
|
||||||
|
import android.media.session.PlaybackState
|
||||||
|
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.res.vectorResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import app.seb3thehacker.gearslip.R
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsColors
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsIconBox
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsIconButton
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsButton
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsTone
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.gsColors
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsThemes
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.LocalGsTheme
|
||||||
|
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.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 uiThemeId by CarSettings.uiTheme.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() }
|
||||||
|
|
||||||
|
// The one place with a navigator and a frame to act a voice command on - CarAssistant itself
|
||||||
|
// only knows how to listen and speak, not what "open Spotify" means.
|
||||||
|
val frameForAssistant by CarEnvironment.frame.collectAsState()
|
||||||
|
LaunchedEffect(navigator) {
|
||||||
|
CarAssistant.pendingCommand.collect { heard ->
|
||||||
|
if (heard != null) {
|
||||||
|
val reply = AssistantCommands.handle(appContext, navigator, frameForAssistant, heard)
|
||||||
|
CarAssistant.reply(reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The car's own back, home, map, media and phone keys.
|
||||||
|
LaunchedEffect(navigator) {
|
||||||
|
CarKeys.screenKeys.collect { key ->
|
||||||
|
when (key) {
|
||||||
|
CarKeys.ScreenKey.BACK -> when {
|
||||||
|
navigator.appMenu != null -> navigator.dismissAppMenu()
|
||||||
|
VoiceReply.state.value.phase != VoiceReply.Phase.IDLE -> VoiceReply.cancel()
|
||||||
|
else -> navigator.back()
|
||||||
|
}
|
||||||
|
CarKeys.ScreenKey.HOME, CarKeys.ScreenKey.MAP -> navigator.home()
|
||||||
|
CarKeys.ScreenKey.MEDIA -> if (CarServices.mediaApp.value != null) navigator.media() else navigator.apps()
|
||||||
|
CarKeys.ScreenKey.PHONE -> BuiltInApps.Phone.open(navigator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalDensity provides Density(base.density * scale, base.fontScale),
|
||||||
|
LocalCarNavigator provides navigator,
|
||||||
|
LocalDarkOutside provides darkOutside,
|
||||||
|
LocalMapControlsWake provides controlsWake,
|
||||||
|
LocalGsTheme provides GsThemes.byId(uiThemeId),
|
||||||
|
) {
|
||||||
|
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
|
||||||
|
val safetyWarningSeen by CarSettings.hasSeenSafetyWarning.collectAsState()
|
||||||
|
Box(Modifier.fillMaxSize().padding(pad)) {
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
if (!safetyWarningSeen) {
|
||||||
|
CarSafetyWizard(onDone = CarSettings::markSafetyWarningSeen)
|
||||||
|
return@Column
|
||||||
|
}
|
||||||
|
// Header navigation (breadcrumb trail) - off for now, kept to tweak later.
|
||||||
|
// BreadcrumbBar(navigator)
|
||||||
|
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||||
|
// Home never leaves the composition; other screens cover it. Dropping it
|
||||||
|
// would tear down the map's surface, and coming back would then show black
|
||||||
|
// while the nav app redrew on a new one.
|
||||||
|
CarHome()
|
||||||
|
if (screen != CarScreen.Home) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.background)
|
||||||
|
// Keeps touches that miss this screen's controls off the map.
|
||||||
|
.pointerInput(Unit) { detectTapGestures { } },
|
||||||
|
) {
|
||||||
|
when (screen) {
|
||||||
|
CarScreen.Home -> Unit
|
||||||
|
CarScreen.Apps -> CarLauncher()
|
||||||
|
CarScreen.Media -> mediaApp?.let { MediaScreen(it) { navigator.back() } }
|
||||||
|
CarScreen.Browse -> BrowseAppScreen(navigator)
|
||||||
|
CarScreen.Settings -> CarSettingsScreen()
|
||||||
|
CarScreen.Dashboard -> CarDashboardScreen()
|
||||||
|
CarScreen.Weather -> WeatherScreen()
|
||||||
|
CarScreen.VehicleData -> VehicleDataScreen()
|
||||||
|
is CarScreen.Notifications -> NotificationsScreen(screen.replyTo)
|
||||||
|
is CarScreen.Messages -> MessagesScreen(screen.packageName)
|
||||||
|
is CarScreen.App -> CarApps.find(screen.id)?.content?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PopupTimer()
|
||||||
|
// The dashboard is already listing them, so it needs no popup.
|
||||||
|
NotificationPopup(
|
||||||
|
showNew = screen !is CarScreen.Notifications && screen != CarScreen.Dashboard,
|
||||||
|
modifier = Modifier.align(Alignment.TopEnd),
|
||||||
|
)
|
||||||
|
// Above the popup: a call is more urgent than any notification.
|
||||||
|
CallOverlay(Modifier.align(Alignment.TopCenter))
|
||||||
|
AssistantOverlay(Modifier.align(Alignment.TopCenter))
|
||||||
|
if (app.seb3thehacker.gearslip.BuildConfig.DEBUG) {
|
||||||
|
// Bottom corner, not top: a screen's own header almost always puts
|
||||||
|
// something in the top-right (settings, search, a Liked Songs
|
||||||
|
// shortcut) and this badge used to sit right on top of it, hiding
|
||||||
|
// whatever the screen drew there.
|
||||||
|
DevBuildBadge(Modifier.align(Alignment.BottomEnd))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The keyboard takes the nav bar's row instead of squeezing in above it -
|
||||||
|
// nothing on the nav bar is useful mid-type, and the screen is too small to
|
||||||
|
// show both.
|
||||||
|
val keyboardVisible by CarKeyboardVisibility.state
|
||||||
|
if (!keyboardVisible) CarNavBar(navigator)
|
||||||
|
}
|
||||||
|
// Over everything, nav bar included: a long press on an app, anywhere.
|
||||||
|
AppMenu(navigator, rememberRunningApps())
|
||||||
|
if (safetyWarningSeen) CarWhatsNew()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single blocking screen, shown once per install before anything else the car screen can show -
|
||||||
|
* Home included. The phone's own setup guide carries the same warning, but a driver who plugs in
|
||||||
|
* without ever opening the phone app would otherwise never see it, on the one screen they are
|
||||||
|
* actually about to use while driving. [onDone] fires once, from its own button; there is no way
|
||||||
|
* to skip past this without tapping it.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun CarSafetyWizard(onDone: () -> Unit) {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxWidth().padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||||
|
shape = MaterialTheme.shapes.extraLarge,
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text("Drive safely", style = ChromeType.headline, fontWeight = FontWeight.Bold)
|
||||||
|
Text(
|
||||||
|
"Gearslip is unofficial software: it is not made or reviewed by any " +
|
||||||
|
"carmaker, and it can fail without warning.",
|
||||||
|
style = ChromeType.body,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Never interact with this screen while driving. Set the route or the " +
|
||||||
|
"song before you go, then keep your attention on the road.",
|
||||||
|
style = ChromeType.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
GsButton(onClick = onDone, modifier = Modifier.fillMaxWidth().height(56.dp)) {
|
||||||
|
Text("I understand", style = ChromeType.body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A templated app that isn't navigation, full size with its own title bar and Stop button - the
|
||||||
|
* same [CarAppStage] chrome the map uses on Home, just not embedded there. [CarServices.browse]
|
||||||
|
* is a connection of its own, so opening one of these never bumps the map off Home.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun BrowseAppScreen(navigator: CarNavigator) {
|
||||||
|
val status by CarServices.browse.status.collectAsState()
|
||||||
|
val frame by CarEnvironment.frame.collectAsState()
|
||||||
|
val template by CarServices.browse.template.collectAsState()
|
||||||
|
val leave = {
|
||||||
|
CarServices.stopBrowse()
|
||||||
|
navigator.back()
|
||||||
|
}
|
||||||
|
// An app that never answers is as stuck as one that says no; don't leave the driver on "Loading".
|
||||||
|
var timedOut by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(template == null) {
|
||||||
|
timedOut = false
|
||||||
|
if (template == null) {
|
||||||
|
delay(APP_START_TIMEOUT_MS)
|
||||||
|
timedOut = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val failed = status.phase == CarAppConnection.Phase.REJECTED || status.phase == CarAppConnection.Phase.FAILED
|
||||||
|
if (failed || timedOut) {
|
||||||
|
AppWontStart(status.app ?: "This app", leave)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
CarAppStage(CarServices.browse, status, frame, onDisconnect = leave)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val APP_START_TIMEOUT_MS = 15_000L
|
||||||
|
|
||||||
|
/** What a car app that won't run under Gearslip gets instead of a loading screen that never ends. */
|
||||||
|
@Composable
|
||||||
|
private fun AppWontStart(app: String, onBack: () -> Unit) {
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxSize().padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text("$app doesn't work with Gearslip yet.", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
|
||||||
|
Text(
|
||||||
|
"It didn't start. Some apps only run on Google's Android Auto.",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
GsButton(onClick = onBack, tone = GsTone.Tonal) { Text("Back") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks a debug build on the car screen itself, not just the phone's Home screen - the one
|
||||||
|
* that matters while driving, since a debug build is slower and less tested than what ships.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun DevBuildBadge(modifier: Modifier = Modifier) {
|
||||||
|
Surface(
|
||||||
|
modifier = modifier.padding(6.dp),
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onError,
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"DEV BUILD",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "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,
|
||||||
|
) {
|
||||||
|
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", navigator::back, size = 32.dp, iconSize = 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
|
||||||
|
|
||||||
|
val pinnedIds by CarSettings.pinnedApps.collectAsState()
|
||||||
|
val entries = rememberAllEntries()
|
||||||
|
val pinnedEntries = remember(entries, pinnedIds) {
|
||||||
|
pinnedIds.mapNotNull { id -> entries.find { it.componentId == id } }
|
||||||
|
}
|
||||||
|
val frame by CarEnvironment.frame.collectAsState()
|
||||||
|
val running = rememberRunningApps()
|
||||||
|
|
||||||
|
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.
|
||||||
|
NavHomeButton(navigator, entries, running)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() }
|
||||||
|
val experimentalFeatures by CarSettings.experimentalFeaturesEnabled.collectAsState()
|
||||||
|
val voiceEnabled by CarSettings.voiceAssistantEnabled.collectAsState()
|
||||||
|
if (experimentalFeatures && voiceEnabled) {
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
NavAssistantIcon()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pinned shortcuts, then whatever else is open right now. A pinned app that's also
|
||||||
|
// open stays in its pinned spot, marked with a dot, instead of showing up twice.
|
||||||
|
// The map app lives on the Home button, so it never gets a second icon here.
|
||||||
|
val runningEntries = running.keys
|
||||||
|
.filter { it !in pinnedIds && running[it] != RunningSlot.NAV }
|
||||||
|
.mapNotNull { id -> entries.find { it.componentId == id } }
|
||||||
|
val shownPins = pinnedEntries.filter { running[it.componentId] != RunningSlot.NAV }
|
||||||
|
if (shownPins.isNotEmpty() || runningEntries.isNotEmpty()) NavDivider()
|
||||||
|
shownPins.forEach { entry -> NavEntryIcon(entry, running, navigator, frame) }
|
||||||
|
if (shownPins.isNotEmpty() && runningEntries.isNotEmpty()) NavDivider()
|
||||||
|
runningEntries.forEach { entry -> NavEntryIcon(entry, running, navigator, frame) }
|
||||||
|
|
||||||
|
// Next turn first, off the map only; the player shares the space when it fits,
|
||||||
|
// and gives way to the turn when it doesn't.
|
||||||
|
val turn = rememberNextTurn().takeIf { navigator.current != CarScreen.Home }
|
||||||
|
BoxWithConstraints(Modifier.weight(1f).padding(horizontal = 16.dp), contentAlignment = Alignment.Center) {
|
||||||
|
val roomForBoth = maxWidth >= TURN_AND_PLAYER_MIN_WIDTH
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
turn?.let { NavTurnPill(it) { navigator.home() } }
|
||||||
|
if (turn == null || roomForBoth) {
|
||||||
|
NavNowPlaying(navigator, selected = navigator.current == CarScreen.Media, modifier = Modifier.weight(1f, fill = false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
// The clock is also the notifications button: the dashboard lists them under the
|
||||||
|
// calendar and weather, and a dot here marks anything unread.
|
||||||
|
val unread by CarNotifications.unread.collectAsState()
|
||||||
|
NavChip(
|
||||||
|
navigator.current == CarScreen.Dashboard || navigator.current is CarScreen.Notifications,
|
||||||
|
{ navigator.dashboard() },
|
||||||
|
) {
|
||||||
|
if (unread > 0) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.offset(x = 8.dp, y = (-2).dp)
|
||||||
|
.size(8.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.primary),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Home: the map, with the player or lyrics beside it. While a map app runs, the button wears that
|
||||||
|
* app's icon and a long press offers to close it, so the app needs no icon of its own on the bar.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun NavHomeButton(navigator: CarNavigator, entries: List<Entry>, running: Map<String, RunningSlot>) {
|
||||||
|
val selected = navigator.current == CarScreen.Home
|
||||||
|
val navId = running.entries.firstOrNull { it.value == RunningSlot.NAV }?.key
|
||||||
|
val navEntry = navId?.let { id -> entries.find { it.componentId == id } }
|
||||||
|
GsIconBox(
|
||||||
|
{ navigator.home() },
|
||||||
|
colors = navColors(selected),
|
||||||
|
latched = selected,
|
||||||
|
onLongClick = navId?.let { id -> { navigator.showAppMenu(id) } },
|
||||||
|
) {
|
||||||
|
val icon = navEntry?.icon
|
||||||
|
if (icon != null) {
|
||||||
|
Box(Modifier.padding(NAV_ITEM_PADDING).size(28.dp), contentAlignment = Alignment.Center) {
|
||||||
|
Image(icon, navEntry.label, Modifier.size(28.dp).clip(RoundedCornerShape(7.dp)))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
ImageVector.vectorResource(R.drawable.navigation_24), contentDescription = "Home",
|
||||||
|
modifier = Modifier.padding(NAV_ITEM_PADDING).size(28.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Splits the nav bar's sections: Home and Apps, pinned apps, open apps. */
|
||||||
|
@Composable
|
||||||
|
private fun NavDivider() {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.padding(horizontal = 10.dp)
|
||||||
|
.width(1.dp)
|
||||||
|
.height(28.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.outlineVariant),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pinned or open app on the nav bar. Tapping brings it back without reconnecting if it's
|
||||||
|
* already open; a long press opens its menu (pin, unpin, close).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun NavEntryIcon(
|
||||||
|
entry: Entry,
|
||||||
|
running: Map<String, RunningSlot>,
|
||||||
|
navigator: CarNavigator,
|
||||||
|
frame: CarEnvironment.Frame,
|
||||||
|
) {
|
||||||
|
val id = entry.componentId ?: return
|
||||||
|
val slot = running[id]
|
||||||
|
Box(Modifier.padding(horizontal = 3.dp)) {
|
||||||
|
NavAppIcon(
|
||||||
|
entry,
|
||||||
|
open = slot != null,
|
||||||
|
showing = slot != null && slot.isShowing(navigator.current),
|
||||||
|
onLongClick = { navigator.showAppMenu(id) },
|
||||||
|
) { openApp(entry, slot, navigator, frame) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One open or pinned app's icon on the nav bar - the same fixed size and shape either way. */
|
||||||
|
@Composable
|
||||||
|
private fun NavAppIcon(
|
||||||
|
entry: Entry,
|
||||||
|
open: Boolean,
|
||||||
|
showing: Boolean,
|
||||||
|
onLongClick: () -> Unit,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
GsIconBox(
|
||||||
|
onClick,
|
||||||
|
Modifier.size(44.dp),
|
||||||
|
colors = if (showing) GsColors(scheme.secondaryContainer, scheme.onSecondaryContainer)
|
||||||
|
else GsColors(scheme.surfaceVariant, scheme.onSurfaceVariant),
|
||||||
|
latched = showing,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
) {
|
||||||
|
entry.icon?.let { Image(it, entry.label, Modifier.size(30.dp).clip(RoundedCornerShape(8.dp))) }
|
||||||
|
entry.builtIn?.let { Icon(it.glyph, entry.label, Modifier.size(26.dp)) }
|
||||||
|
// Only running apps get the line along the bottom edge, the usual taskbar mark for it.
|
||||||
|
if (open) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.padding(bottom = 2.dp)
|
||||||
|
.size(width = 12.dp, height = 3.dp)
|
||||||
|
.clip(RoundedCornerShape(50))
|
||||||
|
.background(scheme.primary),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 BoxScope.() -> Unit) {
|
||||||
|
GsIconBox(onClick, colors = navColors(selected), latched = selected) {
|
||||||
|
Box(Modifier.padding(horizontal = 10.dp, vertical = 2.dp)) { content() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every nav bar item is raised, so it reads as pressable; the current screen's takes the accent colour. */
|
||||||
|
@Composable
|
||||||
|
private fun navColors(selected: Boolean): GsColors {
|
||||||
|
val s = MaterialTheme.colorScheme
|
||||||
|
return if (selected) GsColors(s.secondaryContainer, s.onSecondaryContainer)
|
||||||
|
else GsColors(s.surfaceContainerHighest, s.onSurface)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Between Apps and whatever's actually open, not with the clock/battery group on the right - a
|
||||||
|
* driver reaches for it right after deciding "not a tap for this", same gesture family as the
|
||||||
|
* two buttons beside it.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun NavAssistantIcon() {
|
||||||
|
val state by CarAssistant.state.collectAsState()
|
||||||
|
val active = state.phase != CarAssistant.Phase.IDLE
|
||||||
|
GsIconBox({ CarAssistant.start() }, colors = navColors(active), latched = active) {
|
||||||
|
MicGlyph(Modifier.padding(NAV_ITEM_PADDING).size(28.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inner padding of every icon on the nav bar, leaving each button room inside the 56dp bar. */
|
||||||
|
private val NAV_ITEM_PADDING = 8.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NavItem(icon: ImageVector, selected: Boolean, onClick: () -> Unit) {
|
||||||
|
GsIconBox(onClick, colors = navColors(selected), latched = selected) {
|
||||||
|
Icon(icon, contentDescription = null, modifier = Modifier.padding(NAV_ITEM_PADDING).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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long the pill shows a new song's title before settling to just the controls. */
|
||||||
|
private const val TITLE_SHOWN_MS = 3_000L
|
||||||
|
|
||||||
|
/** Below this, the nav bar's middle only has room for the turn, not the player beside it. */
|
||||||
|
private val TURN_AND_PLAYER_MIN_WIDTH = 520.dp
|
||||||
|
|
||||||
|
/** The next manoeuvre, as the running nav app last described it. */
|
||||||
|
private class NextTurn(val icon: CarIcon?, val distance: String, val road: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read straight off the nav app's own template: a navigation app keeps sending its routing card
|
||||||
|
* while it's running, whether or not the map is the screen being shown.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun rememberNextTurn(): NextTurn? {
|
||||||
|
val status by CarServices.nav.status.collectAsState()
|
||||||
|
val template by CarServices.nav.template.collectAsState()
|
||||||
|
// Read so a change of units redraws the distance straight away, not at the next update.
|
||||||
|
CarSettings.units.collectAsState().value
|
||||||
|
if (status.phase != CarAppConnection.Phase.RUNNING) return null
|
||||||
|
val routing = (template as? NavigationTemplate)?.navigationInfo as? RoutingInfo ?: return null
|
||||||
|
if (routing.isLoading) return null
|
||||||
|
val step = routing.currentStep ?: return null
|
||||||
|
return NextTurn(
|
||||||
|
icon = step.maneuver?.icon,
|
||||||
|
distance = routing.currentDistance.display(),
|
||||||
|
road = step.road.text().ifEmpty { step.cue.text() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The next turn in the nav bar: arrow, how far, onto what. Tapping goes back to the map. */
|
||||||
|
@Composable
|
||||||
|
private fun NavTurnPill(turn: NextTurn, onClick: () -> Unit) {
|
||||||
|
// Same finish as every other button on the bar, in the accent colour; the text runs a step
|
||||||
|
// bigger than anything else there, since it's the one thing worth a glance mid-drive.
|
||||||
|
GsIconBox(
|
||||||
|
onClick,
|
||||||
|
Modifier.widthIn(max = 340.dp).height(44.dp),
|
||||||
|
colors = gsColors(GsTone.Primary),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.padding(NAV_ITEM_PADDING),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
CarGlyph(turn.icon, Modifier.size(28.dp))
|
||||||
|
if (turn.distance.isNotEmpty()) {
|
||||||
|
Text(turn.distance, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, maxLines = 1)
|
||||||
|
}
|
||||||
|
if (turn.road.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
turn.road,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Title - Artist" on one line. The text itself cuts it off, and only where the pill runs out of room. */
|
||||||
|
private fun nowPlayingLabel(title: String, artist: String): String =
|
||||||
|
listOf(title.trim(), artist.trim()).filter { it.isNotEmpty() }.joinToString(" - ")
|
||||||
|
.ifEmpty { "Nothing playing" }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The player in the nav bar, on every screen. Art and title open the full media screen;
|
||||||
|
* play/pause and skip work right here. The dock button moves the player into the column beside
|
||||||
|
* the map on Home (see [CarHome]), and the pill steps aside there while it's docked - unless the
|
||||||
|
* column is showing lyrics instead, when the pill stays and its dock button swaps them back.
|
||||||
|
*/
|
||||||
|
@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
|
||||||
|
// Docked beside the map, the player is already on screen - the pill steps aside for it.
|
||||||
|
if (navigator.sidePanel == SidePanel.CONTROLS && navigator.current == CarScreen.Home) return
|
||||||
|
// The media screen is the full player already; the pill would only repeat its controls.
|
||||||
|
if (navigator.current == CarScreen.Media) return
|
||||||
|
val context = LocalContext.current
|
||||||
|
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
|
||||||
|
val position by produceState(now.currentPosition(), now) {
|
||||||
|
while (true) {
|
||||||
|
value = now.currentPosition()
|
||||||
|
if (!now.playing) break
|
||||||
|
delay(1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A new song announces itself: title, play/pause and dock for a few seconds, then the title
|
||||||
|
// goes and the pill settles to just the controls - unless the driver keeps the title up.
|
||||||
|
val keepTitle by CarSettings.playerTitle.collectAsState()
|
||||||
|
var announcing by remember { mutableStateOf(true) }
|
||||||
|
LaunchedEffect(now.title, now.artist) {
|
||||||
|
announcing = true
|
||||||
|
delay(TITLE_SHOWN_MS)
|
||||||
|
announcing = false
|
||||||
|
}
|
||||||
|
val showTitle = keepTitle || announcing
|
||||||
|
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
val onPill = if (selected) scheme.onPrimaryContainer else scheme.onSecondaryContainer
|
||||||
|
Surface(
|
||||||
|
modifier = modifier
|
||||||
|
.animateContentSize()
|
||||||
|
// With the title up, all the room the bar has, so as much of it shows as can.
|
||||||
|
.then(if (showTitle) Modifier.fillMaxWidth() else Modifier)
|
||||||
|
.height(44.dp),
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
color = if (selected) scheme.primaryContainer else scheme.secondaryContainer,
|
||||||
|
contentColor = onPill,
|
||||||
|
) {
|
||||||
|
Box {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxHeight().padding(horizontal = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
// Art (and the title while it shows) opens the full media screen.
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.then(if (showTitle) Modifier.weight(1f) else Modifier)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.clip(RoundedCornerShape(10.dp))
|
||||||
|
.clickable { navigator.media() },
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Box(Modifier.size(34.dp).clip(RoundedCornerShape(8.dp)).background(scheme.surfaceVariant)) {
|
||||||
|
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
||||||
|
}
|
||||||
|
if (showTitle) {
|
||||||
|
Text(
|
||||||
|
nowPlayingLabel(now.title, now.artist),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!showTitle) {
|
||||||
|
GsIconButton(
|
||||||
|
MediaIcons.Previous, "Previous", media::previous,
|
||||||
|
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), size = 36.dp, iconSize = 22.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
GsIconButton(
|
||||||
|
if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow,
|
||||||
|
if (now.playing) "Pause" else "Play",
|
||||||
|
media::togglePlay,
|
||||||
|
tone = GsTone.Primary, size = 36.dp, iconSize = 22.dp,
|
||||||
|
)
|
||||||
|
if (!showTitle) {
|
||||||
|
GsIconButton(
|
||||||
|
MediaIcons.Next, "Next", media::next,
|
||||||
|
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_NEXT), size = 36.dp, iconSize = 22.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
GsIconButton(
|
||||||
|
MediaIcons.DockRight, "Move the player beside the map", navigator::togglePlayerDock,
|
||||||
|
size = 36.dp, iconSize = 22.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (now.durationMs > 0) {
|
||||||
|
// matchParentSize, so the line follows the pill's width rather than widening it.
|
||||||
|
Box(Modifier.matchParentSize(), contentAlignment = Alignment.BottomStart) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth((position.toFloat() / now.durationMs).coerceIn(0f, 1f))
|
||||||
|
.height(2.dp)
|
||||||
|
.background(onPill.copy(alpha = 0.7f)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 rather than a vector asset.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ChargingGlyph(modifier: Modifier = Modifier, tint: Color = MaterialTheme.colorScheme.onSurfaceVariant) {
|
||||||
|
androidx.compose.foundation.Canvas(modifier) {
|
||||||
|
val w = size.width
|
||||||
|
val h = size.height
|
||||||
|
val bolt = androidx.compose.ui.graphics.Path().apply {
|
||||||
|
moveTo(w * 0.58f, 0f)
|
||||||
|
lineTo(w * 0.12f, h * 0.58f)
|
||||||
|
lineTo(w * 0.46f, h * 0.58f)
|
||||||
|
lineTo(w * 0.42f, h)
|
||||||
|
lineTo(w * 0.88f, h * 0.40f)
|
||||||
|
lineTo(w * 0.54f, h * 0.40f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
drawPath(bolt, tint)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
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.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.widthIn
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
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.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.seb3thehacker.gearslip.Changelog
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsButton
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsSwitch
|
||||||
|
import app.seb3thehacker.gearslip.notify.CarMotion
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What's new in this build, once, the first time the car screen shows after an update - or any
|
||||||
|
* time from Settings > About. Held back while the car is moving: a list to read is for when
|
||||||
|
* parked. The switch turns the automatic showing off (or back on); it doesn't close the card.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CarWhatsNew() {
|
||||||
|
val release = Changelog.current ?: return
|
||||||
|
val afterUpdates by CarSettings.whatsNewAfterUpdates.collectAsState()
|
||||||
|
val seen by CarSettings.whatsNewSeen.collectAsState()
|
||||||
|
val open by CarSettings.whatsNewOpen.collectAsState()
|
||||||
|
// Polled: CarMotion is a plain value, and the card should appear once the car stops.
|
||||||
|
val moving by produceState(CarMotion.moving) {
|
||||||
|
while (true) {
|
||||||
|
value = CarMotion.moving
|
||||||
|
delay(5_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val due = afterUpdates && seen < release.versionCode && !moving
|
||||||
|
if (!open && !due) return
|
||||||
|
val close = { CarSettings.closeWhatsNew(release.versionCode) }
|
||||||
|
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black.copy(alpha = 0.5f))
|
||||||
|
.clickable(remember { MutableInteractionSource() }, indication = null, onClick = close),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(24.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
// Swallows taps on the card itself, so only the scrim around it closes.
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
.widthIn(max = 640.dp)
|
||||||
|
.clickable(remember { MutableInteractionSource() }, indication = null) {},
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||||
|
Text(
|
||||||
|
"What's new in ${release.versionName}",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
release.changes.forEach { change ->
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.padding(top = 9.dp)
|
||||||
|
.size(7.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.primary, CircleShape),
|
||||||
|
)
|
||||||
|
Text(change, style = MaterialTheme.typography.bodyLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||||
|
GsSwitch(afterUpdates, CarSettings::setWhatsNewAfterUpdates)
|
||||||
|
Text(
|
||||||
|
"Show after updates",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
GsButton(onClick = close, modifier = Modifier.height(56.dp)) {
|
||||||
|
Text("Got it", style = MaterialTheme.typography.titleMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import android.os.SystemClock
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the map's buttons (settings, pan, zoom, locate) are showing.
|
||||||
|
*
|
||||||
|
* They fade out after [IDLE_MS] with no touch anywhere on the car screen, to leave the map clear,
|
||||||
|
* and any touch brings them back. [touch] is called for every pointer event and is cheap: it only
|
||||||
|
* writes state when the buttons were actually hidden.
|
||||||
|
*/
|
||||||
|
class MapControlsWake {
|
||||||
|
|
||||||
|
var awake by mutableStateOf(true)
|
||||||
|
private set
|
||||||
|
|
||||||
|
private var lastTouch = SystemClock.uptimeMillis()
|
||||||
|
|
||||||
|
fun touch() {
|
||||||
|
lastTouch = SystemClock.uptimeMillis()
|
||||||
|
if (!awake) awake = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called on a timer; hides the buttons once nothing has touched the screen for [IDLE_MS]. */
|
||||||
|
fun tick() {
|
||||||
|
if (awake && SystemClock.uptimeMillis() - lastTouch >= IDLE_MS) awake = false
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val IDLE_MS = 4_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalMapControlsWake = staticCompositionLocalOf { MapControlsWake() }
|
||||||
@@ -0,0 +1,539 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.*
|
||||||
|
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.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.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.PathFillType
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
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) }
|
||||||
|
|
||||||
|
val art = rememberArt(now)
|
||||||
|
|
||||||
|
// The art's colour behind everything, header included; the buttons keep their own.
|
||||||
|
Surface(Modifier.fillMaxSize(), color = rememberArtColor(art, MaterialTheme.colorScheme.background, darkLightness = 0.15f, lightLightness = 0.9f)) {
|
||||||
|
if (phase != CarMedia.Phase.READY) {
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
AppIdentity(app, onExit, Modifier.padding(horizontal = 16.dp, vertical = 10.dp))
|
||||||
|
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.NEEDS_APP_RUNNING -> Notice(
|
||||||
|
"${app.label} isn't running.",
|
||||||
|
"${app.label} plays through Gearslip's own player, but only while it's " +
|
||||||
|
"open on the phone. Open it once and it keeps playing here in the car.",
|
||||||
|
action = "Open ${app.label}" to { media.launchAndConnect(app) },
|
||||||
|
)
|
||||||
|
CarMedia.Rejection.REFUSED -> Notice(
|
||||||
|
"${app.label} would not let Gearslip browse it.",
|
||||||
|
"Some media apps only accept Google's own host.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> Notice("Connecting to ${app.label}…", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return@Surface
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two columns, split the same way on every tab so nothing jumps when switching: the app
|
||||||
|
// and the art (or, beside a list, the compact player) on the left; the tabs across the
|
||||||
|
// top of the right column, over whatever the tab shows.
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxSize().padding(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
|
) {
|
||||||
|
Column(Modifier.weight(0.8f).fillMaxHeight()) {
|
||||||
|
AppIdentity(app, onExit)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
if (tab == Tab.NOW_PLAYING) {
|
||||||
|
CoverArt(art, Modifier.weight(1f).fillMaxWidth())
|
||||||
|
} else {
|
||||||
|
CompactPlayer(now, media, Modifier.weight(1f).fillMaxWidth())
|
||||||
|
}
|
||||||
|
AudioBadge(capture)
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1.2f).fillMaxHeight()) {
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.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(12.dp))
|
||||||
|
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||||
|
when (tab) {
|
||||||
|
Tab.NOW_PLAYING -> NowPlayingControls(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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Back, then which app this is: its own icon and its name, quieter than the song title - the
|
||||||
|
* name alone in big bold type read as one more heading fighting the track for attention.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun AppIdentity(app: MediaApp, onExit: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
|
val icon = remember(app) { findEntry(app.component.flattenToString())?.icon }
|
||||||
|
Row(modifier, verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
GsIconButton(Icons.Filled.ArrowBack, "Back", onExit, size = 44.dp)
|
||||||
|
Spacer(Modifier.width(14.dp))
|
||||||
|
icon?.let {
|
||||||
|
Image(it, null, Modifier.size(26.dp).clip(RoundedCornerShape(7.dp)))
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
app.label.lowercase().replaceFirstChar { it.titlecase() },
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
GsIconBox(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier.height(44.dp),
|
||||||
|
colors = if (selected) GsColors(MaterialTheme.colorScheme.primaryContainer, MaterialTheme.colorScheme.onPrimaryContainer)
|
||||||
|
else GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface),
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
latched = selected,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
lineStyle: androidx.compose.ui.text.TextStyle = MaterialTheme.typography.titleLarge,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val lyrics by produceState<LyricsState>(LyricsState.Loading, now.title, now.artist, now.album, now.durationMs) {
|
||||||
|
value = LyricsState.Loading
|
||||||
|
value = Lyrics.find(context, 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 = lineStyle,
|
||||||
|
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))
|
||||||
|
GsButton(onClick = run, tone = GsTone.Tonal) { Text(label) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- now playing -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The now-playing view on its own, for places with no tabs around it (an app's own
|
||||||
|
* now-playing template): the cover on the left, [NowPlayingControls] on the right.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun NowPlayingPage(now: NowPlaying, media: CarMedia, modifier: Modifier) {
|
||||||
|
Row(
|
||||||
|
modifier.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
|
) {
|
||||||
|
CoverArt(rememberArt(now), Modifier.weight(0.8f).fillMaxHeight())
|
||||||
|
NowPlayingControls(now, media, Modifier.weight(1.2f).fillMaxHeight())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The cover, as big a square as its space allows, centred in it. */
|
||||||
|
@Composable
|
||||||
|
private fun CoverArt(art: android.graphics.Bitmap?, modifier: Modifier) {
|
||||||
|
BoxWithConstraints(modifier, contentAlignment = Alignment.Center) {
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
shadowElevation = 6.dp,
|
||||||
|
modifier = Modifier.size(minOf(maxWidth, maxHeight)),
|
||||||
|
) {
|
||||||
|
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything about the track and every control for it, read top to bottom: title and artist,
|
||||||
|
* the seek bar, the transport, then like, shuffle and repeat as big icon-only buttons.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun NowPlayingControls(now: NowPlaying, media: CarMedia, modifier: Modifier) {
|
||||||
|
Column(
|
||||||
|
modifier.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(14.dp, Alignment.CenterVertically),
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
now.title.ifEmpty { "Nothing playing" },
|
||||||
|
style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
listOf(now.artist, now.album).filter { it.isNotEmpty() }.joinToString(" - "),
|
||||||
|
style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
now.error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, maxLines = 2) }
|
||||||
|
}
|
||||||
|
PlayerProgress(now, media, Modifier.fillMaxWidth())
|
||||||
|
TransportRow(now, media, skip = 60.dp, modifier = Modifier.fillMaxWidth())
|
||||||
|
PlayModeRow(now, media, size = 56.dp, modifier = Modifier.fillMaxWidth(), maxOther = 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The player beside the browse list, the queue and the lyrics: no art, which the list needs the
|
||||||
|
* room for, just what's playing, the seek bar and the controls.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun CompactPlayer(now: NowPlaying, media: CarMedia, modifier: Modifier) {
|
||||||
|
Column(
|
||||||
|
modifier.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
now.title.ifEmpty { "Nothing playing" },
|
||||||
|
style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
now.artist,
|
||||||
|
style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
PlayerProgress(now, media, Modifier.fillMaxWidth())
|
||||||
|
TransportRow(now, media, skip = 52.dp, modifier = Modifier.fillMaxWidth())
|
||||||
|
PlayModeRow(now, media, size = 48.dp, modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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) {
|
||||||
|
GsIconButton(Icons.Filled.ArrowBack, "Up", media::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 the player beside it 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(62.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(62.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) {
|
||||||
|
GsIconButton(Icons.Filled.PlayArrow, "Play", { media.playAll(entry) })
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
val Equalizer = icon("Equalizer", "M10,20h4L14,4h-4v16zM4,20h4v-8L4,12v8zM16,9v11h4L20,9h-4z")
|
||||||
|
val Shuffle = icon("Shuffle", "M10.59,9.17L5.41,4 4,5.41l5.17,5.17 1.42,-1.41zM14.5,4l2.04,2.04L4,18.59 5.41,20 17.96,7.46 20,9.5L20,4h-5.5zM14.83,13.41l-1.41,1.41 3.13,3.13L14.5,20L20,20v-5.5l-2.04,2.04 -3.13,-3.13z")
|
||||||
|
val Repeat = icon("Repeat", "M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4z")
|
||||||
|
val RepeatOne = icon("RepeatOne", "M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4zM13,15L13,9h-1l-2,1v1h1.5v4L13,15z")
|
||||||
|
/** A screen with a filled right-hand panel: "put the player beside the map". */
|
||||||
|
val DockRight = icon("DockRight", "M2,4h20v16H2zM4,6h10v12H4z", PathFillType.EvenOdd)
|
||||||
|
|
||||||
|
private fun icon(name: String, path: String, fillType: PathFillType = PathFillType.NonZero) = ImageVector.Builder(
|
||||||
|
name, 24.dp, 24.dp, 24f, 24f,
|
||||||
|
).addPath(addPathNodes(path), pathFillType = fillType, fill = SolidColor(androidx.compose.ui.graphics.Color.Black)).build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import android.os.SystemClock
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
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.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
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.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.runtime.withFrameMillis
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.res.vectorResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.seb3thehacker.gearslip.R
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsButton
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsIconButton
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsProgressBar
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsTone
|
||||||
|
import app.seb3thehacker.gearslip.notify.CarNotification
|
||||||
|
import app.seb3thehacker.gearslip.notify.CarNotifications
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A new message on the car screen: a card at the top right, over the player beside the map on
|
||||||
|
* Home, so the road stays in view. It offers two things - hear it, or answer it by voice - and
|
||||||
|
* shows [VoiceReply]'s progress once either starts. A bar along its bottom empties as the card
|
||||||
|
* runs out of time, whatever it's waiting to do: go away, or send.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** How long a new message stays up on its own. */
|
||||||
|
private const val POPUP_MS = 5_000L
|
||||||
|
|
||||||
|
/** When [this] popup goes away on its own. */
|
||||||
|
private fun CarNotification.until() = shownAt + POPUP_MS
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Times the popup out. Each new message in the same chat starts the clock again. A voice reply
|
||||||
|
* covers the card while it runs; if the popup's time ran out meanwhile, it goes when the reply ends.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun PopupTimer() {
|
||||||
|
val popup by CarNotifications.popup.collectAsState()
|
||||||
|
val voice by VoiceReply.state.collectAsState()
|
||||||
|
val n = popup ?: return
|
||||||
|
val busy = voice.phase != VoiceReply.Phase.IDLE
|
||||||
|
LaunchedEffect(n.id, n.shownAt, busy) {
|
||||||
|
if (busy) return@LaunchedEffect
|
||||||
|
delay((n.until() - SystemClock.uptimeMillis()).coerceAtLeast(0L))
|
||||||
|
CarNotifications.dismissPopup(n.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun ReadGlyph(modifier: Modifier = Modifier) =
|
||||||
|
Icon(ImageVector.vectorResource(R.drawable.volume_up_24), null, modifier)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun ReplyGlyph(modifier: Modifier = Modifier) =
|
||||||
|
Icon(ImageVector.vectorResource(R.drawable.voice_selection_24), null, modifier)
|
||||||
|
|
||||||
|
/** "Alex", or "Alex · 3 new" when a burst of messages from one chat has been merged. */
|
||||||
|
private fun CarNotification.heading(): String {
|
||||||
|
val who = title.ifBlank { appLabel }
|
||||||
|
return if (burst > 1) "$who · $burst new" else who
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The card. It shows the voice reply too, wherever that was started from. */
|
||||||
|
@Composable
|
||||||
|
fun NotificationPopup(showNew: Boolean, modifier: Modifier = Modifier) {
|
||||||
|
val popup by CarNotifications.popup.collectAsState()
|
||||||
|
val voice by VoiceReply.state.collectAsState()
|
||||||
|
val target = voice.target
|
||||||
|
if (voice.phase != VoiceReply.Phase.IDLE && target != null) {
|
||||||
|
PopupCard(voice.until, voice.span, modifier, held = voice.held) { VoiceReplyBody(voice, target) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val n = popup?.takeIf { showNew } ?: return
|
||||||
|
PopupCard(n.until(), POPUP_MS, modifier) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
NotificationIcon(n, size = 44.dp)
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(n.heading(), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
Text(n.text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
GsIconButton(Icons.Filled.Close, "Dismiss", { CarNotifications.dismissPopup(n.id) }, size = 40.dp, iconSize = 20.dp)
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
GsButton(onClick = { VoiceReply.read(n) }, tone = GsTone.Neutral, modifier = Modifier.weight(1f)) {
|
||||||
|
ReadGlyph(Modifier.size(20.dp))
|
||||||
|
Text("Read", Modifier.padding(start = 8.dp))
|
||||||
|
}
|
||||||
|
if (n.reply != null) {
|
||||||
|
GsButton(onClick = { VoiceReply.reply(n) }, modifier = Modifier.weight(1f)) {
|
||||||
|
ReplyGlyph(Modifier.size(20.dp))
|
||||||
|
Text("Reply", Modifier.padding(start = 8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The card's frame. When [until] is set, a bar along the bottom empties over [span] ms, so the
|
||||||
|
* driver sees at a glance how long is left before the card acts. A [held] countdown shows its bar
|
||||||
|
* standing still.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun PopupCard(until: Long, span: Long, modifier: Modifier, held: Float = 0f, content: @Composable () -> Unit) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(20.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
shadowElevation = 8.dp,
|
||||||
|
modifier = modifier.padding(12.dp).widthIn(min = 300.dp, max = 400.dp).fillMaxWidth(0.34f),
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
content()
|
||||||
|
if (until > 0L && span > 0L) {
|
||||||
|
val left by rememberTimeLeft(until, span)
|
||||||
|
GsProgressBar(left, Modifier.fillMaxWidth(), height = 14.dp)
|
||||||
|
} else if (held > 0f) {
|
||||||
|
GsProgressBar(held, Modifier.fillMaxWidth(), height = 14.dp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The share of [span] still to go before [until], updated every frame so the bar moves smoothly. */
|
||||||
|
@Composable
|
||||||
|
private fun rememberTimeLeft(until: Long, span: Long) = produceState(fractionLeft(until, span), until, span) {
|
||||||
|
// The state outlives a change of deadline, so start from the new one rather than the old value.
|
||||||
|
value = fractionLeft(until, span)
|
||||||
|
while (value > 0f) {
|
||||||
|
withFrameMillis { value = fractionLeft(until, span) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fractionLeft(until: Long, span: Long): Float =
|
||||||
|
((until - SystemClock.uptimeMillis()).toFloat() / span).coerceIn(0f, 1f)
|
||||||
|
|
||||||
|
/** Each step of a voice reply, with the buttons that fit it. */
|
||||||
|
@Composable
|
||||||
|
private fun VoiceReplyBody(s: VoiceReply.State, n: CarNotification) {
|
||||||
|
val navigator = LocalCarNavigator.current
|
||||||
|
val who = n.title.ifBlank { n.appLabel }
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
when (s.phase) {
|
||||||
|
VoiceReply.Phase.OFFERING, VoiceReply.Phase.ASKING, VoiceReply.Phase.LISTENING -> ListeningDot()
|
||||||
|
VoiceReply.Phase.SENT -> Icon(Icons.Filled.Check, null, Modifier.size(32.dp), tint = MaterialTheme.colorScheme.primary)
|
||||||
|
else -> NotificationIcon(n, size = 44.dp)
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(voiceHeading(s, who), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
voiceDetail(s, n)?.let {
|
||||||
|
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 3, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (s.phase == VoiceReply.Phase.CONFIRMING) {
|
||||||
|
Text("Say “cancel” or “change it”", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
|
if ((s.phase == VoiceReply.Phase.READ || s.phase == VoiceReply.Phase.OFFERING) && n.reply != null) {
|
||||||
|
QuickReplies(n)
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
val wide = Modifier.weight(1f)
|
||||||
|
when (s.phase) {
|
||||||
|
VoiceReply.Phase.READING, VoiceReply.Phase.READ -> {
|
||||||
|
GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) {
|
||||||
|
Text(if (s.phase == VoiceReply.Phase.READING) "Stop" else "Close")
|
||||||
|
}
|
||||||
|
if (n.reply != null) {
|
||||||
|
GsButton(onClick = { VoiceReply.reply(n, readFirst = false) }, modifier = wide) {
|
||||||
|
ReplyGlyph(Modifier.size(20.dp))
|
||||||
|
Text("Reply", Modifier.padding(start = 8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VoiceReply.Phase.OFFERING -> {
|
||||||
|
GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text("No") }
|
||||||
|
GsButton(onClick = { VoiceReply.reply(n, readFirst = false) }, modifier = wide) {
|
||||||
|
ReplyGlyph(Modifier.size(20.dp))
|
||||||
|
Text("Reply", Modifier.padding(start = 8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VoiceReply.Phase.ASKING, VoiceReply.Phase.LISTENING ->
|
||||||
|
GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text("Cancel") }
|
||||||
|
VoiceReply.Phase.CONFIRMING -> {
|
||||||
|
GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text("Cancel") }
|
||||||
|
GsButton(onClick = VoiceReply::sendNow, modifier = wide) { Text("Send now") }
|
||||||
|
}
|
||||||
|
VoiceReply.Phase.ERROR -> {
|
||||||
|
GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text("Close") }
|
||||||
|
if (n.reply != null) {
|
||||||
|
GsButton(onClick = { VoiceReply.reply(n, readFirst = false) }, tone = GsTone.Tonal, modifier = wide) { Text("Try again") }
|
||||||
|
GsButton(
|
||||||
|
onClick = { VoiceReply.cancel(); navigator.notifications(replyTo = n.key) },
|
||||||
|
tone = GsTone.Neutral, modifier = wide,
|
||||||
|
) { Text("Type") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VoiceReply.Phase.SENT, VoiceReply.Phase.IDLE -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun voiceHeading(s: VoiceReply.State, who: String): String = when (s.phase) {
|
||||||
|
VoiceReply.Phase.READING, VoiceReply.Phase.READ -> who
|
||||||
|
VoiceReply.Phase.OFFERING, VoiceReply.Phase.ASKING -> "Reply to $who"
|
||||||
|
VoiceReply.Phase.LISTENING -> "Listening"
|
||||||
|
VoiceReply.Phase.CONFIRMING -> "Sending to $who"
|
||||||
|
VoiceReply.Phase.SENT -> "Sent to $who"
|
||||||
|
VoiceReply.Phase.ERROR -> "Couldn't reply"
|
||||||
|
VoiceReply.Phase.IDLE -> ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun voiceDetail(s: VoiceReply.State, n: CarNotification): String? = when (s.phase) {
|
||||||
|
VoiceReply.Phase.READING, VoiceReply.Phase.READ -> n.text
|
||||||
|
VoiceReply.Phase.OFFERING -> s.heard.ifBlank { "Do you want to reply? Say yes or no." }
|
||||||
|
VoiceReply.Phase.ASKING -> "Get ready to say your reply."
|
||||||
|
VoiceReply.Phase.LISTENING -> s.heard.ifBlank { "Say your reply" }
|
||||||
|
VoiceReply.Phase.CONFIRMING, VoiceReply.Phase.SENT -> "“${s.heard}”"
|
||||||
|
VoiceReply.Phase.ERROR -> s.error
|
||||||
|
VoiceReply.Phase.IDLE -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ready-made answers, one tap each: sent at once, with no voice. */
|
||||||
|
@Composable
|
||||||
|
internal fun QuickReplies(n: CarNotification, modifier: Modifier = Modifier) {
|
||||||
|
Row(
|
||||||
|
modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
VoiceReply.quickReplies().forEach { text ->
|
||||||
|
GsButton(onClick = { VoiceReply.quickReply(n, text) }, tone = GsTone.Neutral) {
|
||||||
|
Text(text, style = MaterialTheme.typography.labelLarge, maxLines = 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ListeningDot(modifier: Modifier = Modifier) {
|
||||||
|
Box(
|
||||||
|
modifier.size(44.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
MicGlyph(Modifier.size(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.*
|
||||||
|
import android.content.Intent
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.compose.animation.animateContentSize
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.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.filled.Check
|
||||||
|
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.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.saveable.rememberSaveable
|
||||||
|
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.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 app.seb3thehacker.gearslip.notify.ChatLine
|
||||||
|
import java.text.DateFormat
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
/** Lines a collapsed conversation shows; tapping it shows the rest the app sent. */
|
||||||
|
private const val COLLAPSED_LINES = 2
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A messaging app's recent conversations, the way Android Auto shows them: the app has no car
|
||||||
|
* screen of its own, so each thread comes from the notification it has on the phone, newest
|
||||||
|
* first, with reply and mark-as-read when the app offers them.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun MessagesScreen(packageName: String) {
|
||||||
|
val history by CarNotifications.history.collectAsState()
|
||||||
|
val listening by CarNotifications.listening.collectAsState()
|
||||||
|
val navigator = LocalCarNavigator.current
|
||||||
|
val context = LocalContext.current.applicationContext
|
||||||
|
val entry = remember(packageName) { findEntry(MESSAGING_PREFIX + packageName) }
|
||||||
|
val label = entry?.label ?: history.firstOrNull { it.packageName == packageName }?.appLabel ?: "Messages"
|
||||||
|
val threads = history.filter { it.packageName == packageName }
|
||||||
|
|
||||||
|
LaunchedEffect(history) { CarNotifications.dismissPopupFrom(packageName) }
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", navigator::back)
|
||||||
|
entry?.icon?.let { Image(it, contentDescription = null, modifier = Modifier.size(36.dp).clip(RoundedCornerShape(8.dp))) }
|
||||||
|
Text(label, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!listening) {
|
||||||
|
AccessBanner {
|
||||||
|
context.startActivity(
|
||||||
|
Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (threads.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
"No recent messages. New $label messages appear here as they arrive on the phone.",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
contentPadding = PaddingValues(bottom = 16.dp),
|
||||||
|
) {
|
||||||
|
items(threads, key = { it.key }) { n ->
|
||||||
|
ConversationCard(
|
||||||
|
n,
|
||||||
|
onRead = { VoiceReply.read(n) },
|
||||||
|
onReply = { VoiceReply.reply(n) },
|
||||||
|
onMarkRead = { CarNotifications.markThreadRead(context, n) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ConversationCard(n: CarNotification, onRead: () -> Unit, onReply: () -> Unit, onMarkRead: () -> Unit) {
|
||||||
|
var expanded by rememberSaveable(n.key) { mutableStateOf(false) }
|
||||||
|
// An app that posts plain notifications instead of a conversation still gets one line.
|
||||||
|
val lines = n.lines.ifEmpty { listOf(ChatLine(n.title.ifBlank { n.appLabel }, n.text.ifBlank { n.title }, n.postedAt)) }
|
||||||
|
val shown = if (expanded) lines else lines.takeLast(COLLAPSED_LINES)
|
||||||
|
val dim = n.read || n.replied
|
||||||
|
var quick by rememberSaveable(n.key) { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.animateContentSize()
|
||||||
|
.clickable(enabled = lines.size > COLLAPSED_LINES) { expanded = !expanded },
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.padding(14.dp),
|
||||||
|
verticalAlignment = Alignment.Top,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
) {
|
||||||
|
NotificationIcon(n, size = 52.dp)
|
||||||
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
n.title.ifBlank { n.appLabel },
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = if (dim) FontWeight.Medium else FontWeight.Bold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
" · ${DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(n.postedAt))}",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!expanded && lines.size > COLLAPSED_LINES) {
|
||||||
|
Text(
|
||||||
|
"${lines.size - COLLAPSED_LINES} earlier",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
shown.forEach { MessageLine(it, showSender = n.isGroup) }
|
||||||
|
if (quick) QuickReplies(n, Modifier.padding(top = 6.dp))
|
||||||
|
}
|
||||||
|
Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
if (n.replied) {
|
||||||
|
Icon(Icons.Filled.Check, contentDescription = "Replied", tint = MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
|
GsButton(onClick = onRead, tone = GsTone.Neutral) {
|
||||||
|
ReadGlyph(Modifier.size(20.dp))
|
||||||
|
Text("Read", Modifier.padding(start = 8.dp))
|
||||||
|
}
|
||||||
|
if (n.reply != null) {
|
||||||
|
GsButton(onClick = onReply, tone = GsTone.Tonal) {
|
||||||
|
ReplyGlyph(Modifier.size(20.dp))
|
||||||
|
Text("Reply", Modifier.padding(start = 8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (n.reply != null) {
|
||||||
|
GsButton(onClick = { quick = !quick }, tone = GsTone.Neutral) {
|
||||||
|
Text(if (quick) "Hide quick replies" else "Quick reply")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (n.markRead != null) {
|
||||||
|
GsButton(onClick = onMarkRead, tone = GsTone.Neutral) { Text("Mark read") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MessageLine(line: ChatLine, showSender: Boolean) {
|
||||||
|
val who = when {
|
||||||
|
line.sender == null -> "You: "
|
||||||
|
showSender -> "${line.sender}: "
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
who + line.text,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = if (line.sender == null) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 3,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
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.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.widthIn
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Call
|
||||||
|
import androidx.compose.material.icons.filled.Info
|
||||||
|
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.ui.graphics.vector.ImageVector
|
||||||
|
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.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsButton
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsTone
|
||||||
|
import app.seb3thehacker.gearslip.host.CarAppConnection
|
||||||
|
import app.seb3thehacker.gearslip.media.CarMedia
|
||||||
|
|
||||||
|
/** What a built-in app's id carries in a pin, so it can't collide with an installed app's component. */
|
||||||
|
internal const val BUILT_IN_PREFIX = "gearslip:"
|
||||||
|
|
||||||
|
/** One of Gearslip's own apps: a screen, with nothing running behind it once you leave. */
|
||||||
|
internal class BuiltInApp(
|
||||||
|
val id: String,
|
||||||
|
val label: String,
|
||||||
|
val glyph: ImageVector,
|
||||||
|
val screen: CarScreen,
|
||||||
|
val open: (CarNavigator) -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal object BuiltInApps {
|
||||||
|
val Web = BuiltInApp("web", "Web", Icons.Filled.Search, CarScreen.App("web")) { it.open("web") }
|
||||||
|
val ScreenSharing = BuiltInApp("phone", "Screen sharing", Icons.Filled.Share, CarScreen.App("phone")) { it.open("phone") }
|
||||||
|
val Phone = BuiltInApp("dialer", "Phone", Icons.Filled.Call, CarScreen.App("dialer")) { it.open("dialer") }
|
||||||
|
val VehicleData = BuiltInApp("vehicle", "Vehicle data", Icons.Filled.Info, CarScreen.VehicleData) { it.vehicleData() }
|
||||||
|
val Settings = BuiltInApp("settings", "Settings", Icons.Filled.Settings, CarScreen.Settings) { it.settings() }
|
||||||
|
|
||||||
|
val all = listOf(Web, ScreenSharing, Phone, VehicleData, Settings)
|
||||||
|
val entries: List<Entry> = all.map { Entry(it.label, icon = null, builtIn = it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vehicle data is experimental: raw, unverified sensor labels (see [CarSensors]). */
|
||||||
|
@Composable
|
||||||
|
internal fun rememberVehicleDataShown(): Boolean {
|
||||||
|
val experimental by CarSettings.experimentalFeaturesEnabled.collectAsState()
|
||||||
|
val sensors by CarSettings.carSensorsEnabled.collectAsState()
|
||||||
|
return experimental && sensors
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where an open app lives - decides where tapping it goes and how it closes. Gearslip's own
|
||||||
|
* apps have no connection behind them, so [SCREEN] is simply "on screen right now".
|
||||||
|
*/
|
||||||
|
internal enum class RunningSlot { NAV, BROWSE, MEDIA, SCREEN }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apps open right now, as component id to where they live, in the order the nav bar shows them:
|
||||||
|
* the map, then a browsed templated app, then the media app, then whichever built-in app is on
|
||||||
|
* screen. The map and a browsed app count from the moment they start connecting until they're
|
||||||
|
* closed. The media app counts while it has something loaded to play - the same rule the nav
|
||||||
|
* bar's player follows, so the two always show up together - or while its own screen is up.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun rememberRunningApps(): Map<String, RunningSlot> {
|
||||||
|
val navStatus by CarServices.nav.status.collectAsState()
|
||||||
|
val browseStatus by CarServices.browse.status.collectAsState()
|
||||||
|
val mediaApp by CarServices.mediaApp.collectAsState()
|
||||||
|
val mediaPhase by CarServices.media.phase.collectAsState()
|
||||||
|
val now by CarServices.media.now.collectAsState()
|
||||||
|
val current = LocalCarNavigator.current.current
|
||||||
|
|
||||||
|
fun CarAppConnection.liveId(phase: CarAppConnection.Phase) =
|
||||||
|
connectedComponent?.flattenToString()?.takeIf {
|
||||||
|
phase == CarAppConnection.Phase.BINDING ||
|
||||||
|
phase == CarAppConnection.Phase.HANDSHAKE ||
|
||||||
|
phase == CarAppConnection.Phase.RUNNING
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildMap {
|
||||||
|
CarServices.nav.liveId(navStatus.phase)?.let { put(it, RunningSlot.NAV) }
|
||||||
|
CarServices.browse.liveId(browseStatus.phase)?.let { put(it, RunningSlot.BROWSE) }
|
||||||
|
val mediaLive = when (mediaPhase) {
|
||||||
|
CarMedia.Phase.CONNECTING -> true
|
||||||
|
CarMedia.Phase.READY -> now.isActive || current == CarScreen.Media
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
mediaApp?.component?.flattenToString()?.takeIf { mediaLive }?.let { put(it, RunningSlot.MEDIA) }
|
||||||
|
BuiltInApps.all.firstOrNull { it.screen == current }?.let { put(BUILT_IN_PREFIX + it.id, RunningSlot.SCREEN) }
|
||||||
|
(current as? CarScreen.Messages)?.let { put(MESSAGING_PREFIX + it.packageName, RunningSlot.SCREEN) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The screen an open app is showing on, so its nav bar icon can stay pressed in while it's up. */
|
||||||
|
internal fun RunningSlot.isShowing(current: CarScreen): Boolean = when (this) {
|
||||||
|
RunningSlot.NAV -> current == CarScreen.Home
|
||||||
|
RunningSlot.BROWSE -> current == CarScreen.Browse
|
||||||
|
RunningSlot.MEDIA -> current == CarScreen.Media
|
||||||
|
RunningSlot.SCREEN -> true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brings an app up: back to its screen if it's already open - never a reconnect, which would
|
||||||
|
* throw away the route or restart the session - otherwise a fresh launch.
|
||||||
|
*/
|
||||||
|
internal fun openApp(entry: Entry, slot: RunningSlot?, navigator: CarNavigator, frame: CarEnvironment.Frame) {
|
||||||
|
when (slot) {
|
||||||
|
RunningSlot.NAV -> navigator.home()
|
||||||
|
RunningSlot.BROWSE -> navigator.browse(entry.label)
|
||||||
|
RunningSlot.MEDIA -> navigator.media()
|
||||||
|
RunningSlot.SCREEN -> Unit
|
||||||
|
null -> launchEntry(entry, navigator, frame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun closeApp(slot: RunningSlot, navigator: CarNavigator) {
|
||||||
|
when (slot) {
|
||||||
|
RunningSlot.NAV -> CarServices.stopNav()
|
||||||
|
RunningSlot.BROWSE -> CarServices.stopBrowse()
|
||||||
|
RunningSlot.MEDIA -> CarServices.closeMedia()
|
||||||
|
RunningSlot.SCREEN -> Unit
|
||||||
|
}
|
||||||
|
if (slot != RunningSlot.NAV && slot.isShowing(navigator.current)) navigator.back()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a long press on an app offers - pin it to or unpin it from the nav bar, move it along the
|
||||||
|
* launcher, and close it if it's open - as a card in the middle of the screen with car-sized buttons, over a scrim that
|
||||||
|
* dismisses it. Drawn in the car UI's own layout rather than a system popup window, which the
|
||||||
|
* car's virtual display has no reason to host well.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun AppMenu(navigator: CarNavigator, running: Map<String, RunningSlot>) {
|
||||||
|
val id = navigator.appMenu ?: return
|
||||||
|
val entry = findEntry(id)
|
||||||
|
if (entry == null) {
|
||||||
|
navigator.dismissAppMenu()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val pinned by CarSettings.pinnedApps.collectAsState()
|
||||||
|
val isPinned = id in pinned
|
||||||
|
val barFull = !isPinned && pinned.size >= CarSettings.MAX_PINNED
|
||||||
|
val slot = running[id]
|
||||||
|
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black.copy(alpha = 0.5f))
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
onClick = navigator::dismissAppMenu,
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(24.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
// Swallows taps on the card itself, so only the scrim around it dismisses.
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(max = 420.dp)
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
onClick = {},
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
entry.icon?.let { Image(it, null, Modifier.size(44.dp).clip(RoundedCornerShape(10.dp))) }
|
||||||
|
entry.builtIn?.let { Icon(it.glyph, null, Modifier.size(36.dp)) }
|
||||||
|
Text(
|
||||||
|
entry.label,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
modifier = Modifier.padding(start = 14.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
GsButton(
|
||||||
|
onClick = {
|
||||||
|
CarSettings.togglePin(id)
|
||||||
|
navigator.dismissAppMenu()
|
||||||
|
},
|
||||||
|
tone = GsTone.Tonal,
|
||||||
|
enabled = !barFull,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
when {
|
||||||
|
isPinned -> "Unpin from nav bar"
|
||||||
|
barFull -> "Nav bar is full (${CarSettings.MAX_PINNED} pinned)"
|
||||||
|
else -> "Pin to nav bar"
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (navigator.current == CarScreen.Apps) MoveButtons(id)
|
||||||
|
if (slot != null) {
|
||||||
|
GsButton(
|
||||||
|
onClick = {
|
||||||
|
closeApp(slot, navigator)
|
||||||
|
navigator.dismissAppMenu()
|
||||||
|
},
|
||||||
|
tone = GsTone.Danger,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||||
|
) { Text("Close app", style = MaterialTheme.typography.titleMedium) }
|
||||||
|
}
|
||||||
|
GsButton(
|
||||||
|
onClick = navigator::dismissAppMenu,
|
||||||
|
tone = GsTone.Neutral,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||||
|
) { Text("Cancel", style = MaterialTheme.typography.titleMedium) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves the app one place left or right on the launcher. The menu stays open so a tile can be
|
||||||
|
* walked several places, and the grid behind the scrim shows where it went.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun MoveButtons(id: String) {
|
||||||
|
val custom by CarSettings.appOrder.collectAsState()
|
||||||
|
val order = launcherOrder(rememberAllEntries(), rememberVehicleDataShown(), custom).mapNotNull { it.componentId }
|
||||||
|
val index = order.indexOf(id)
|
||||||
|
if (index < 0) return
|
||||||
|
fun move(by: Int) {
|
||||||
|
val next = order.toMutableList()
|
||||||
|
java.util.Collections.swap(next, index, index + by)
|
||||||
|
CarSettings.setAppOrder(next)
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
GsButton(
|
||||||
|
onClick = { move(-1) },
|
||||||
|
tone = GsTone.Tonal,
|
||||||
|
enabled = index > 0,
|
||||||
|
modifier = Modifier.weight(1f).height(56.dp),
|
||||||
|
) { Text("Move left", style = MaterialTheme.typography.titleMedium) }
|
||||||
|
GsButton(
|
||||||
|
onClick = { move(1) },
|
||||||
|
tone = GsTone.Tonal,
|
||||||
|
enabled = index < order.lastIndex,
|
||||||
|
modifier = Modifier.weight(1f).height(56.dp),
|
||||||
|
) { Text("Move right", style = MaterialTheme.typography.titleMedium) }
|
||||||
|
}
|
||||||
|
if (custom.isNotEmpty()) {
|
||||||
|
GsButton(
|
||||||
|
onClick = { CarSettings.setAppOrder(emptyList()) },
|
||||||
|
tone = GsTone.Neutral,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||||
|
) { Text("Reset app order", style = MaterialTheme.typography.titleMedium) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.*
|
||||||
|
import android.content.Intent
|
||||||
|
import android.provider.Settings
|
||||||
|
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.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.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.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replying to one notification. The list itself lives on the dashboard (see
|
||||||
|
* [NotificationsSection]); with nothing to reply to, this is just the dashboard.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun NotificationsScreen(replyTo: String?) {
|
||||||
|
val history by CarNotifications.history.collectAsState()
|
||||||
|
val navigator = LocalCarNavigator.current
|
||||||
|
val target = history.firstOrNull { it.key == replyTo && it.reply != null }
|
||||||
|
if (target != null) {
|
||||||
|
ReplyPane(target, onDone = navigator::back)
|
||||||
|
} else {
|
||||||
|
CarDashboardScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The notification history, under the calendar on the dashboard. Replying opens its own pane.
|
||||||
|
* Anything arriving while this is on screen is being looked at, so it never counts as unread.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun NotificationsSection() {
|
||||||
|
val history by CarNotifications.history.collectAsState()
|
||||||
|
val listening by CarNotifications.listening.collectAsState()
|
||||||
|
val navigator = LocalCarNavigator.current
|
||||||
|
val context = LocalContext.current.applicationContext
|
||||||
|
LaunchedEffect(history) { CarNotifications.markRead() }
|
||||||
|
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
"Notifications",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
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()) {
|
||||||
|
Text(
|
||||||
|
"Nothing yet. New notifications from the phone will show up here.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
history.forEach { n ->
|
||||||
|
key(n.key) { NotificationRow(n) { navigator.notifications(replyTo = it.key) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal 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),
|
||||||
|
)
|
||||||
|
GsButton(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 -> GsButton(onClick = { VoiceReply.reply(n) }, tone = GsTone.Tonal) {
|
||||||
|
ReplyGlyph(Modifier.size(20.dp))
|
||||||
|
Text("Reply", Modifier.padding(start = 8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal 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),
|
||||||
|
) {
|
||||||
|
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", onDone)
|
||||||
|
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, onDismiss = onDone,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.*
|
||||||
|
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.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),
|
||||||
|
)
|
||||||
|
GsButton(onClick = { PhoneApps.openAccessibilitySettings(context) }) { Text("Open") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PhoneAppTile(app: PhoneApp, onClick: () -> Unit) {
|
||||||
|
GsIconBox(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = Modifier.height(120.dp),
|
||||||
|
colors = GsColors(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant),
|
||||||
|
shape = MaterialTheme.shapes.extraLarge,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxSize().padding(8.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
app.icon?.let { Image(it, contentDescription = null, modifier = Modifier.size(52.dp)) }
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
app.label,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.*
|
||||||
|
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.layout.width
|
||||||
|
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.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.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. */
|
||||||
|
internal data class Contact(val name: String, val number: String)
|
||||||
|
|
||||||
|
private val CALL_PERMISSIONS = arrayOf(Manifest.permission.CALL_PHONE, Manifest.permission.READ_CONTACTS)
|
||||||
|
|
||||||
|
/** Also used by [AssistantCommands] for "call <name>". */
|
||||||
|
internal fun hasCallPermissions(context: Context): Boolean =
|
||||||
|
CALL_PERMISSIONS.all { context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }
|
||||||
|
|
||||||
|
internal 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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal 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)) {
|
||||||
|
// The tabs share the title's row, so the list and keypad get the height back.
|
||||||
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
"Phone",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
if (hasPermissions) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
DialerTab("Contacts", tab == TAB_CONTACTS, Modifier.width(140.dp)) { tab = TAB_CONTACTS }
|
||||||
|
DialerTab("Keypad", tab == TAB_KEYPAD, Modifier.width(140.dp)) { tab = TAB_KEYPAD }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermissions) {
|
||||||
|
MissingPermission(context)
|
||||||
|
return@Column
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
GsButton(tone = GsTone.Tonal, 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) {
|
||||||
|
GsIconBox(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier.height(44.dp),
|
||||||
|
colors = if (selected) GsColors(MaterialTheme.colorScheme.primaryContainer, MaterialTheme.colorScheme.onPrimaryContainer)
|
||||||
|
else GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface),
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
latched = selected,
|
||||||
|
) {
|
||||||
|
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)) {
|
||||||
|
GsButton(
|
||||||
|
onClick = { if (number.isNotEmpty()) number = number.dropLast(1) },
|
||||||
|
tone = GsTone.Tonal,
|
||||||
|
enabled = number.isNotEmpty(),
|
||||||
|
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||||
|
) { Text("⌫", style = MaterialTheme.typography.titleLarge) }
|
||||||
|
GsIconBox(
|
||||||
|
onClick = { placeCall(context, number) },
|
||||||
|
colors = gsColors(GsTone.Primary),
|
||||||
|
enabled = number.isNotEmpty(),
|
||||||
|
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||||
|
) { Icon(Icons.Filled.Call, "Call") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun KeypadButton(digit: String, modifier: Modifier, onClick: () -> Unit) {
|
||||||
|
GsIconBox(
|
||||||
|
onClick,
|
||||||
|
modifier,
|
||||||
|
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface),
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
) {
|
||||||
|
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 },
|
||||||
|
onDismiss = { 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)) {
|
||||||
|
GsButton(onClick = onDismiss, tone = GsTone.Outline, modifier = Modifier.weight(1f).height(56.dp)) {
|
||||||
|
Text("Cancel", style = MaterialTheme.typography.titleMedium)
|
||||||
|
}
|
||||||
|
GsButton(onClick = onConfirm, modifier = Modifier.weight(1f).height(56.dp)) {
|
||||||
|
Text("Call", style = MaterialTheme.typography.titleMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContactSearchField(query: String, active: Boolean, onToggle: () -> Unit) {
|
||||||
|
Surface(
|
||||||
|
onClick = onToggle,
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
color = if (active) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
query.ifEmpty { "Search contacts" },
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = if (query.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContactRow(contact: Contact, onClick: () -> Unit) {
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier.size(40.dp).background(MaterialTheme.colorScheme.primaryContainer, CircleShape),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
contact.name.take(1).uppercase(),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(contact.name, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
Text(contact.number, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
Icon(Icons.Filled.Call, contentDescription = "Call", tint = MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import android.media.session.PlaybackState
|
||||||
|
import android.support.v4.media.session.PlaybackStateCompat
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Favorite
|
||||||
|
import androidx.compose.material.icons.filled.FavoriteBorder
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
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.runtime.mutableLongStateOf
|
||||||
|
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.ImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.luminance
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import app.seb3thehacker.gearslip.media.ArtColor
|
||||||
|
import app.seb3thehacker.gearslip.media.MediaArt
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsIconBox
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsIconButton
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsSeekBar
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsTone
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.gsColors
|
||||||
|
import app.seb3thehacker.gearslip.media.CarMedia
|
||||||
|
import app.seb3thehacker.gearslip.media.CustomAction
|
||||||
|
import app.seb3thehacker.gearslip.media.NowPlaying
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The pieces every player on the car screen is built from - the media screen, the column beside
|
||||||
|
* the map - so a button looks and behaves the same wherever it turns up.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The seek bar, with elapsed and total time under it when [showTimes]. */
|
||||||
|
@Composable
|
||||||
|
internal fun PlayerProgress(now: NowPlaying, media: CarMedia, modifier: Modifier = Modifier, showTimes: Boolean = true) {
|
||||||
|
if (now.durationMs <= 0) return
|
||||||
|
var position by remember { mutableLongStateOf(now.currentPosition()) }
|
||||||
|
LaunchedEffect(now) {
|
||||||
|
while (true) {
|
||||||
|
position = now.currentPosition()
|
||||||
|
if (!now.playing) break
|
||||||
|
delay(500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(modifier) {
|
||||||
|
GsSeekBar(
|
||||||
|
fraction = position.toFloat() / now.durationMs,
|
||||||
|
onSeek = { media.seek((it * now.durationMs).toLong()) },
|
||||||
|
enabled = now.canDo(PlaybackState.ACTION_SEEK_TO),
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||||
|
)
|
||||||
|
if (showTimes) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "1:05" for under a minute past the hour, "1:01:05" once an hour is involved. */
|
||||||
|
internal 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Previous, play/pause, next. [skip] sizes the outer two; play is a step bigger. */
|
||||||
|
@Composable
|
||||||
|
internal fun TransportRow(now: NowPlaying, media: CarMedia, skip: Dp, modifier: Modifier = Modifier) {
|
||||||
|
val play = skip * 1.25f
|
||||||
|
Row(modifier, horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
GsIconButton(
|
||||||
|
MediaIcons.Previous, "Previous", media::previous,
|
||||||
|
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), size = skip, iconSize = skip * 0.58f, shape = CircleShape,
|
||||||
|
)
|
||||||
|
GsIconButton(
|
||||||
|
if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow,
|
||||||
|
if (now.playing) "Pause" else "Play",
|
||||||
|
media::togglePlay,
|
||||||
|
tone = GsTone.Primary, size = play, iconSize = play * 0.58f, shape = CircleShape,
|
||||||
|
)
|
||||||
|
GsIconButton(
|
||||||
|
MediaIcons.Next, "Next", media::next,
|
||||||
|
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_NEXT), size = skip, iconSize = skip * 0.58f, shape = CircleShape,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like, shuffle and repeat, then up to [maxOther] of the app's other buttons, all icon-only. One
|
||||||
|
* the app doesn't support stays on screen greyed out, so the row never shifts between apps.
|
||||||
|
* [trailing] adds buttons of the caller's own at the end (the side column's lyrics button).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun PlayModeRow(
|
||||||
|
now: NowPlaying,
|
||||||
|
media: CarMedia,
|
||||||
|
size: Dp,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
maxOther: Int = 0,
|
||||||
|
trailing: @Composable RowScope.() -> Unit = {},
|
||||||
|
) {
|
||||||
|
val modes by media.modes.collectAsState()
|
||||||
|
val liked = now.liked
|
||||||
|
val shuffleKnown = modes.shuffle != PlaybackStateCompat.SHUFFLE_MODE_INVALID
|
||||||
|
val repeatKnown = modes.repeat != PlaybackStateCompat.REPEAT_MODE_INVALID
|
||||||
|
val shuffleOn = shuffleKnown && modes.shuffle != PlaybackStateCompat.SHUFFLE_MODE_NONE
|
||||||
|
val repeatOn = repeatKnown && modes.repeat != PlaybackStateCompat.REPEAT_MODE_NONE
|
||||||
|
val icon = size * 0.52f
|
||||||
|
Row(modifier, horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
GsIconButton(
|
||||||
|
if (liked == true) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
|
||||||
|
if (liked == true) "Unlike" else "Like",
|
||||||
|
media::toggleLike,
|
||||||
|
enabled = liked != null,
|
||||||
|
tone = if (liked == true) GsTone.Primary else GsTone.Neutral,
|
||||||
|
size = size, iconSize = icon,
|
||||||
|
)
|
||||||
|
GsIconButton(
|
||||||
|
MediaIcons.Shuffle, if (shuffleOn) "Shuffle off" else "Shuffle on",
|
||||||
|
media::toggleShuffle,
|
||||||
|
enabled = shuffleKnown || now.shuffleAction != null,
|
||||||
|
tone = if (shuffleOn) GsTone.Primary else GsTone.Neutral,
|
||||||
|
size = size, iconSize = icon,
|
||||||
|
)
|
||||||
|
GsIconButton(
|
||||||
|
if (modes.repeat == PlaybackStateCompat.REPEAT_MODE_ONE) MediaIcons.RepeatOne else MediaIcons.Repeat,
|
||||||
|
"Repeat",
|
||||||
|
media::cycleRepeat,
|
||||||
|
enabled = repeatKnown || now.repeatAction != null,
|
||||||
|
tone = if (repeatOn) GsTone.Primary else GsTone.Neutral,
|
||||||
|
size = size, iconSize = icon,
|
||||||
|
)
|
||||||
|
now.otherActions.take(maxOther).forEach { CustomActionButton(it, media, size) }
|
||||||
|
trailing()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One of the app's own buttons, drawn with the app's own icon - it lives in the app's package,
|
||||||
|
* not Gearslip's, so it's loaded from there. Its name stands in if the icon won't load.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun CustomActionButton(action: CustomAction, media: CarMedia, size: Dp) {
|
||||||
|
val icon = rememberActionIcon(action)
|
||||||
|
GsIconBox(
|
||||||
|
onClick = { media.custom(action) },
|
||||||
|
modifier = Modifier.size(size),
|
||||||
|
colors = gsColors(GsTone.Neutral),
|
||||||
|
) {
|
||||||
|
if (icon != null) {
|
||||||
|
Icon(icon, action.name, Modifier.size(size * 0.52f))
|
||||||
|
} else {
|
||||||
|
Text(action.name, style = MaterialTheme.typography.labelSmall, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun rememberActionIcon(action: CustomAction): ImageBitmap? {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val app by CarServices.mediaApp.collectAsState()
|
||||||
|
val pkg = app?.component?.packageName
|
||||||
|
val icon by produceState<ImageBitmap?>(null, pkg, action.icon) {
|
||||||
|
value = if (pkg == null || action.icon == 0) null else withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
context.packageManager.getResourcesForApplication(pkg)
|
||||||
|
.getDrawable(action.icon, null).toBitmap(96, 96).asImageBitmap()
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return icon
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The track's art, from the session's bitmap or, failing that, its art URI. */
|
||||||
|
@Composable
|
||||||
|
internal fun rememberArt(now: NowPlaying): android.graphics.Bitmap? {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
|
||||||
|
return art
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A background taken from the album art's main colour, in place of [fallback]. Only the hue and a
|
||||||
|
* capped amount of its saturation come from the art; the lightness stays close to the theme's
|
||||||
|
* own ([darkLightness] or [lightLightness]), so text drawn in the theme's colours reads the same
|
||||||
|
* on any cover. Art with no real colour in it keeps [fallback]. Fades between songs rather than
|
||||||
|
* jumping.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun rememberArtColor(
|
||||||
|
art: android.graphics.Bitmap?,
|
||||||
|
fallback: Color,
|
||||||
|
darkLightness: Float = 0.2f,
|
||||||
|
lightLightness: Float = 0.86f,
|
||||||
|
): Color {
|
||||||
|
val dark = fallback.luminance() < 0.5f
|
||||||
|
val tint by produceState<ArtColor.Tint?>(null, art) {
|
||||||
|
value = art?.let { withContext(Dispatchers.Default) { runCatching { ArtColor.of(it) }.getOrNull() } }
|
||||||
|
}
|
||||||
|
val target = tint?.let {
|
||||||
|
if (dark) Color.hsl(it.hue, it.saturation.coerceIn(0.3f, 0.5f), darkLightness)
|
||||||
|
else Color.hsl(it.hue, it.saturation.coerceIn(0.35f, 0.55f), lightLightness)
|
||||||
|
} ?: fallback
|
||||||
|
return animateColorAsState(target, tween(600), label = "art colour").value
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import app.seb3thehacker.gearslip.GearslipLog
|
||||||
|
import app.seb3thehacker.gearslip.mirror.PhoneApps
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads what the car screens show before anyone opens them: the car and media apps with their
|
||||||
|
* icons, the phone's launcher apps, the weather and the calendar. Each screen paints from these
|
||||||
|
* results at once and refreshes behind them, so nothing waits on the package manager, the
|
||||||
|
* network or the calendar provider.
|
||||||
|
*
|
||||||
|
* Safe to call as often as you like: calls closer together than [MIN_GAP_MS] are dropped.
|
||||||
|
*/
|
||||||
|
object Prefetch {
|
||||||
|
|
||||||
|
private const val MIN_GAP_MS = 60_000L
|
||||||
|
|
||||||
|
private val worker = Executors.newSingleThreadExecutor { Thread(it, "gearslip-prefetch").apply { isDaemon = true } }
|
||||||
|
@Volatile private var lastRun = 0L
|
||||||
|
|
||||||
|
/** [force] skips the throttle; use it when something changed, such as a permission grant. */
|
||||||
|
fun warm(context: Context, force: Boolean = false) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (!force && now - lastRun < MIN_GAP_MS) return
|
||||||
|
lastRun = now
|
||||||
|
val app = context.applicationContext
|
||||||
|
worker.execute {
|
||||||
|
val start = System.currentTimeMillis()
|
||||||
|
step("launcher apps") { warmLauncherApps(app) }
|
||||||
|
step("phone apps") { PhoneApps.warm(app) }
|
||||||
|
step("weather and calendar") { warmDashboard(app) }
|
||||||
|
GearslipLog.i("prefetch done in ${System.currentTimeMillis() - start}ms")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One failing loader must not stop the others.
|
||||||
|
private fun step(name: String, block: () -> Unit) {
|
||||||
|
runCatching(block).onFailure { GearslipLog.w("prefetch: $name failed: ${it.message}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.*
|
||||||
|
import android.graphics.Rect
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.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.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,
|
||||||
|
) {
|
||||||
|
// 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.
|
||||||
|
GsIconButton(
|
||||||
|
Icons.Filled.Close,
|
||||||
|
"Stop navigating",
|
||||||
|
{ CarServices.nav.stopNavigating() },
|
||||||
|
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface),
|
||||||
|
size = 40.dp,
|
||||||
|
iconSize = 20.dp,
|
||||||
|
shape = CircleShape,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
Column {
|
||||||
|
if (time.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
time,
|
||||||
|
style = ChromeType.headline,
|
||||||
|
color = estimate.remainingTimeColor.color(dark, MaterialTheme.colorScheme.onSurface),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (detail.isNotEmpty()) {
|
||||||
|
Text(detail, style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- map with a side pane ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shape every remaining map-backed template shares: the app's map across the whole surface,
|
||||||
|
* the template's content in a card floating over its left side, and both action strips stacked at
|
||||||
|
* the right edge. The card is as tall as its content, up to the height of the screen, so a
|
||||||
|
* handful of buttons no longer claim a whole column of the map. [fullHeight] is for content that
|
||||||
|
* needs the room, such as a search field with its keyboard.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun MapPaneLayout(
|
||||||
|
modifier: Modifier,
|
||||||
|
actionStrip: androidx.car.app.model.ActionStrip?,
|
||||||
|
mapActionStrip: androidx.car.app.model.ActionStrip?,
|
||||||
|
fullHeight: Boolean = false,
|
||||||
|
pane: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
// Pan mode hands the whole screen to the map, leaving only its own controls.
|
||||||
|
val pan by CarServices.nav.panMode.collectAsState()
|
||||||
|
val edgeActions = (actionStrip?.actions.orEmpty() + mapActionStrip?.actions.orEmpty()).map { it.identity() }.toSet()
|
||||||
|
androidx.compose.foundation.layout.BoxWithConstraints(modifier.fillMaxWidth()) {
|
||||||
|
if (!pan) {
|
||||||
|
ChromeSurface(
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.TopStart)
|
||||||
|
.padding(12.dp)
|
||||||
|
.width(maxOf(maxWidth * CONTENT_PANE_FRACTION - 12.dp, 232.dp))
|
||||||
|
.let { if (fullHeight) it.height(maxHeight - 24.dp) else it.heightIn(max = maxHeight - 24.dp) },
|
||||||
|
shape = androidx.compose.foundation.shape.RoundedCornerShape(20.dp),
|
||||||
|
shadowElevation = 8.dp,
|
||||||
|
) {
|
||||||
|
androidx.compose.runtime.CompositionLocalProvider(LocalMapEdgeActions provides edgeActions) { pane() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RightEdgeControls(
|
||||||
|
if (pan) null else actionStrip,
|
||||||
|
mapActionStrip,
|
||||||
|
Modifier.align(Alignment.TopEnd).padding(12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MapWithContentChrome(template: MapWithContentTemplate, modifier: Modifier) {
|
||||||
|
MapPaneLayout(
|
||||||
|
modifier,
|
||||||
|
template.actionStrip,
|
||||||
|
template.mapController?.mapActionStrip,
|
||||||
|
fullHeight = template.contentTemplate.needsFullHeight(),
|
||||||
|
) {
|
||||||
|
// A pane floating over the map is a place or a route the app is proposing, so its one
|
||||||
|
// action - Start - counts itself down rather than waiting to be pressed; the same pane
|
||||||
|
// filling a whole screen elsewhere gets no such push.
|
||||||
|
ContentTemplate(template.contentTemplate, Modifier, autoStartPane = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MapChrome(template: MapTemplate, modifier: Modifier) {
|
||||||
|
MapPaneLayout(
|
||||||
|
modifier,
|
||||||
|
template.actionStrip,
|
||||||
|
template.mapController?.mapActionStrip,
|
||||||
|
) {
|
||||||
|
ChromeSurface(Modifier, shape = androidx.compose.foundation.shape.RoundedCornerShape(0.dp)) {
|
||||||
|
Column {
|
||||||
|
HeaderBar(
|
||||||
|
headerTitle(template.header, ""),
|
||||||
|
template.header?.startHeaderAction,
|
||||||
|
template.header?.endHeaderActions.orEmpty(),
|
||||||
|
)
|
||||||
|
if (template.pane != null) PaneRows(template.pane, autoStart = true)
|
||||||
|
else ItemListColumn(template.itemList)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PlaceListNavigationChrome(template: PlaceListNavigationTemplate, modifier: Modifier) {
|
||||||
|
MapPaneLayout(modifier, template.actionStrip, template.mapActionStrip) {
|
||||||
|
ListPane(
|
||||||
|
headerTitle(template.header, template.title.text()),
|
||||||
|
template.header?.startHeaderAction ?: template.headerAction,
|
||||||
|
template.isLoading,
|
||||||
|
template.itemList,
|
||||||
|
Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PlaceListMapChrome(template: PlaceListMapTemplate, modifier: Modifier) {
|
||||||
|
MapPaneLayout(modifier, template.actionStrip, null) {
|
||||||
|
ListPane(
|
||||||
|
template.title.text(),
|
||||||
|
template.headerAction,
|
||||||
|
template.isLoading,
|
||||||
|
template.itemList,
|
||||||
|
Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Route choices down the side, with the app's "start" action pinned under them. */
|
||||||
|
@Composable
|
||||||
|
private fun RoutePreviewChrome(template: RoutePreviewNavigationTemplate, modifier: Modifier) {
|
||||||
|
MapPaneLayout(modifier, template.actionStrip, template.mapActionStrip) {
|
||||||
|
ChromeSurface(Modifier, shape = androidx.compose.foundation.shape.RoundedCornerShape(0.dp)) {
|
||||||
|
Column {
|
||||||
|
HeaderBar(
|
||||||
|
headerTitle(template.header, template.title.text()),
|
||||||
|
template.header?.startHeaderAction ?: template.headerAction,
|
||||||
|
template.header?.endHeaderActions.orEmpty(),
|
||||||
|
)
|
||||||
|
Box(Modifier.weight(1f, fill = false)) {
|
||||||
|
if (template.isLoading) {
|
||||||
|
Text("Finding routes…", Modifier.padding(14.dp), style = ChromeType.title)
|
||||||
|
} else {
|
||||||
|
ItemListColumn(template.itemList)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
template.navigateAction?.let {
|
||||||
|
AutoStartButton(it, Modifier.padding(12.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ListPane(
|
||||||
|
title: String,
|
||||||
|
startAction: androidx.car.app.model.Action?,
|
||||||
|
loading: Boolean,
|
||||||
|
itemList: androidx.car.app.model.ItemList?,
|
||||||
|
modifier: Modifier,
|
||||||
|
) {
|
||||||
|
ChromeSurface(modifier, shape = androidx.compose.foundation.shape.RoundedCornerShape(0.dp)) {
|
||||||
|
Column {
|
||||||
|
HeaderBar(title, startAction)
|
||||||
|
if (loading) Text("Loading…", Modifier.padding(14.dp), style = ChromeType.title)
|
||||||
|
else ItemListColumn(itemList)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fraction of the surface the content pane covers beside a map. */
|
||||||
|
private const val CONTENT_PANE_FRACTION = 0.42f
|
||||||
|
|
||||||
|
/** Fraction of the surface the travel-estimate bar covers along the bottom. */
|
||||||
|
private const val ESTIMATE_FRACTION = 0.14f
|
||||||
|
|
||||||
|
/** Search, sign-in and the like are laid out for a whole column, so they keep one. */
|
||||||
|
private fun Template?.needsFullHeight() =
|
||||||
|
this is androidx.car.app.model.SearchTemplate || this is androidx.car.app.model.signin.SignInTemplate ||
|
||||||
|
this is androidx.car.app.model.TabTemplate || this is androidx.car.app.model.LongMessageTemplate
|
||||||
@@ -0,0 +1,644 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.*
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
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.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.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.GridItem
|
||||||
|
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,
|
||||||
|
ownActionsElsewhere: Boolean = false,
|
||||||
|
tabId: String = "",
|
||||||
|
) {
|
||||||
|
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, showActionsInHeader = !ownActionsElsewhere, tabId = tabId)
|
||||||
|
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),
|
||||||
|
onClear = {
|
||||||
|
query = ""
|
||||||
|
delegate.textChanged("")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
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) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The field the on-screen keyboard types into. It is the same height and shape as the round
|
||||||
|
* buttons beside it, so the header reads as one row of controls rather than a thin strip squeezed
|
||||||
|
* between two big buttons, and it carries a magnifier so it reads as search at a glance.
|
||||||
|
* [onClear] adds a clear button once something is typed; [searchIcon] is off for sign-in fields.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SearchField(
|
||||||
|
query: String,
|
||||||
|
hint: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
searchIcon: Boolean = true,
|
||||||
|
onClear: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val muted = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
|
shape = androidx.compose.foundation.shape.CircleShape,
|
||||||
|
modifier = modifier.fillMaxWidth().height(ICON_BUTTON_SIZE),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.padding(start = if (searchIcon) 16.dp else 22.dp, end = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
if (searchIcon) {
|
||||||
|
androidx.compose.material3.Icon(
|
||||||
|
androidx.compose.material.icons.Icons.Filled.Search,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = muted,
|
||||||
|
modifier = Modifier.size(26.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
query.ifEmpty { hint.ifEmpty { "Search" } },
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = if (query.isEmpty()) muted else MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
if (onClear != null && query.isNotEmpty()) {
|
||||||
|
GsIconButton(
|
||||||
|
androidx.compose.material.icons.Icons.Filled.Close,
|
||||||
|
"Clear",
|
||||||
|
onClear,
|
||||||
|
colors = GsColors(androidx.compose.ui.graphics.Color.Transparent, muted),
|
||||||
|
size = 42.dp,
|
||||||
|
iconSize = 22.dp,
|
||||||
|
shape = androidx.compose.foundation.shape.CircleShape,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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(), searchIcon = false)
|
||||||
|
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
|
||||||
|
val innerTemplate = template.tabContents?.template
|
||||||
|
// A sectioned tab's own actions (search, a Liked Songs shortcut) belong on the same line as
|
||||||
|
// the tabs themselves - a real Android Auto host's header row, not a second near-empty row
|
||||||
|
// underneath it that SectionedContent would otherwise draw just to hold them.
|
||||||
|
val trailingActions = (innerTemplate as? SectionedItemTemplate)?.actions.orEmpty().filter { it.isDrawable() }
|
||||||
|
Box(modifier) {
|
||||||
|
ContentSurface(Modifier.fillMaxSize()) {
|
||||||
|
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
|
||||||
|
GsIconBox(
|
||||||
|
onClick = { template.tabCallbackDelegate.tabSelected(tab.contentId) },
|
||||||
|
colors = if (selected) GsColors(MaterialTheme.colorScheme.primaryContainer, MaterialTheme.colorScheme.onPrimaryContainer)
|
||||||
|
else GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface),
|
||||||
|
shape = RoundedCornerShape(20.dp),
|
||||||
|
latched = selected,
|
||||||
|
) {
|
||||||
|
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(
|
||||||
|
innerTemplate,
|
||||||
|
Modifier.weight(1f),
|
||||||
|
ownActionsElsewhere = trailingActions.isNotEmpty(),
|
||||||
|
tabId = active.orEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Bottom-left, not on the tab row: a driver's hand rests near the bottom of the screen,
|
||||||
|
// and the tab row is already busy with the tabs themselves.
|
||||||
|
if (trailingActions.isNotEmpty()) {
|
||||||
|
Row(
|
||||||
|
Modifier.align(Alignment.BottomStart).padding(12.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
trailingActions.forEach { ActionButton(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tabs share a row; this keeps the modifier chain readable where no scroll is wanted. */
|
||||||
|
private fun Modifier.verticalScrollNone(): Modifier = this
|
||||||
|
|
||||||
|
// --- sectioned items --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A section's items ([SectionRows]) are cached for the life of the process, keyed off the
|
||||||
|
* foreground app and, when nested under a tab, the tab - so flipping back to a shelf already
|
||||||
|
* seen this drive is instant instead of re-asking the app over the binder. Bounded and in
|
||||||
|
* memory only, so it costs nothing on disk and disappears with the process.
|
||||||
|
*/
|
||||||
|
private val sectionCache = android.util.LruCache<String, List<Item>>(64)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (and,
|
||||||
|
* via [sectionCache], for the rest of the drive).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SectionedContent(template: SectionedItemTemplate, modifier: Modifier, showActionsInHeader: Boolean = true, tabId: String = "") {
|
||||||
|
val appPackage = CarServices.nav.appPackage.orEmpty()
|
||||||
|
ContentSurface(modifier) {
|
||||||
|
// The app's own actions (Spotify's search and Liked Songs shortcut, for two) come from
|
||||||
|
// template.actions, not the header - but a bottom-of-content row is wrong for a driver
|
||||||
|
// reaching for it. Normally the header's own end-actions slot is where a real Android
|
||||||
|
// Auto host puts these; nested under a TabTemplate, though, TabContent already drew them
|
||||||
|
// on the tab row itself, so showActionsInHeader is false and this header stays title-only
|
||||||
|
// (and usually collapses to nothing, since a tab's own content carries no separate title).
|
||||||
|
HeaderBar(
|
||||||
|
headerTitle(template.header, ""),
|
||||||
|
template.header?.startHeaderAction,
|
||||||
|
template.header?.endHeaderActions.orEmpty() + if (showActionsInHeader) template.actions.orEmpty() else emptyList(),
|
||||||
|
)
|
||||||
|
if (template.isLoading) {
|
||||||
|
Loading()
|
||||||
|
return@ContentSurface
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) {
|
||||||
|
template.sections.forEachIndexed { index, section ->
|
||||||
|
PaneTitle(section.title.text())
|
||||||
|
SectionRows(section, "$appPackage/$tabId/$index/${section.title.text()}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SectionRows(section: Section<*>, cacheKey: String) {
|
||||||
|
var items by remember(section) { mutableStateOf(sectionCache.get(cacheKey).orEmpty()) }
|
||||||
|
|
||||||
|
LaunchedEffect(section) {
|
||||||
|
if (sectionCache.get(cacheKey) != null) return@LaunchedEffect
|
||||||
|
val delegate = section.itemsDelegate
|
||||||
|
val sizeResult = runCatching { delegate.size }
|
||||||
|
val size = sizeResult.getOrDefault(0)
|
||||||
|
sizeResult.onFailure { GearslipLog.w("host: section.itemsDelegate.size threw: ${it.javaClass.simpleName}: ${it.message}") }
|
||||||
|
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")
|
||||||
|
val fetched = (value as? List<Item>).orEmpty()
|
||||||
|
items = fetched
|
||||||
|
sectionCache.put(cacheKey, fetched)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
// A section's items are homogeneous in practice - one kind or the other - but nothing in
|
||||||
|
// the API guarantees that, so both are handled rather than assuming whichever the app
|
||||||
|
// happened to send first.
|
||||||
|
val rows = items.filterIsInstance<CarRow>()
|
||||||
|
val tiles = items.filterIsInstance<GridItem>()
|
||||||
|
rows.forEach { RowItem(it) }
|
||||||
|
if (tiles.isNotEmpty()) {
|
||||||
|
// A plain scrollable Row, not LazyRow: the car screen renders into a VirtualDisplay
|
||||||
|
// Presentation rather than a normal window, and LazyRow's SubcomposeLayout measures to
|
||||||
|
// zero height there even with an explicit size - a handful of tiles per shelf makes the
|
||||||
|
// lost virtualization no real loss.
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.horizontalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
tiles.forEach { tile -> GridTile(tile, Modifier.width(180.dp)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- media ----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The template itself carries only a header - real Android Auto hosts draw this screen from the
|
||||||
|
* app's MediaSession instead, which is exactly what [CarServices.media] already does for the
|
||||||
|
* media-app launcher and the bottom bar. Reusing [NowPlayingPage] here rather than a "not
|
||||||
|
* supported" placeholder means an app whose own now-playing template Gearslip lands on (like
|
||||||
|
* Spotify's "Liked Songs" screen) still gets working transport controls.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun MediaPlaybackContent(template: MediaPlaybackTemplate, modifier: Modifier) {
|
||||||
|
val media = CarServices.media
|
||||||
|
val now by media.now.collectAsState()
|
||||||
|
ContentSurface(modifier) {
|
||||||
|
HeaderBar(
|
||||||
|
headerTitle(template.header, ""),
|
||||||
|
template.header?.startHeaderAction,
|
||||||
|
template.header?.endHeaderActions.orEmpty(),
|
||||||
|
)
|
||||||
|
if (now.title.isNotEmpty()) {
|
||||||
|
NowPlayingPage(now, media, Modifier.weight(1f).fillMaxWidth())
|
||||||
|
} else {
|
||||||
|
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
"Nothing playing yet.",
|
||||||
|
style = ChromeType.body,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- shared ---------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContentSurface(modifier: Modifier, content: @Composable ColumnScopeAlias.() -> Unit) {
|
||||||
|
ChromeSurface(modifier, shape = RoundedCornerShape(0.dp)) {
|
||||||
|
Column(Modifier.fillMaxWidth(), content = content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private typealias ColumnScopeAlias = androidx.compose.foundation.layout.ColumnScope
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Loading() {
|
||||||
|
Box(Modifier.fillMaxWidth().height(80.dp), contentAlignment = Alignment.Center) {
|
||||||
|
Text("Loading…", style = ChromeType.title)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,684 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.*
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
import androidx.compose.ui.graphics.ColorFilter
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
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
|
||||||
|
|
||||||
|
GsIconBox(
|
||||||
|
onClick = {
|
||||||
|
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")
|
||||||
|
},
|
||||||
|
modifier = modifier,
|
||||||
|
colors = GsColors(background, content),
|
||||||
|
enabled = action.isEnabled,
|
||||||
|
shape = if (title.isEmpty()) androidx.compose.foundation.shape.CircleShape else RoundedCornerShape(if (large) 28.dp else 24.dp),
|
||||||
|
latched = active,
|
||||||
|
) {
|
||||||
|
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
|
||||||
|
|
||||||
|
GsIconBox(
|
||||||
|
onClick = { fire() },
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
// 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.
|
||||||
|
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, content),
|
||||||
|
enabled = action.isEnabled,
|
||||||
|
shape = RoundedCornerShape(28.dp),
|
||||||
|
) {
|
||||||
|
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
|
||||||
|
ACTION_TYPE_NOW_PLAYING -> ACTION_TYPE_NOW_PLAYING
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The media "now playing" standard action, newer than the Car App Library we build against, so it
|
||||||
|
* has no named constant here. Spotify puts it in its header with no icon of its own.
|
||||||
|
*/
|
||||||
|
private const val ACTION_TYPE_NOW_PLAYING = 65542
|
||||||
|
|
||||||
|
// --- 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
|
||||||
|
// Off the main thread and cached per icon: loadDrawable() is a Binder round trip into the
|
||||||
|
// app's own process for a content:// icon, plus a bitmap decode - done synchronously on the
|
||||||
|
// composition thread for every tile on screen, that's what made a shelf of ten covers feel
|
||||||
|
// like it was hanging rather than loading.
|
||||||
|
val bitmapState by produceState<ImageBitmap?>(null, icon) {
|
||||||
|
value = withContext(Dispatchers.IO) { icon.image(context) }
|
||||||
|
}
|
||||||
|
val bitmap = bitmapState
|
||||||
|
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
|
||||||
|
ACTION_TYPE_NOW_PLAYING -> MediaIcons.Equalizer
|
||||||
|
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 tappable tiles - what apps use for a home screen beside the map. Icons here are as
|
||||||
|
* often a small menu glyph (Organic Maps' Search, say) as real cover art, so the tile stays
|
||||||
|
* modest; [GridTile]'s own default is for the shelf case, where every tile is a photo. */
|
||||||
|
@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 -> GridTile(item, iconSize = 36.dp) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One cover-art tile: an app's own icon over a title and optional subtitle. Shared by
|
||||||
|
* [GridItems] (a whole screen of tiles, [iconSize] scaled down there to suit a plain menu glyph)
|
||||||
|
* and a section's horizontal shelf, which carries the same [GridItem] type but scrolls sideways
|
||||||
|
* instead of wrapping into a grid and wants its full-size cover art.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun GridTile(item: GridItem, modifier: Modifier = Modifier, iconSize: androidx.compose.ui.unit.Dp = 144.dp) {
|
||||||
|
Column(
|
||||||
|
modifier
|
||||||
|
.clickable { item.onClickDelegate.click("grid item") }
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
CarGlyph(item.image, Modifier.size(iconSize))
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
internal val ICON_BUTTON_SIZE = 54.dp
|
||||||
|
private val ICON_BUTTON_GLYPH = 30.dp
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
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.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
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.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the head unit's sensor channel is willing to send, live and unfiltered - see
|
||||||
|
* [CarSensors] for why the labels are a best guess and the raw field dump next to each one is
|
||||||
|
* the part to actually trust while confirming what this unit really sends.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun VehicleDataScreen() {
|
||||||
|
val readings by CarSensors.readings.collectAsState()
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 16.dp)) {
|
||||||
|
Text("Vehicle data", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||||
|
Text(
|
||||||
|
"Raw sensor channel from the connected head unit. Field meaning is a best guess - " +
|
||||||
|
"watch a row change while you do the thing (brake, drive, headlights) to confirm it.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 4.dp, bottom = 16.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (readings.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
"No sensor service advertised by this head unit, or not connected.",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
return@Column
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
items(readings, key = { it.typeId }) { reading -> ReadingRow(reading) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ReadingRow(reading: CarSensors.Reading) {
|
||||||
|
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.large) {
|
||||||
|
Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp)) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(reading.typeName, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
reading.raw,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.os.SystemClock
|
||||||
|
import androidx.car.app.navigation.model.NavigationTemplate
|
||||||
|
import app.seb3thehacker.gearslip.GearslipLog
|
||||||
|
import app.seb3thehacker.gearslip.call.CallUiState
|
||||||
|
import app.seb3thehacker.gearslip.call.CarCalls
|
||||||
|
import app.seb3thehacker.gearslip.host.clockTime
|
||||||
|
import app.seb3thehacker.gearslip.host.remainingTime
|
||||||
|
import app.seb3thehacker.gearslip.notify.CarNotification
|
||||||
|
import app.seb3thehacker.gearslip.notify.CarNotifications
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Messages by voice, the way Android Auto does it: a message is read aloud, "Do you want to
|
||||||
|
* reply?", a yes gets "Go ahead", the reply is read back, and it sends after a few seconds unless
|
||||||
|
* cancelled. Reply does the same; only once the message has just been read does it skip straight
|
||||||
|
* to "What's your reply?". While the reply counts down, saying "cancel" or "change it" stops it or
|
||||||
|
* starts it again. A quick reply sends a ready-made answer with no voice at all. Listening and speaking
|
||||||
|
* go through [CarAssistant], so this hears through the same microphone and recognizer.
|
||||||
|
*
|
||||||
|
* Every step runs on the main thread. Each run gets a new [run] number, and callbacks from an
|
||||||
|
* older run are dropped, so a cancel can never be undone by a late recognizer result.
|
||||||
|
*/
|
||||||
|
object VoiceReply {
|
||||||
|
|
||||||
|
enum class Phase {
|
||||||
|
IDLE,
|
||||||
|
/** Reading the message aloud. */
|
||||||
|
READING,
|
||||||
|
/** Read; the Reply button stays up for a moment in case the driver wants it. */
|
||||||
|
READ,
|
||||||
|
/** Asking "Do you want to reply?" and listening for a yes. */
|
||||||
|
OFFERING,
|
||||||
|
/** Saying "What's your reply?". */
|
||||||
|
ASKING,
|
||||||
|
LISTENING,
|
||||||
|
/**
|
||||||
|
* Reading the reply back, then counting down to [State.until] while listening for
|
||||||
|
* "cancel" or "change it". [State.until] is 0 while the countdown is held.
|
||||||
|
*/
|
||||||
|
CONFIRMING,
|
||||||
|
SENT,
|
||||||
|
ERROR,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class State(
|
||||||
|
val phase: Phase = Phase.IDLE,
|
||||||
|
val target: CarNotification? = null,
|
||||||
|
val heard: String = "",
|
||||||
|
val error: String = "",
|
||||||
|
/**
|
||||||
|
* Uptime at which this step ends on its own (the card goes, or the reply sends), and how
|
||||||
|
* long the step was given, for the card's bar. 0 when nothing is timed.
|
||||||
|
*/
|
||||||
|
val until: Long = 0L,
|
||||||
|
val span: Long = 0L,
|
||||||
|
/** While a countdown is held, how much of its bar was left, so the bar stays put. */
|
||||||
|
val held: Float = 0f,
|
||||||
|
)
|
||||||
|
|
||||||
|
const val SEND_DELAY_MS = 5_000L
|
||||||
|
private const val LINGER_MS = 5_000L
|
||||||
|
private const val SENT_MS = 2_000L
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(State())
|
||||||
|
val state: StateFlow<State> = _state
|
||||||
|
|
||||||
|
private val main = Handler(Looper.getMainLooper())
|
||||||
|
private var appContext: Context? = null
|
||||||
|
private var run = 0
|
||||||
|
|
||||||
|
fun init(context: Context) { appContext = context.applicationContext }
|
||||||
|
|
||||||
|
val active: Boolean get() = _state.value.phase != Phase.IDLE
|
||||||
|
|
||||||
|
/** Reads [n]'s newest messages aloud. */
|
||||||
|
fun read(n: CarNotification) {
|
||||||
|
val id = begin(State(Phase.READING, n))
|
||||||
|
CarAssistant.say(spoken(n)) {
|
||||||
|
if (id != run) return@say
|
||||||
|
when {
|
||||||
|
n.reply == null -> finish()
|
||||||
|
// The microphone is silent during a call, so there's no point asking.
|
||||||
|
CarCalls.state.value is CallUiState.Active -> idleAfterRead(id)
|
||||||
|
else -> offer(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Do you want to reply?" A yes goes on to the reply; anything else ends quietly. The bar runs
|
||||||
|
* while it listens, so the card never looks stuck, and holds once the driver starts talking.
|
||||||
|
*/
|
||||||
|
private fun offer(id: Int) {
|
||||||
|
_state.value = _state.value.copy(phase = Phase.OFFERING, heard = "", until = 0L, span = 0L, held = 0f)
|
||||||
|
CarAssistant.say("Do you want to reply?") {
|
||||||
|
if (id != run) return@say
|
||||||
|
val until = SystemClock.uptimeMillis() + OFFER_MS
|
||||||
|
_state.value = _state.value.copy(until = until, span = OFFER_MS)
|
||||||
|
// The recognizer gives up on its own; this only covers one that doesn't.
|
||||||
|
main.postAtTime({ if (id == run && _state.value.until != 0L) cancel() }, until + OFFER_GRACE_MS)
|
||||||
|
CarAssistant.dictate(
|
||||||
|
onPartial = { text -> if (id == run) _state.value = _state.value.copy(heard = text) },
|
||||||
|
onSpeaking = {
|
||||||
|
if (id != run) return@dictate
|
||||||
|
val s = _state.value
|
||||||
|
val left = ((s.until - SystemClock.uptimeMillis()).toFloat() / s.span).coerceIn(0.05f, 1f)
|
||||||
|
_state.value = s.copy(until = 0L, span = 0L, held = left)
|
||||||
|
},
|
||||||
|
onResult = { text ->
|
||||||
|
if (id != run) return@dictate
|
||||||
|
if (isYes(text)) {
|
||||||
|
_state.value = _state.value.copy(phase = Phase.ASKING, heard = "", until = 0L, span = 0L, held = 0f)
|
||||||
|
CarAssistant.say("Go ahead.") { if (id == run) listen(id) }
|
||||||
|
} else {
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// No answer: the card has already offered Reply and the quick replies all along.
|
||||||
|
onError = { if (id == run) finish() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun idleAfterRead(id: Int) {
|
||||||
|
_state.value = _state.value.copy(phase = Phase.READ, heard = "")
|
||||||
|
lingerThenIdle(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isYes(text: String): Boolean {
|
||||||
|
val words = words(text)
|
||||||
|
if (words.any { it in NO_WORDS }) return false
|
||||||
|
return words.any { it in YES_WORDS }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads [n] aloud and asks whether to reply, or, when the driver has just heard it, asks for
|
||||||
|
* the reply straight away. Then listens, reads the reply back and sends it.
|
||||||
|
*/
|
||||||
|
fun reply(n: CarNotification, readFirst: Boolean = true) {
|
||||||
|
if (n.reply == null) {
|
||||||
|
val id = begin(State(Phase.ERROR, n, error = "${n.appLabel} doesn't allow replies from the car."))
|
||||||
|
lingerThenIdle(id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Android hands every app but the dialer a silent microphone during a call.
|
||||||
|
if (CarCalls.state.value is CallUiState.Active) {
|
||||||
|
val id = begin(State(Phase.ERROR, n, error = "Voice reply works once the call ends."))
|
||||||
|
lingerThenIdle(id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!readFirst) {
|
||||||
|
val id = begin(State(Phase.ASKING, n))
|
||||||
|
ask(id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Read it, then "Do you want to reply?", the same as Read.
|
||||||
|
read(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ask(id: Int) {
|
||||||
|
CarAssistant.say("What's your reply?") {
|
||||||
|
if (id == run) listen(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun listen(id: Int) {
|
||||||
|
_state.value = _state.value.copy(phase = Phase.LISTENING, heard = "")
|
||||||
|
CarAssistant.dictate(
|
||||||
|
onPartial = { text -> if (id == run) _state.value = _state.value.copy(heard = text) },
|
||||||
|
onResult = { text -> if (id == run) confirm(id, text) },
|
||||||
|
onError = { message ->
|
||||||
|
if (id != run) return@dictate
|
||||||
|
_state.value = _state.value.copy(phase = Phase.ERROR, error = message)
|
||||||
|
lingerThenIdle(id)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun confirm(id: Int, text: String) {
|
||||||
|
_state.value = _state.value.copy(phase = Phase.CONFIRMING, heard = text, until = 0L, span = 0L, held = 0f)
|
||||||
|
// The countdown starts once the driver has heard the whole reply, not while it's read out.
|
||||||
|
CarAssistant.say("Sending: $text") {
|
||||||
|
if (id != run) return@say
|
||||||
|
startCountdown(id, SEND_DELAY_MS)
|
||||||
|
// A moment for the read-back to finish playing in the car before the microphone opens.
|
||||||
|
main.postDelayed({ if (id == run) listenForCommand(id) }, ECHO_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends in [ms]. After a hold the bar carries on from where it stopped instead of jumping back
|
||||||
|
* to full: the span is stretched so the bar still runs out exactly at [ms].
|
||||||
|
*/
|
||||||
|
private fun startCountdown(id: Int, ms: Long) {
|
||||||
|
val sendAt = SystemClock.uptimeMillis() + ms
|
||||||
|
val held = _state.value.held
|
||||||
|
val span = if (held > 0f) (ms / held).toLong() else ms
|
||||||
|
_state.value = _state.value.copy(until = sendAt, span = span, held = 0f)
|
||||||
|
main.removeCallbacksAndMessages(SEND_TOKEN)
|
||||||
|
main.postAtTime({ if (id == run) send() }, SEND_TOKEN, sendAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listens through the countdown for "cancel", "change it" or "send". The countdown holds from
|
||||||
|
* the first word, so a reply can't go out while the driver is still saying "cancel".
|
||||||
|
*/
|
||||||
|
private fun listenForCommand(id: Int) {
|
||||||
|
CarAssistant.dictate(
|
||||||
|
onPartial = {},
|
||||||
|
cue = false,
|
||||||
|
onSpeaking = {
|
||||||
|
if (id != run) return@dictate
|
||||||
|
main.removeCallbacksAndMessages(SEND_TOKEN)
|
||||||
|
val s = _state.value
|
||||||
|
val left = if (s.until > 0L && s.span > 0L) {
|
||||||
|
((s.until - SystemClock.uptimeMillis()).toFloat() / s.span).coerceIn(0.05f, 1f)
|
||||||
|
} else {
|
||||||
|
s.held
|
||||||
|
}
|
||||||
|
_state.value = s.copy(until = 0L, span = 0L, held = left)
|
||||||
|
},
|
||||||
|
onResult = { text ->
|
||||||
|
if (id != run) return@dictate
|
||||||
|
when (command(text)) {
|
||||||
|
Command.CANCEL -> {
|
||||||
|
finish()
|
||||||
|
CarAssistant.say("Not sent.") {}
|
||||||
|
}
|
||||||
|
Command.CHANGE -> {
|
||||||
|
main.removeCallbacksAndMessages(SEND_TOKEN)
|
||||||
|
_state.value = _state.value.copy(phase = Phase.ASKING, heard = "", until = 0L, span = 0L, held = 0f)
|
||||||
|
ask(id)
|
||||||
|
}
|
||||||
|
Command.SEND -> send()
|
||||||
|
// Talk that wasn't meant for us, a passenger or the radio: carry on counting.
|
||||||
|
null -> startCountdown(id, RESUME_MS)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError = { if (id == run && _state.value.until == 0L) startCountdown(id, RESUME_MS) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class Command { CANCEL, CHANGE, SEND }
|
||||||
|
|
||||||
|
private fun command(text: String): Command? {
|
||||||
|
val words = words(text)
|
||||||
|
return when {
|
||||||
|
words.any { it in CANCEL_WORDS } -> Command.CANCEL
|
||||||
|
words.any { it in CHANGE_WORDS } -> Command.CHANGE
|
||||||
|
words.any { it in SEND_WORDS } -> Command.SEND
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun words(text: String) = text.lowercase().split(Regex("[^a-z']+")).filter { it.isNotEmpty() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ready-made replies for a tap instead of a voice reply. The first gives the arrival time when
|
||||||
|
* the map is following a route.
|
||||||
|
*/
|
||||||
|
fun quickReplies(): List<String> = listOfNotNull(etaReply(), "Driving, I'll reply soon.", "On my way.")
|
||||||
|
|
||||||
|
private fun etaReply(): String? {
|
||||||
|
val estimate = (CarServices.nav.template.value as? NavigationTemplate)?.destinationTravelEstimate ?: return null
|
||||||
|
val at = estimate.arrivalTimeAtDestination.clockTime()
|
||||||
|
val left = remainingTime(estimate.remainingTimeSeconds)
|
||||||
|
return when {
|
||||||
|
at.isNotEmpty() -> "Driving, I'll be there around $at."
|
||||||
|
left.isNotEmpty() -> "Driving, I'll be there in about $left."
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sends [text] to [n] straight away, as if it had been said and confirmed. */
|
||||||
|
fun quickReply(n: CarNotification, text: String) {
|
||||||
|
if (n.reply == null) return
|
||||||
|
begin(State(Phase.CONFIRMING, n, heard = text))
|
||||||
|
send()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sends a reply that's counting down, without waiting for the rest of it. */
|
||||||
|
fun sendNow() {
|
||||||
|
if (_state.value.phase == Phase.CONFIRMING) send()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun send() {
|
||||||
|
val s = _state.value
|
||||||
|
val n = s.target ?: return finish()
|
||||||
|
val context = appContext ?: return finish()
|
||||||
|
val id = ++run
|
||||||
|
CarAssistant.stopListening()
|
||||||
|
CarAssistant.stopSpeaking()
|
||||||
|
if (CarNotifications.reply(context, n, s.heard.trim())) {
|
||||||
|
GearslipLog.i("voice reply: sent to ${n.appLabel}")
|
||||||
|
CarAssistant.say("Message sent.") {}
|
||||||
|
CarNotifications.dismissPopup(n.id)
|
||||||
|
_state.value = s.copy(phase = Phase.SENT, until = SystemClock.uptimeMillis() + SENT_MS, span = SENT_MS, held = 0f)
|
||||||
|
main.postDelayed({ if (id == run) finish() }, SENT_MS)
|
||||||
|
} else {
|
||||||
|
_state.value = s.copy(phase = Phase.ERROR, error = "${n.appLabel} withdrew the reply. Open the app on the phone to answer.")
|
||||||
|
lingerThenIdle(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stops wherever it is. Nothing is sent. */
|
||||||
|
fun cancel() {
|
||||||
|
if (!active) return
|
||||||
|
CarAssistant.stopListening()
|
||||||
|
CarAssistant.stopSpeaking()
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The steering wheel's voice key. With a message on screen it answers that message, and
|
||||||
|
* during a countdown it sends straight away. Returns false when there's nothing to answer, so
|
||||||
|
* the key can start the assistant instead.
|
||||||
|
*/
|
||||||
|
fun onVoiceKey(): Boolean {
|
||||||
|
val s = _state.value
|
||||||
|
when (s.phase) {
|
||||||
|
// Already heard, so straight to the reply.
|
||||||
|
Phase.READ, Phase.READING, Phase.OFFERING, Phase.ERROR -> s.target?.let { reply(it, readFirst = false); return true }
|
||||||
|
Phase.CONFIRMING -> { sendNow(); return true }
|
||||||
|
Phase.ASKING, Phase.LISTENING, Phase.SENT -> return true
|
||||||
|
Phase.IDLE -> Unit
|
||||||
|
}
|
||||||
|
val popup = CarNotifications.popup.value ?: return false
|
||||||
|
if (popup.reply == null) return false
|
||||||
|
reply(popup)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun begin(state: State): Int {
|
||||||
|
CarAssistant.stopListening()
|
||||||
|
CarAssistant.stopSpeaking()
|
||||||
|
main.removeCallbacksAndMessages(null)
|
||||||
|
_state.value = state
|
||||||
|
return ++run
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun lingerThenIdle(id: Int) {
|
||||||
|
_state.value = _state.value.copy(until = SystemClock.uptimeMillis() + LINGER_MS, span = LINGER_MS, held = 0f)
|
||||||
|
main.postDelayed({ if (id == run) finish() }, LINGER_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finish() {
|
||||||
|
run++
|
||||||
|
main.removeCallbacksAndMessages(null)
|
||||||
|
_state.value = State()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Alex says: ..." or, in a group, "Sam in Climbing says: ...", for the newest messages. */
|
||||||
|
private fun spoken(n: CarNotification): String {
|
||||||
|
val from = n.title.ifBlank { n.appLabel }
|
||||||
|
val lines = n.lines.filter { it.sender != null }.takeLast(n.burst.coerceIn(1, MAX_READ))
|
||||||
|
if (lines.isEmpty()) return "$from says: ${n.text}"
|
||||||
|
return lines.joinToString(". ") { line ->
|
||||||
|
val who = if (n.isGroup) "${line.sender} in $from" else from
|
||||||
|
"$who says: ${line.text}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val MAX_READ = 3
|
||||||
|
private val YES_WORDS = setOf("yes", "yeah", "yep", "yup", "sure", "ok", "okay", "reply", "please")
|
||||||
|
private val NO_WORDS = setOf("no", "nope", "nah", "cancel", "don't", "stop")
|
||||||
|
private val CANCEL_WORDS = setOf("cancel", "stop", "don't", "no", "nope")
|
||||||
|
private val CHANGE_WORDS = setOf("change", "redo", "again", "edit", "wrong", "fix")
|
||||||
|
private val SEND_WORDS = setOf("send", "yes", "yeah", "ok", "okay", "go")
|
||||||
|
|
||||||
|
private val SEND_TOKEN = Any()
|
||||||
|
private const val ECHO_MS = 300L
|
||||||
|
/** How long "Do you want to reply?" waits for an answer: the start beep, then the engine's silence limit. */
|
||||||
|
private const val OFFER_MS = 6_500L
|
||||||
|
private const val OFFER_GRACE_MS = 1_500L
|
||||||
|
/** After talk that wasn't a command, the reply waits this long again before it goes. */
|
||||||
|
private const val RESUME_MS = 3_000L
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.location.Geocoder
|
||||||
|
import android.location.Location
|
||||||
|
import android.location.LocationManager
|
||||||
|
import app.seb3thehacker.gearslip.GearslipLog
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
class HourForecast(val time: Long, val temp: Int, val code: Int, val isDay: Boolean, val rainChance: Int)
|
||||||
|
|
||||||
|
class DayForecast(val date: Long, val high: Int, val low: Int, val code: Int, val rainChance: Int)
|
||||||
|
|
||||||
|
class WeatherData(
|
||||||
|
val place: String,
|
||||||
|
val temp: Int,
|
||||||
|
val feelsLike: Int,
|
||||||
|
val code: Int,
|
||||||
|
val isDay: Boolean,
|
||||||
|
val humidity: Int,
|
||||||
|
val wind: Int,
|
||||||
|
val tempUnit: String,
|
||||||
|
val windUnit: String,
|
||||||
|
val hours: List<HourForecast>,
|
||||||
|
val days: List<DayForecast>,
|
||||||
|
val fetchedAt: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface WeatherState {
|
||||||
|
data object Loading : WeatherState
|
||||||
|
data class Unavailable(val reason: String) : WeatherState
|
||||||
|
data class Ready(val data: WeatherData) : WeatherState
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current weather and a week's forecast for wherever the phone is, from Open-Meteo (free, no
|
||||||
|
* key or account; the data is CC BY 4.0, so the weather screen credits it).
|
||||||
|
*
|
||||||
|
* The location is the phone's last fix if it is recent, otherwise a fresh one is requested, so
|
||||||
|
* a phone that has been idle does not leave the card on "unavailable".
|
||||||
|
*/
|
||||||
|
object Weather {
|
||||||
|
|
||||||
|
private const val TTL_MS = 30 * 60 * 1000L
|
||||||
|
private const val FIX_MAX_AGE_MS = 30 * 60 * 1000L
|
||||||
|
private const val FIX_TIMEOUT_S = 12L
|
||||||
|
|
||||||
|
private val flow = MutableStateFlow<WeatherState>(WeatherState.Loading)
|
||||||
|
val state: StateFlow<WeatherState> = flow
|
||||||
|
|
||||||
|
private val io = Executors.newSingleThreadExecutor { Thread(it, "gearslip-weather").apply { isDaemon = true } }
|
||||||
|
|
||||||
|
private var placeCache: Pair<String, String>? = null // rounded "lat,lon" to place name
|
||||||
|
|
||||||
|
/** Runs off the main thread. Fresh data younger than [TTL_MS] is kept unless [force]. */
|
||||||
|
fun refresh(context: Context, force: Boolean = false) {
|
||||||
|
val current = flow.value
|
||||||
|
if (!force && current is WeatherState.Ready && System.currentTimeMillis() - current.data.fetchedAt < TTL_MS) return
|
||||||
|
val app = context.applicationContext
|
||||||
|
io.execute { load(app) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun load(context: Context) {
|
||||||
|
// Keep showing the old reading while a new one loads; only the first load shows a spinner.
|
||||||
|
val location = locate(context)
|
||||||
|
if (location == null) {
|
||||||
|
if (flow.value !is WeatherState.Ready) {
|
||||||
|
val allowed = hasLocationPermission(context)
|
||||||
|
flow.value = WeatherState.Unavailable(
|
||||||
|
if (allowed) "Waiting for a location fix." else "Allow location for Gearslip on the phone to see the weather.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val data = runCatching { fetch(context, location) }
|
||||||
|
.onFailure { GearslipLog.w("weather fetch failed: ${it.message}") }
|
||||||
|
.getOrNull()
|
||||||
|
if (data != null) {
|
||||||
|
flow.value = WeatherState.Ready(data)
|
||||||
|
GearslipLog.i("weather: ${data.place} ${data.temp}${data.tempUnit} ${describe(data.code)}")
|
||||||
|
}
|
||||||
|
else if (flow.value !is WeatherState.Ready) flow.value = WeatherState.Unavailable("Could not reach the weather service.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- location -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun hasLocationPermission(context: Context) =
|
||||||
|
context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
|
||||||
|
context.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
private fun locate(context: Context): Location? {
|
||||||
|
if (!hasLocationPermission(context)) return null
|
||||||
|
val manager = context.getSystemService(LocationManager::class.java) ?: return null
|
||||||
|
val known = manager.allProviders
|
||||||
|
.mapNotNull { runCatching { manager.getLastKnownLocation(it) }.getOrNull() }
|
||||||
|
.maxByOrNull { it.time }
|
||||||
|
if (known != null && System.currentTimeMillis() - known.time < FIX_MAX_AGE_MS) return known
|
||||||
|
|
||||||
|
val provider = listOf(LocationManager.FUSED_PROVIDER, LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER)
|
||||||
|
.firstOrNull { it in manager.allProviders && manager.isProviderEnabled(it) }
|
||||||
|
?: return known
|
||||||
|
val latch = CountDownLatch(1)
|
||||||
|
var fresh: Location? = null
|
||||||
|
runCatching {
|
||||||
|
manager.getCurrentLocation(provider, null, io) { fresh = it; latch.countDown() }
|
||||||
|
latch.await(FIX_TIMEOUT_S, TimeUnit.SECONDS)
|
||||||
|
}.onFailure { GearslipLog.w("weather: fresh fix failed: ${it.message}") }
|
||||||
|
// A stale fix still gives the right city, which is all a forecast needs.
|
||||||
|
return fresh ?: known
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun placeName(context: Context, location: Location): String {
|
||||||
|
val key = "%.2f,%.2f".format(Locale.US, location.latitude, location.longitude)
|
||||||
|
placeCache?.let { if (it.first == key) return it.second }
|
||||||
|
val name = runCatching {
|
||||||
|
if (!Geocoder.isPresent()) return@runCatching null
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
Geocoder(context, Locale.getDefault()).getFromLocation(location.latitude, location.longitude, 1)
|
||||||
|
?.firstOrNull()?.let { it.locality ?: it.subAdminArea ?: it.adminArea }
|
||||||
|
}.getOrNull() ?: "%.2f, %.2f".format(Locale.US, location.latitude, location.longitude)
|
||||||
|
placeCache = key to name
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- network --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun fetch(context: Context, location: Location): WeatherData {
|
||||||
|
val imperial = CarSettings.imperial()
|
||||||
|
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 -> "-"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
private val SUN = Color(0xFFFFB300)
|
||||||
|
private val MOON = Color(0xFFCFD8DC)
|
||||||
|
private val CLOUD = Color(0xFF90A4AE)
|
||||||
|
private val RAIN = Color(0xFF29B6F6)
|
||||||
|
private val SNOW = Color(0xFF81D4FA)
|
||||||
|
private val BOLT = Color(0xFFFFD54F)
|
||||||
|
|
||||||
|
/** Sun, moon, cloud, rain, snow, storm or fog for a WMO weather code. Drawn, so it needs no icon pack. */
|
||||||
|
@Composable
|
||||||
|
fun WeatherIcon(code: Int, isDay: Boolean, size: Dp = 28.dp, modifier: Modifier = Modifier) {
|
||||||
|
Canvas(modifier.size(size)) {
|
||||||
|
when (code) {
|
||||||
|
0 -> if (isDay) sun(center, this.size.minDimension * 0.5f) else moon(center, this.size.minDimension * 0.5f)
|
||||||
|
1, 2 -> {
|
||||||
|
if (isDay) sun(Offset(this.size.width * 0.38f, this.size.height * 0.36f), this.size.minDimension * 0.36f)
|
||||||
|
else moon(Offset(this.size.width * 0.38f, this.size.height * 0.36f), this.size.minDimension * 0.36f)
|
||||||
|
cloud(Offset(this.size.width * 0.06f, this.size.height * 0.34f), Size(this.size.width * 0.9f, this.size.height * 0.6f))
|
||||||
|
}
|
||||||
|
3 -> cloud(Offset(0f, this.size.height * 0.2f), Size(this.size.width, this.size.height * 0.7f))
|
||||||
|
45, 48 -> fog()
|
||||||
|
in 71..77, in 85..86 -> precip(SNOW, dots = true)
|
||||||
|
in 95..99 -> storm()
|
||||||
|
else -> precip(RAIN, dots = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DrawScope.sun(c: Offset, r: Float) {
|
||||||
|
drawCircle(SUN, r * 0.55f, c)
|
||||||
|
val stroke = r * 0.14f
|
||||||
|
for (i in 0 until 8) {
|
||||||
|
val a = Math.toRadians(i * 45.0)
|
||||||
|
drawLine(
|
||||||
|
SUN, c + Offset(cos(a).toFloat(), sin(a).toFloat()) * (r * 0.75f),
|
||||||
|
c + Offset(cos(a).toFloat(), sin(a).toFloat()) * r, stroke, StrokeCap.Round,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DrawScope.moon(c: Offset, r: Float) {
|
||||||
|
val disc = Path().apply { addOval(androidx.compose.ui.geometry.Rect(c, r * 0.62f)) }
|
||||||
|
val bite = Path().apply { addOval(androidx.compose.ui.geometry.Rect(c + Offset(r * 0.32f, -r * 0.22f), r * 0.55f)) }
|
||||||
|
drawPath(Path.combine(androidx.compose.ui.graphics.PathOperation.Difference, disc, bite), MOON)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DrawScope.cloud(topLeft: Offset, s: Size) {
|
||||||
|
val h = s.height
|
||||||
|
drawCircle(CLOUD, h * 0.42f, topLeft + Offset(s.width * 0.3f, h * 0.55f))
|
||||||
|
drawCircle(CLOUD, h * 0.55f, topLeft + Offset(s.width * 0.55f, h * 0.42f))
|
||||||
|
drawCircle(CLOUD, h * 0.38f, topLeft + Offset(s.width * 0.78f, h * 0.62f))
|
||||||
|
drawRoundRect(CLOUD, topLeft + Offset(s.width * 0.28f, h * 0.55f), Size(s.width * 0.52f, h * 0.45f),
|
||||||
|
androidx.compose.ui.geometry.CornerRadius(h * 0.2f))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DrawScope.precip(color: Color, dots: Boolean) {
|
||||||
|
cloud(Offset(0f, 0f), Size(size.width, size.height * 0.62f))
|
||||||
|
for (i in 0 until 3) {
|
||||||
|
val x = size.width * (0.28f + 0.22f * i)
|
||||||
|
val y = size.height * 0.72f
|
||||||
|
if (dots) drawCircle(color, size.width * 0.045f, Offset(x, y + size.height * 0.08f))
|
||||||
|
else drawLine(color, Offset(x, y), Offset(x - size.width * 0.06f, y + size.height * 0.2f), size.width * 0.07f, StrokeCap.Round)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DrawScope.storm() {
|
||||||
|
cloud(Offset(0f, 0f), Size(size.width, size.height * 0.62f))
|
||||||
|
val w = size.width
|
||||||
|
val h = size.height
|
||||||
|
val bolt = Path().apply {
|
||||||
|
moveTo(w * 0.55f, h * 0.55f); lineTo(w * 0.36f, h * 0.82f); lineTo(w * 0.5f, h * 0.82f)
|
||||||
|
lineTo(w * 0.42f, h * 1f); lineTo(w * 0.66f, h * 0.7f); lineTo(w * 0.52f, h * 0.7f); close()
|
||||||
|
}
|
||||||
|
drawPath(bolt, BOLT)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DrawScope.fog() {
|
||||||
|
val stroke = Stroke(width = size.height * 0.09f, cap = StrokeCap.Round)
|
||||||
|
for (i in 0 until 4) {
|
||||||
|
val y = size.height * (0.25f + 0.17f * i)
|
||||||
|
val inset = if (i % 2 == 0) 0.1f else 0.2f
|
||||||
|
drawLine(CLOUD, Offset(size.width * inset, y), Offset(size.width * (1f - inset), y), stroke.width, stroke.cap)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import java.text.DateFormat
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/** The weather in full: now, the next twelve hours and the week. Opened from the nav bar or the dashboard. */
|
||||||
|
@Composable
|
||||||
|
fun WeatherScreen() {
|
||||||
|
val context = LocalContext.current.applicationContext
|
||||||
|
val state by Weather.state.collectAsState()
|
||||||
|
LaunchedEffect(Unit) { Weather.refresh(context) }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 24.dp, vertical = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||||
|
) {
|
||||||
|
when (val s = state) {
|
||||||
|
WeatherState.Loading -> Text("Getting the weather…", style = MaterialTheme.typography.titleMedium)
|
||||||
|
is WeatherState.Unavailable -> Text(
|
||||||
|
s.reason, style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
is WeatherState.Ready -> Forecast(s.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Forecast(w: WeatherData) {
|
||||||
|
Text(w.place, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||||
|
|
||||||
|
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.extraLarge) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(24.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
|
) {
|
||||||
|
WeatherIcon(w.code, w.isDay, size = 88.dp)
|
||||||
|
Column {
|
||||||
|
Text("${w.temp}${w.tempUnit}", fontSize = 56.sp, fontWeight = FontWeight.Bold)
|
||||||
|
Text(Weather.describe(w.code), style = MaterialTheme.typography.titleLarge)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Text("Feels like ${w.feelsLike}${w.tempUnit}", style = MaterialTheme.typography.bodyLarge)
|
||||||
|
Text("Humidity ${w.humidity}%", style = MaterialTheme.typography.bodyLarge)
|
||||||
|
Text("Wind ${w.wind} ${w.windUnit}", style = MaterialTheme.typography.bodyLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Next hours", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||||
|
LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
items(w.hours) { h ->
|
||||||
|
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.large) {
|
||||||
|
Column(
|
||||||
|
Modifier.width(84.dp).padding(vertical = 12.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(h.time)).replace(":00", ""),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
)
|
||||||
|
WeatherIcon(h.code, h.isDay, size = 32.dp)
|
||||||
|
Text("${h.temp}°", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||||
|
Text(
|
||||||
|
if (h.rainChance > 0) "${h.rainChance}%" else " ",
|
||||||
|
style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("This week", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
w.days.forEachIndexed { i, d ->
|
||||||
|
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.large) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
if (i == 0) "Today" else SimpleDateFormat("EEEE", Locale.getDefault()).format(Date(d.date)),
|
||||||
|
Modifier.width(120.dp), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
WeatherIcon(d.code, true, size = 28.dp)
|
||||||
|
Text(Weather.describe(d.code), Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium)
|
||||||
|
if (d.rainChance > 0) Text("${d.rainChance}%", color = MaterialTheme.colorScheme.primary)
|
||||||
|
Text("${d.low}° / ${d.high}°", style = MaterialTheme.typography.bodyLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"Weather data by Open-Meteo.com (CC BY 4.0)",
|
||||||
|
style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
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) },
|
||||||
|
onDismiss = {
|
||||||
|
fieldText = null
|
||||||
|
web?.evaluateJavascript(BLUR_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.
|
||||||
|
// Dismissing the host keyboard without blurring the page's own field would leave it focused,
|
||||||
|
// so the next tap anywhere with a text cursor pops the keyboard straight back up.
|
||||||
|
private const val BLUR_FIELD_JS = "document.activeElement && document.activeElement.blur();"
|
||||||
|
|
||||||
|
private const val SUBMIT_FIELD_JS = """
|
||||||
|
(function() {
|
||||||
|
var el = document.activeElement;
|
||||||
|
if (!el) return;
|
||||||
|
var enter = new KeyboardEvent('keydown', {
|
||||||
|
key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true,
|
||||||
|
});
|
||||||
|
var notHandled = el.dispatchEvent(enter);
|
||||||
|
if (notHandled && el.form) {
|
||||||
|
if (el.form.requestSubmit) el.form.requestSubmit(); else el.form.submit();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
"""
|
||||||
|
|
||||||
|
/** Reports focus/blur on text inputs, textareas and contenteditable elements to [FieldFocusBridge]. */
|
||||||
|
private const val FOCUS_TRACKING_JS = """
|
||||||
|
(function() {
|
||||||
|
function isEditable(el) {
|
||||||
|
if (!el) return false;
|
||||||
|
var tag = el.tagName;
|
||||||
|
if (tag === 'TEXTAREA') return true;
|
||||||
|
if (el.isContentEditable) return true;
|
||||||
|
if (tag !== 'INPUT') return false;
|
||||||
|
var skip = ['button', 'submit', 'checkbox', 'radio', 'range', 'color', 'file', 'image', 'reset'];
|
||||||
|
return skip.indexOf((el.type || 'text').toLowerCase()) === -1;
|
||||||
|
}
|
||||||
|
document.addEventListener('focusin', function(e) {
|
||||||
|
if (isEditable(e.target)) window.GearslipField.focused(e.target.value || '');
|
||||||
|
});
|
||||||
|
document.addEventListener('focusout', function(e) {
|
||||||
|
if (isEditable(e.target)) window.GearslipField.blurred();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
"""
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car.theme
|
||||||
|
|
||||||
|
import android.os.SystemClock
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.drawOutline
|
||||||
|
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||||
|
import androidx.compose.ui.graphics.drawscope.translate
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.interaction.PressInteraction
|
||||||
|
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.defaultMinSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ProvideTextStyle
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
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.draw.drawWithContent
|
||||||
|
import androidx.compose.ui.draw.scale
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Shape
|
||||||
|
import androidx.compose.ui.graphics.isSpecified
|
||||||
|
import androidx.compose.ui.graphics.lerp
|
||||||
|
import androidx.compose.ui.graphics.luminance
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/** What a control is for, which picks its colours from the Material scheme. */
|
||||||
|
enum class GsTone { Primary, Tonal, Neutral, Outline, Danger }
|
||||||
|
|
||||||
|
data class GsColors(val container: Color, val content: Color, val border: Color = Color.Unspecified)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun gsColors(tone: GsTone): GsColors {
|
||||||
|
val s = MaterialTheme.colorScheme
|
||||||
|
return when (tone) {
|
||||||
|
GsTone.Primary -> GsColors(s.primary, s.onPrimary)
|
||||||
|
GsTone.Tonal -> GsColors(s.secondaryContainer, s.onSecondaryContainer)
|
||||||
|
GsTone.Neutral -> GsColors(s.surfaceContainerHighest, s.onSurfaceVariant)
|
||||||
|
GsTone.Outline -> GsColors(Color.Transparent, s.primary, s.outline)
|
||||||
|
GsTone.Danger -> GsColors(s.error, s.onError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun gsShape(): Shape = RoundedCornerShape(gsLook().corner)
|
||||||
|
|
||||||
|
/** The current theme's control look, swapped for its light-mode variant on a light UI. */
|
||||||
|
@Composable
|
||||||
|
fun gsLook(): ControlLook {
|
||||||
|
val look = LocalGsTheme.current.control
|
||||||
|
val light = look.light ?: return look
|
||||||
|
return if (MaterialTheme.colorScheme.surface.luminance() > 0.5f) light else look
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paints a control's container in the current theme's finish. Transparent containers stay
|
||||||
|
* transparent in every theme. Use directly for custom-shaped tappables; [GsButton] and
|
||||||
|
* [GsIconButton] already apply it. [latched] holds a control down - a selected option, the current
|
||||||
|
* screen's nav button - in themes that have a pressed-in look.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun Modifier.gsSurface(
|
||||||
|
container: Color,
|
||||||
|
shape: Shape = gsShape(),
|
||||||
|
border: Color = Color.Unspecified,
|
||||||
|
pressed: Boolean = false,
|
||||||
|
latched: Boolean = false,
|
||||||
|
): Modifier {
|
||||||
|
val look = gsLook()
|
||||||
|
val down = pressed || latched
|
||||||
|
// The keycap's face sinks onto its base while pressed, and stays down while latched: the base
|
||||||
|
// shrinks to nothing and the face moves down by the same amount, so the control's overall size
|
||||||
|
// never changes.
|
||||||
|
val lipNow by animateDpAsState(if (down) 0.dp else look.lip, tween(if (pressed) 50 else 110), label = "keycap")
|
||||||
|
// Tactile: the drop shadow collapses and the face drops by [ControlLook.sink].
|
||||||
|
val lift by animateFloatAsState(if (down) 0f else 1f, tween(if (pressed) 50 else 140), label = "lift")
|
||||||
|
val sinkNow by animateDpAsState(if (down) look.sink else 0.dp, tween(if (pressed) 50 else 140), label = "sink")
|
||||||
|
var m = this
|
||||||
|
if (container.alpha > 0f && look.shadow > 0.dp) {
|
||||||
|
m = m.drawBehind { drawSoftShadow(shape, look.shadow.toPx(), look.shadowAlpha * lift, lift) }
|
||||||
|
}
|
||||||
|
if (look.sink > 0.dp) m = m.offset(y = sinkNow)
|
||||||
|
if (container.alpha > 0f && look.lip > 0.dp) {
|
||||||
|
val base = lerp(container, Color.Black, 0.35f)
|
||||||
|
m = m
|
||||||
|
.padding(top = look.lip - lipNow, bottom = lipNow)
|
||||||
|
.drawBehind {
|
||||||
|
translate(top = lipNow.toPx()) {
|
||||||
|
drawOutline(shape.createOutline(size, layoutDirection, this), base)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m = m.clip(shape)
|
||||||
|
if (container.alpha > 0f) {
|
||||||
|
val shade = if (container.luminance() > 0.5f) look.bottomShadeOnLight else look.bottomShade
|
||||||
|
m = if (look.topLight > 0f || shade > 0f) {
|
||||||
|
m.background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
0f to lerp(container, Color.White, look.topLight),
|
||||||
|
0.5f to container,
|
||||||
|
1f to lerp(container, Color.Black, shade),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
m.background(container)
|
||||||
|
}
|
||||||
|
if (latched && look.insetAlpha > 0f) {
|
||||||
|
val inset = look.insetAlpha
|
||||||
|
m = m.drawBehind {
|
||||||
|
drawRect(Brush.verticalGradient(0f to Color.Black.copy(alpha = inset), 0.3f to Color.Transparent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (look.rimAlpha > 0f) {
|
||||||
|
m = m.border(1.dp, Brush.verticalGradient(listOf(Color.White.copy(alpha = look.rimAlpha), Color.Transparent)), shape)
|
||||||
|
}
|
||||||
|
if (!latched && (look.bevelTop > 0f || look.bevelBottom > 0f)) {
|
||||||
|
m = m.border(
|
||||||
|
1.dp,
|
||||||
|
Brush.verticalGradient(
|
||||||
|
0f to Color.White.copy(alpha = look.bevelTop),
|
||||||
|
0.3f to Color.Transparent,
|
||||||
|
0.7f to Color.Transparent,
|
||||||
|
1f to Color.Black.copy(alpha = look.bevelBottom),
|
||||||
|
),
|
||||||
|
shape,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (border.isSpecified) m = m.border(BorderStroke(1.dp, border), shape)
|
||||||
|
if (pressed) m = m.drawWithContent {
|
||||||
|
drawContent()
|
||||||
|
drawRect(Color.Black.copy(alpha = look.pressedDim))
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A blurred shadow falling [elevation] below the control, faked with a few stacked, growing
|
||||||
|
* outlines: cheap, works on every API level, and already soft enough that the car's video
|
||||||
|
* compression has nothing crisp to smear.
|
||||||
|
*/
|
||||||
|
private fun DrawScope.drawSoftShadow(shape: Shape, elevation: Float, alpha: Float, lift: Float) {
|
||||||
|
if (alpha <= 0f) return
|
||||||
|
val blur = elevation * 2f
|
||||||
|
val steps = 6
|
||||||
|
for (i in steps downTo 1) {
|
||||||
|
val grow = blur * i / steps - blur * 0.35f
|
||||||
|
val outline = shape.createOutline(
|
||||||
|
Size((size.width + grow * 2).coerceAtLeast(0f), (size.height + grow * 2).coerceAtLeast(0f)),
|
||||||
|
layoutDirection,
|
||||||
|
this,
|
||||||
|
)
|
||||||
|
translate(-grow, elevation * lift - grow) {
|
||||||
|
drawOutline(outline, Color.Black.copy(alpha = alpha / steps))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a control should look pressed. Held for at least [MIN_PRESS_MS] after the finger lifts:
|
||||||
|
* a quick tap would otherwise come and go between two frames of the car's video stream.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun rememberPressed(source: MutableInteractionSource): Boolean {
|
||||||
|
var shown by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(source) {
|
||||||
|
var since = 0L
|
||||||
|
var release: Job? = null
|
||||||
|
source.interactions.collect { interaction ->
|
||||||
|
when (interaction) {
|
||||||
|
is PressInteraction.Press -> {
|
||||||
|
release?.cancel()
|
||||||
|
since = SystemClock.uptimeMillis()
|
||||||
|
shown = true
|
||||||
|
}
|
||||||
|
is PressInteraction.Release, is PressInteraction.Cancel -> {
|
||||||
|
release?.cancel()
|
||||||
|
release = launch {
|
||||||
|
delay((MIN_PRESS_MS - (SystemClock.uptimeMillis() - since)).coerceAtLeast(0))
|
||||||
|
shown = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return shown
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val MIN_PRESS_MS = 140L
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Modifier.gsPressable(
|
||||||
|
source: MutableInteractionSource,
|
||||||
|
pressed: Boolean,
|
||||||
|
enabled: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onLongClick: (() -> Unit)? = null,
|
||||||
|
role: Role = Role.Button,
|
||||||
|
shape: Shape = gsShape(),
|
||||||
|
): Modifier {
|
||||||
|
val look = gsLook()
|
||||||
|
// Reached with the car's knob or D-pad rather than a finger: a ring shows where focus is.
|
||||||
|
val focused by source.collectIsFocusedAsState()
|
||||||
|
val ring = MaterialTheme.colorScheme.primary
|
||||||
|
return this
|
||||||
|
.scale(if (pressed) look.pressedScale else 1f)
|
||||||
|
.then(if (focused) Modifier.border(3.dp, ring, shape) else Modifier)
|
||||||
|
.combinedClickable(
|
||||||
|
interactionSource = source,
|
||||||
|
indication = null,
|
||||||
|
enabled = enabled,
|
||||||
|
role = role,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
onClick = onClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun GsColors.disabled(enabled: Boolean, onSurface: Color): GsColors =
|
||||||
|
if (enabled) this
|
||||||
|
else GsColors(
|
||||||
|
container = if (container.alpha > 0f) onSurface.copy(alpha = 0.12f) else container,
|
||||||
|
content = onSurface.copy(alpha = 0.38f),
|
||||||
|
border = if (border.isSpecified) onSurface.copy(alpha = 0.12f) else border,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A themed text (or text-and-icon) button. */
|
||||||
|
@Composable
|
||||||
|
fun GsButton(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
tone: GsTone = GsTone.Primary,
|
||||||
|
colors: GsColors = gsColors(tone),
|
||||||
|
enabled: Boolean = true,
|
||||||
|
contentPadding: PaddingValues = PaddingValues(horizontal = 24.dp, vertical = 10.dp),
|
||||||
|
content: @Composable RowScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
val c = colors.disabled(enabled, MaterialTheme.colorScheme.onSurface)
|
||||||
|
val shape = gsShape()
|
||||||
|
val source = remember { MutableInteractionSource() }
|
||||||
|
val pressed = rememberPressed(source)
|
||||||
|
Row(
|
||||||
|
modifier
|
||||||
|
.defaultMinSize(minWidth = 64.dp, minHeight = 48.dp)
|
||||||
|
.gsPressable(source, pressed, enabled, onClick, shape = shape)
|
||||||
|
.gsSurface(c.container, shape, c.border, pressed)
|
||||||
|
.padding(contentPadding),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
CompositionLocalProvider(LocalContentColor provides c.content) {
|
||||||
|
ProvideTextStyle(MaterialTheme.typography.labelLarge) { content() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A themed square icon button. Pass a [modifier] size to override the default [size]. */
|
||||||
|
@Composable
|
||||||
|
fun GsIconButton(
|
||||||
|
icon: ImageVector,
|
||||||
|
contentDescription: String?,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
tone: GsTone = GsTone.Neutral,
|
||||||
|
colors: GsColors = gsColors(tone),
|
||||||
|
enabled: Boolean = true,
|
||||||
|
size: Dp = 48.dp,
|
||||||
|
iconSize: Dp = 24.dp,
|
||||||
|
shape: Shape = gsShape(),
|
||||||
|
) {
|
||||||
|
GsIconBox(onClick, modifier.size(size), colors, enabled, shape) {
|
||||||
|
Icon(icon, contentDescription, Modifier.size(iconSize))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [GsIconButton] with arbitrary content, for glyphs drawn in code or app icons. */
|
||||||
|
@Composable
|
||||||
|
fun GsIconBox(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
colors: GsColors = gsColors(GsTone.Neutral),
|
||||||
|
enabled: Boolean = true,
|
||||||
|
shape: Shape = gsShape(),
|
||||||
|
/** Held down, as a keycap theme draws it - for a toggle or the current screen's nav button. */
|
||||||
|
latched: Boolean = false,
|
||||||
|
onLongClick: (() -> Unit)? = null,
|
||||||
|
content: @Composable BoxScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
val c = colors.disabled(enabled, MaterialTheme.colorScheme.onSurface)
|
||||||
|
val source = remember { MutableInteractionSource() }
|
||||||
|
val pressed = rememberPressed(source)
|
||||||
|
Box(
|
||||||
|
modifier
|
||||||
|
.gsPressable(source, pressed, enabled, onClick, onLongClick, shape = shape)
|
||||||
|
.gsSurface(c.container, shape, c.border, pressed, latched),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
CompositionLocalProvider(LocalContentColor provides c.content) { content() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car.theme
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
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.background
|
||||||
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
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.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
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.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.graphics.lerp
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A capsule progress bar: a tinted track with a glossy fill set inside it, lighter along the
|
||||||
|
* top and darker along the bottom. Below its own height the fill narrows into a smaller oval, so
|
||||||
|
* a countdown runs smoothly all the way to empty. The flat theme draws Material's wavy bar instead.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun GsProgressBar(
|
||||||
|
fraction: Float,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
height: Dp = 22.dp,
|
||||||
|
color: Color = MaterialTheme.colorScheme.primary,
|
||||||
|
/** Brightens the fill, for a bar that's being dragged. */
|
||||||
|
active: Boolean = false,
|
||||||
|
) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
val shown = fraction.coerceIn(0f, 1f)
|
||||||
|
val inset = 3.dp
|
||||||
|
val fill = if (active) lerp(color, Color.White, 0.12f) else color
|
||||||
|
if (LocalGsTheme.current.id == GsThemes.Flat.id) {
|
||||||
|
WavyBar(shown, fill, lerp(scheme.surfaceContainerHighest, color, 0.12f), modifier.fillMaxWidth().height(height))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
BoxWithConstraints(
|
||||||
|
modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(height)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(lerp(scheme.surfaceContainerHighest, color, 0.12f))
|
||||||
|
.padding(inset),
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) {
|
||||||
|
if (shown <= 0f) return@BoxWithConstraints
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.width(maxWidth * shown)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
0f to lerp(fill, Color.White, 0.30f),
|
||||||
|
0.55f to fill,
|
||||||
|
1f to lerp(fill, Color.Black, 0.22f),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// The gloss: a soft white band across the top half.
|
||||||
|
.drawWithContent {
|
||||||
|
drawContent()
|
||||||
|
drawRect(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
0f to Color.White.copy(alpha = 0.35f),
|
||||||
|
0.5f to Color.White.copy(alpha = 0.05f),
|
||||||
|
0.5f to Color.Transparent,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material 3's wavy progress bar, drawn here because the stable library doesn't offer it yet: the
|
||||||
|
* filled part is a sine wave that drifts along, then a gap, then a straight track and a stop dot
|
||||||
|
* at the end.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun WavyBar(fraction: Float, color: Color, track: Color, modifier: Modifier) {
|
||||||
|
val phase by rememberInfiniteTransition(label = "wave").animateFloat(
|
||||||
|
0f, 1f, infiniteRepeatable(tween(1_000, easing = LinearEasing), RepeatMode.Restart), label = "phase",
|
||||||
|
)
|
||||||
|
Canvas(modifier) {
|
||||||
|
val stroke = 4.dp.toPx()
|
||||||
|
val amplitude = (size.height / 2 - stroke).coerceIn(0f, 3.dp.toPx())
|
||||||
|
val wavelength = 32.dp.toPx()
|
||||||
|
val gap = 6.dp.toPx()
|
||||||
|
val mid = size.height / 2
|
||||||
|
val start = stroke / 2
|
||||||
|
val end = size.width - stroke / 2
|
||||||
|
val split = start + (end - start) * fraction
|
||||||
|
|
||||||
|
if (split > start) {
|
||||||
|
val wave = Path()
|
||||||
|
var x = start
|
||||||
|
val shift = phase * wavelength
|
||||||
|
while (x <= split) {
|
||||||
|
val y = mid + amplitude * sin(2 * Math.PI * (x - shift) / wavelength).toFloat()
|
||||||
|
if (x == start) wave.moveTo(x, y) else wave.lineTo(x, y)
|
||||||
|
x += 1f
|
||||||
|
}
|
||||||
|
drawPath(wave, color, style = Stroke(stroke, cap = StrokeCap.Round))
|
||||||
|
}
|
||||||
|
val trackStart = if (split > start) split + gap + stroke else start
|
||||||
|
if (trackStart < end) {
|
||||||
|
drawLine(track, Offset(trackStart, mid), Offset(end, mid), stroke, StrokeCap.Round)
|
||||||
|
}
|
||||||
|
drawCircle(color, stroke / 2, Offset(end, mid))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [GsProgressBar] that seeks: tapping or dragging moves it. 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 GsSeekBar(
|
||||||
|
fraction: Float,
|
||||||
|
onSeek: (Float) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
) {
|
||||||
|
var dragging by remember { mutableStateOf<Float?>(null) }
|
||||||
|
// A taller touch area than the bar itself, so it's easy to hit on a car screen.
|
||||||
|
Box(
|
||||||
|
modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(36.dp)
|
||||||
|
.then(
|
||||||
|
if (!enabled) Modifier else Modifier
|
||||||
|
.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) }
|
||||||
|
},
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
GsProgressBar(dragging ?: fraction, active = dragging != null)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car.theme
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An on/off switch in the current theme's finish: the track is pressed in while on, the knob is
|
||||||
|
* raised like a button. The whole switch is one tap target, as big as a nav bar button.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun GsSwitch(checked: Boolean, onCheckedChange: (Boolean) -> Unit, modifier: Modifier = Modifier) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
val knob = 26.dp
|
||||||
|
val travel = 64.dp - knob - 8.dp
|
||||||
|
val x by animateDpAsState(if (checked) travel else 0.dp, tween(140), label = "switch")
|
||||||
|
GsIconBox(
|
||||||
|
onClick = { onCheckedChange(!checked) },
|
||||||
|
modifier = modifier.size(width = 64.dp, height = 36.dp),
|
||||||
|
colors = if (checked) GsColors(scheme.primary, scheme.onPrimary) else GsColors(scheme.surfaceContainerHighest, scheme.onSurfaceVariant),
|
||||||
|
shape = CircleShape,
|
||||||
|
latched = checked,
|
||||||
|
) {
|
||||||
|
Box(Modifier.matchParentSize().padding(4.dp), contentAlignment = Alignment.CenterStart) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.offset(x = x)
|
||||||
|
.size(knob)
|
||||||
|
.gsSurface(if (checked) scheme.onPrimary else scheme.outline, CircleShape),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car.theme
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the car UI's controls look, separate from its colours: [MaterialTheme]'s colour scheme still
|
||||||
|
* decides what colour a button is, and this decides how that colour is drawn. A theme is plain
|
||||||
|
* data, so a user-supplied theme later is just another instance - nothing about the built-in ones
|
||||||
|
* is special beyond being listed in [GsThemes.builtIn].
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
data class GsTheme(
|
||||||
|
/** Stable key persisted in settings; never reuse one for a different look. */
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val control: ControlLook,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The finish on anything tappable that has a container: buttons, toggles, keypad keys. */
|
||||||
|
@Immutable
|
||||||
|
data class ControlLook(
|
||||||
|
val corner: Dp = 12.dp,
|
||||||
|
/** How far the top edge is lifted towards white, 0..1, fading evenly to the base colour at the centre. */
|
||||||
|
val topLight: Float = 0f,
|
||||||
|
/** How far the bottom edge is pushed towards black, 0..1. */
|
||||||
|
val bottomShade: Float = 0f,
|
||||||
|
/**
|
||||||
|
* [bottomShade] for containers that are already light, where lifting the top towards white
|
||||||
|
* barely shows: those get their depth from a darker bottom edge instead.
|
||||||
|
*/
|
||||||
|
val bottomShadeOnLight: Float = 0f,
|
||||||
|
/** Alpha of the thin highlight along the top rim, fading out down the sides; 0 for none. */
|
||||||
|
val rimAlpha: Float = 0f,
|
||||||
|
/** A solid darker base under the button, like a keycap; 0 for none. */
|
||||||
|
val lip: Dp = 0.dp,
|
||||||
|
/** How far a soft drop shadow falls below the button; 0 for none. Its blur is twice this. */
|
||||||
|
val shadow: Dp = 0.dp,
|
||||||
|
val shadowAlpha: Float = 0f,
|
||||||
|
/**
|
||||||
|
* A 1px bevel on the edge only - light along the top, dark along the bottom - so depth reads
|
||||||
|
* at any size without shading the face itself. 0 for none.
|
||||||
|
*/
|
||||||
|
val bevelTop: Float = 0f,
|
||||||
|
val bevelBottom: Float = 0f,
|
||||||
|
/** How far the face drops while pressed; 0 for none. */
|
||||||
|
val sink: Dp = 0.dp,
|
||||||
|
/**
|
||||||
|
* Alpha of the shadow cast inside the top edge of a latched control (a selected option, the
|
||||||
|
* current screen's nav button), so it reads as pushed in. 0 for none.
|
||||||
|
*/
|
||||||
|
val insetAlpha: Float = 0f,
|
||||||
|
/**
|
||||||
|
* Alpha of the dark overlay while pressed. Heavier than a ripple on purpose: the car screen
|
||||||
|
* shows a video stream a frame or two behind the finger, so subtle feedback gets lost.
|
||||||
|
*/
|
||||||
|
val pressedDim: Float = 0.22f,
|
||||||
|
/** Scale while pressed, 1 for none. */
|
||||||
|
val pressedScale: Float = 0.95f,
|
||||||
|
/** Replaces this look when the UI is light, for effects that only work on one background. */
|
||||||
|
val light: ControlLook? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
object GsThemes {
|
||||||
|
val Keycap = GsTheme(
|
||||||
|
id = "keycap",
|
||||||
|
name = "Keycap",
|
||||||
|
control = ControlLook(lip = 4.dp, pressedScale = 1f),
|
||||||
|
)
|
||||||
|
|
||||||
|
val Tactile = GsTheme(
|
||||||
|
id = "tactile",
|
||||||
|
name = "Tactile",
|
||||||
|
control = ControlLook(
|
||||||
|
topLight = 0.05f,
|
||||||
|
shadow = 3.dp,
|
||||||
|
shadowAlpha = 0.32f,
|
||||||
|
bevelTop = 0.16f,
|
||||||
|
bevelBottom = 0.22f,
|
||||||
|
sink = 2.dp,
|
||||||
|
insetAlpha = 0.30f,
|
||||||
|
pressedDim = 0.12f,
|
||||||
|
pressedScale = 1f,
|
||||||
|
// A wide soft shadow bands once the car's video stream compresses it on a light
|
||||||
|
// background, so light mode gets a tight one and leans on the bevel instead.
|
||||||
|
light = ControlLook(
|
||||||
|
topLight = 0.05f,
|
||||||
|
shadow = 1.5.dp,
|
||||||
|
shadowAlpha = 0.38f,
|
||||||
|
bevelTop = 0.24f,
|
||||||
|
bevelBottom = 0.30f,
|
||||||
|
sink = 2.dp,
|
||||||
|
insetAlpha = 0.30f,
|
||||||
|
pressedDim = 0.12f,
|
||||||
|
pressedScale = 1f,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val Flat = GsTheme(
|
||||||
|
id = "flat",
|
||||||
|
name = "Flat",
|
||||||
|
control = ControlLook(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val default = Tactile
|
||||||
|
val builtIn = listOf(Tactile, Keycap, Flat)
|
||||||
|
|
||||||
|
fun byId(id: String?): GsTheme = builtIn.firstOrNull { it.id == id } ?: default
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalGsTheme = staticCompositionLocalOf { GsThemes.default }
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package app.seb3thehacker.gearslip.host
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google's own Android Auto, in whatever guise it appears. Gearslip replaces it, so its entries
|
||||||
|
* (there are many: one per screen it registers) are noise in every list of apps to open.
|
||||||
|
*/
|
||||||
|
object AndroidAuto {
|
||||||
|
|
||||||
|
/** Kept in sync with the `<queries><package>` entries in the manifest: package visibility
|
||||||
|
* hides an app's presence entirely unless one names it (or a broader query matches it), and
|
||||||
|
* this one has no launcher icon on current builds for a launcher-category query to find. */
|
||||||
|
private val KNOWN_PACKAGES = listOf("com.google.android.projection.gearhead")
|
||||||
|
|
||||||
|
private val PACKAGE_MARKERS = listOf("gearhead", "projection.gearhead", "android.auto", "androidauto")
|
||||||
|
|
||||||
|
fun matches(packageName: String, label: String): Boolean {
|
||||||
|
val pkg = packageName.lowercase()
|
||||||
|
if (PACKAGE_MARKERS.any { it in pkg }) return true
|
||||||
|
val name = label.lowercase().replace(Regex("[^a-z ]"), " ")
|
||||||
|
return "android auto" in name || "androidauto" in name.replace(" ", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The package name of Google's Android Auto, if it is installed. */
|
||||||
|
fun installedPackage(context: Context): String? {
|
||||||
|
val manager = context.packageManager
|
||||||
|
return KNOWN_PACKAGES.firstOrNull { pkg ->
|
||||||
|
runCatching { manager.getPackageInfo(pkg, 0) }.isSuccess
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether [packageName] can still fight Gearslip for the USB connection: installed, and not
|
||||||
|
* already disabled for this user. `ApplicationInfo.enabled` only reflects the manifest's
|
||||||
|
* static default, not a driver's own Settings toggle, so this reads the actual runtime
|
||||||
|
* setting instead - a driver who disabled it instead of uninstalling it (the app info screen
|
||||||
|
* offers both) has already done what this step asks.
|
||||||
|
*/
|
||||||
|
fun isActive(context: Context, packageName: String): Boolean {
|
||||||
|
val setting = runCatching { context.packageManager.getApplicationEnabledSetting(packageName) }
|
||||||
|
.getOrDefault(PackageManager.COMPONENT_ENABLED_STATE_DEFAULT)
|
||||||
|
return setting == PackageManager.COMPONENT_ENABLED_STATE_DEFAULT ||
|
||||||
|
setting == PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 100 of 162 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user