2 Commits
Author SHA1 Message Date
Seb3thehacker af9e35070f Update license 2026-09-20 00:20:21 -07:00
Seb3thehacker 9e35b38b17 Create license 2026-09-20 00:19:55 -07:00
239 changed files with 6 additions and 37326 deletions

No files matched your search

-15
View File
@@ -1,15 +0,0 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: Seb3thehacker
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: seb3thehacker
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
-59
View File
@@ -1,59 +0,0 @@
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/twinksoftware/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
-65
View File
@@ -1,65 +0,0 @@
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.
-1
View File
@@ -1 +0,0 @@
blank_issues_enabled: false
@@ -1,23 +0,0 @@
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.
-15
View File
@@ -1,15 +0,0 @@
# Weekly pull requests for newer Gradle libraries and GitHub Actions, aimed at experimental,
# where unreleased code lands. Security fixes come separately, as soon as an alert opens.
version: 2
updates:
- package-ecosystem: gradle
directory: /
target-branch: experimental
schedule:
interval: weekly
open-pull-requests-limit: 5
- package-ecosystem: github-actions
directory: /
target-branch: experimental
schedule:
interval: weekly
-20
View File
@@ -1,20 +0,0 @@
Gearslip 0.3.12 gives the car screen a fresh look and fixes the wireless loop 0.3.02 caused on some head units.
## New
- Navigation cards sit down the left side, with a bigger turn card that shows your lane. The map keeps the right side.
- The car screen and the phone app take their colors from your phone's Material You palette.
- The car's settings sit on the phone's home screen, each group on its own screen.
- Put the home screen's player left or right of the map.
- The browser shows the page title, and it has an address bar, forward, reload, and a start page.
- If Gearslip lacks call permissions, the car's Phone screen asks for them on the phone.
## Changed
- Gearslip uses Car App Library 1.8. Map apps show the right turn arrows and route times, and Vela works fully.
- Setup ends with recommended apps, then the button picker, then usage notes.
## Fixed
- Some head units, such as ZLink adapters, sent their Wi-Fi details twice. Gearslip rejoined the network each time and dropped the connection. It now ignores the repeat.
- Gearslip no longer crashes when a wireless connection ends.
- Opening a second music app without playing anything keeps the player on the song that's still playing.
- Vela's speed sign is full size again.
- RF Sentinel and other non-media car apps get their own tile in the launcher.
-50
View File
@@ -1,50 +0,0 @@
name: CodeQL
# Kotlin can only be scanned while it compiles, so this builds the debug app, the same way
# release.yml does but without a signing key. Workflow files need no build.
on:
push:
branches: [main, experimental]
pull_request:
schedule:
- cron: "0 9 * * 1"
workflow_dispatch:
jobs:
analyze:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
actions: read
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: java-kotlin
build-mode: manual
steps:
- uses: actions/checkout@v4
- uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
- if: matrix.build-mode == 'manual'
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- if: matrix.build-mode == 'manual'
uses: gradle/actions/setup-gradle@v4
- if: matrix.build-mode == 'manual'
run: ./gradlew :gearslip:assembleDebug --no-daemon
- uses: github/codeql-action/analyze@v4
with:
category: "/language:${{ matrix.language }}"
-53
View File
@@ -1,53 +0,0 @@
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 }}
# The hand-written notes go first; GitHub adds the full changelog link under them.
body_path: .github/release-notes.md
generate_release_notes: true
# Every release starts as a pre-release: only phones on the Alpha update channel get it.
# Once it's tested, untick pre-release on GitHub and everyone gets it.
prerelease: true
-34
View File
@@ -1,34 +0,0 @@
name: Security scan
# Trivy looks through the repository for committed secrets and risky workflow or build
# settings. Findings show under the Security tab, next to CodeQL's and Dependabot's.
on:
push:
branches: [main, experimental]
pull_request:
schedule:
- cron: "0 9 * * 1"
workflow_dispatch:
jobs:
trivy:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@v4
- uses: aquasecurity/trivy-action@v0.36.0
with:
scan-type: fs
scanners: vuln,secret,misconfig
format: sarif
output: trivy.sarif
severity: MEDIUM,HIGH,CRITICAL
- uses: github/codeql-action/upload-sarif@v4
if: always()
with:
sarif_file: trivy.sarif
category: trivy
-40
View File
@@ -1,40 +0,0 @@
*.iml
.gradle/
.idea/
.DS_Store
.kotlin
/build
local.properties
# Keys
*.jks
*.keystore
keystore.properties
# Native build cache
.cxx/
# Testing
/cloned-repos
docs-internal/
# Internal
*.pdf
todo.md
logs/
# For AI code
.claude/
CLAUDE.md
# Other
archive/
.wrangler/
stats-worker/
# Certificates and keys never go in the repo; the app downloads or imports them on the phone.
*.p12
*.pfx
*.pem
*.crt
*.key
-49
View File
@@ -1,49 +0,0 @@
# Cars
Drivers who share usage notes tell us which cars Gearslip works in. This list comes from
those notes, and from our own testing. A car shows up here once Gearslip has reached its
screen at least once.
The year is the one the car reports. Some cars report the year of the head unit instead,
and some don't say.
## Works
| Car | Year | Head unit |
|---|---|---|
| Audi | | MIB2+ (Alpine) |
| Audi | | Harman |
| Chevrolet, with myChevrolet | 2019, 2020, 2021 | myChevrolet |
| Chevrolet, with MyLink | 2017 | MyLink |
| Dacia | | Media Nav (LG ULC 4.1) |
| Dacia | | LG ULC 4.5 |
| Ford | 2018 | SYNC 3.0 (Panasonic) |
| Ford Fiesta | 2019 | SYNC 3.2 (Panasonic) |
| Ford Fusion | 2018 | SYNC 3.1 (Panasonic) |
| Jeep Cherokee | 2019 | Uconnect 8.4 (Delphi) |
| MG | 2019 | Desay SV |
| Ram 1500 | 2018 | Uconnect 8.4 (Panasonic) |
| Škoda | 2018 | MIB Standard2 (TechniSat) |
| Subaru Outback | 2020 | Denso |
| Toyota RAV4 | 2020 | Pioneer T1 |
| Volkswagen | 2018 | MIB Standard2 (Delphi) |
| Volkswagen | 2024 | ICAS3 |
| Volvo XC90 | 2022 | SPA Plus (Mitsubishi Electric) |
| Wuling | 2021 | Desay SV |
| [LIVI](https://github.com/f-io/LIVI), the open-source head unit | | |
## Not working yet
| Car | Year | What happens |
|---|---|---|
| Honda | 2016 | The connection drops before Gearslip reaches the screen. |
| Honda | 2019 | The connection drops before Gearslip reaches the screen. |
| Mitsubishi Attrage | 2020 | The connection drops before Gearslip reaches the screen. |
Some cars reject Gearslip's certificate before they say what they are, so they can't appear
here by name. Most of these are from around 2020 or later.
## Add your car
Turn on usage notes in Gearslip's setup or Settings, and drive once. If your car is missing
or listed wrongly, open an issue.
-102
View File
@@ -1,102 +0,0 @@
# Gearslip Contributor License Agreement
Version 1.0, October 2026
Thank you for contributing to Gearslip. This agreement lets Twink Software LLC, the company
behind Gearslip, use your contribution. You keep the copyright in your work. You sign once,
and the agreement covers every contribution you make to Gearslip, past and future.
## Why we ask
Gearslip is free software under the GNU Affero General Public License, version 3 (the
"AGPL"). Twink Software LLC also sells commercial licenses to companies that cannot meet the
AGPL's terms, and that income helps pay for the project. Offering your code on those terms
needs your permission, and this agreement gives it.
## 1. Definitions
- **"We"** and **"us"** mean Twink Software LLC, a California limited liability company
(Secretary of State entity number B20260081700), under that name or any other, and its
successors and assigns.
- **"You"** means the person who signs this agreement.
- **"Contribution"** means any work of authorship, such as code, documentation, or images,
that you submit to Gearslip, whether before or after you sign. A submission includes a
pull request, patch, or issue attachment sent to a repository or channel we manage. It
excludes anything you mark in writing as "Not a Contribution".
## 2. Copyright license
You grant us, and anyone who receives software from us, a perpetual, worldwide,
non-exclusive, no-charge, royalty-free, irrevocable license to reproduce, prepare derivative
works of, publicly display, publicly perform, sublicense, and distribute your Contributions
and derivative works of them.
This license lets us offer your Contributions under licenses other than the AGPL, including
commercial licenses.
## 3. Patent license
You grant us, and anyone who receives software from us, a perpetual, worldwide,
non-exclusive, no-charge, royalty-free, irrevocable patent license to make, have made, use,
offer to sell, sell, import, and otherwise transfer your Contributions. This license covers
only the patent claims you can license that your Contribution, alone or combined with
Gearslip, would infringe.
If anyone sues claiming that Gearslip or a Contribution infringes a patent, the patent
licenses granted to that party for Gearslip end on the date the suit is filed.
## 4. Our promise to you
As long as we distribute a Contribution, we will also offer it under the AGPL or another
license approved by the Open Source Initiative. Your work will always stay available as
free software.
## 5. Your promises
You promise that:
1. **The work is yours.** Each Contribution is your original creation, and you have the
right to grant these licenses.
2. **Your employer agrees.** If your employer has rights in what you create, you have its
permission to contribute on its behalf, or it has waived its rights in your
Contributions.
3. **You name other people's work.** If any part of a Contribution is not your own, you say
so when you submit it, and you name its source and its license.
4. **You'll tell us of changes.** If any of these promises stops being true, you will tell
us.
## 6. No warranty
You provide your Contributions "as is", without warranties or conditions of any kind. You do
not have to provide support for them.
## 7. No obligation
We are not required to use or include any Contribution.
## 8. General
- California law governs this agreement.
- This agreement does not change the rights you have under the AGPL in Gearslip as
released.
- We may publish a new version of this agreement. A new version applies only to
Contributions you make after you sign it; this version keeps covering the ones you made
under it.
## Signing
Most contributors sign on GitHub: the CLA Assistant bot comments on your first pull request,
and you sign there with one click. Your GitHub account then serves as your signature.
To sign by hand instead, fill in the following and send it to us on Matrix. The address is
on the [Seb3thehacker GitHub profile](https://github.com/Seb3thehacker).
Name: ______________________________
GitHub username: ______________________________
Email: ______________________________
Signature: ______________________________
Date: ______________________________
-55
View File
@@ -1,55 +0,0 @@
# 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/twinksoftware/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. Twink Software LLC, which
owns Gearslip, also sells a commercial license, described in [NOTICE.md](NOTICE.md).
Before we can merge your first pull request, you sign the
[Contributor License Agreement](CLA.md). You keep the copyright in your work, and it stays
available as free software; the agreement lets us offer it under the commercial license too.
A bot comments on your pull request with a link, and signing takes one click.
-663
View File
@@ -1,663 +0,0 @@
Copyright (C) 2026 Twink Software LLC and contributors
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/>.
-9
View File
@@ -1,9 +0,0 @@
# 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, Twink Software LLC, which owns Gearslip, 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 us on Matrix; the address is on the [Seb3thehacker GitHub profile](https://github.com/Seb3thehacker).
-195
View File
@@ -1,195 +0,0 @@
<img src="docs/icon/gearslip-icon.png" alt="Gearslip icon" width="96" height="96">
# Gearslip
An Android Auto alternative built for everyone.
[![forthebadge](https://forthebadge.com/badges/compatibility-club-penguin.svg)](https://forthebadge.com)
[<img src="https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png" alt="Get it on Obtainium" height="80">](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://github.com/twinksoftware/gearslip)
Questions, ideas?
[Join the discussion on Matrix](https://matrix.to/#/!mf0_mgBd5KqKpc9FfX_wHp1jFCcwAtPrndIn_oGIyMk?via=matrix.org).
## Which cars work
Most cars work. Starting around 2020, some cars added newer security standards that reject
Gearslip's certificate, and Gearslip can't run in those cars. [CARS.md](CARS.md) lists the
cars drivers have confirmed.
## 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 or wirelessly, with no Google software in the path.
Gearslip acts as a Car App Library host: an app that ships a car interface hands Gearslip
a template, and Gearslip renders that template natively on the car screen. Nothing is
mirrored from the phone. For apps without a car interface, Gearslip offers a separate
screen-sharing mode.
| | |
|---|---|
| ![The launcher, with every car app on the phone and the route in the bar below](docs/screenshots/launcher.png) | ![Vela Maps showing the route, with the music player beside the map](docs/screenshots/navigation.png) |
| Your car apps in one place. A red X marks the ones that don't work yet. | Vela Maps navigating, with the player beside it. |
| ![Metrolist's song, art, and controls in Gearslip's player](docs/screenshots/music.png) | ![A new message card over Vela Maps](docs/screenshots/message.png) |
| Metrolist in Gearslip's player. | A new message, read aloud at a tap, over the map. |
| ![The dashboard, with the weather, the week ahead, and recent notifications](docs/screenshots/dashboard.png) | ![DuckDuckGo open in Gearslip's web browser](docs/screenshots/web.png) |
| The weather, your week, and what you missed. | Browse the web while parked. |
| ![The voice assistant listening over Vela Maps](docs/screenshots/assistant.png) | ![Three routes to a coffee shop in Vela Maps, each with its time and distance](docs/screenshots/places.png) |
| Ask for a place, a song, or an app. | Pick a route to coffee nearby. Each one shows its time and distance. |
## Features
- **Phone calls.** Dial from a keypad, or search your contacts, then confirm before the
call goes out. When a call comes in, answer or decline it on the car screen. During a
call, the nav bar shows who you're talking to, and you mute or hang up from there.
- **Navigation.** Gearslip hosts real Car App Library map apps on the car screen.
Organic Maps, Vela, OsmAnd, Flitsmeister, MAPS.ME, and MapQuest work today. While you
navigate, a big turn card on the left shows the arrow, the distance, and your lane, and
the map keeps the right side.
- **Music.** Browse, play, and queue tracks from a car-enabled media app, with a Now
Playing view, an Up Next queue, and lyrics. Type a search, and apps that accept one
play the best match.
- **The web.** A browser with an address bar, back and forward, and 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.
- **Voice.** Tap the mic, or press the talk button on the wheel, and say "navigate to the
nearest gas station", "play Daft Punk", or "call Mom". Say "help" for the full list.
Speech turns into text on the phone, and nothing you say leaves it.
- **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. Music pauses while
Gearslip speaks or listens. 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.
- **Your apps, your order.** The launcher groups apps by kind: phone, maps, music,
messaging, and the rest. Press and hold an app to move it.
- **Car controls.** Steering wheel buttons, control knobs, and D-pads all work.
- **Your phone's colors.** The car screen and the phone app take their colors from your
phone's Material You palette, and every card and button matches.
- **Themes.** Choose flat buttons or skeuomorphic ones that look like real keys.
- **Settings on the phone.** The car's settings sit on the phone's home screen too, so you
can set things up before you drive. A change on either screen shows on both.
## Status
- **Wired or wireless.** Plug the phone into the car's USB port, or, in a car with wireless
Android Auto, tap Connect wirelessly and pick the car. Pair the phone with the car over
Bluetooth first. After the first wireless drive, Android asks once whether Gearslip may
watch for that car; from then on, Gearslip connects whenever the car's Bluetooth does.
- **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 the Car App Library's templates on the car
screen, 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. TIDAL lets only approved apps browse its library.
- **Calls use the car's Bluetooth,** as they do in Android Auto. Pair the phone with the
car to hear calls through its speakers.
- **Music reaches the car over Bluetooth or USB.** Setup asks which. Bluetooth is the
default: Android never asks for permission, and your map app's voice reaches the car
too. USB sounds a little clearer, with no delay, but each drive Android asks you to
share your screen, which lets Gearslip send the sound. Gearslip never reads the screen; capturing playback is
the only way an ordinary app can pass another app's audio along.
- **Gearslip needs no adb, root, or Shizuku.** Every feature relies on an ordinary
Android permission or an on-device consent dialog. For USB audio, setup offers two
optional adb commands: one removes the audio dialog for good, and the other keeps
notifications visible while audio plays.
- **Updates come from GitHub.** Once a day, Gearslip checks this repository's latest
release. When a new version is out, it downloads the APK, checks it against GitHub's
SHA-256, and installs it through Android's own installer. Turn off automatic checks in
Settings.
## Verify your download
Check that your copy of Gearslip is genuine with
[AppVerifier](https://github.com/soupslurpr/AppVerifier): share this block to it, or compare it
with the output of `apksigner verify --print-certs`.
```
app.seb3thehacker.gearslip
4C:C5:6E:BD:D1:3A:54:D2:60:EC:A9:B7:E2:B2:AE:6B:30:E2:F2:25:BF:DA:8F:50:8F:07:09:ED:F0:84:94:8B
```
## Usage notes
Gearslip can send short notes that show which cars it works in. They stay off unless you
turn them on, in setup or in Settings.
- **When they go out.** When you open Gearslip, at most once a day, and after each drive.
Nothing runs in the background.
- **What they carry.** The Gearslip version and which boxes you ticked, plus whatever
those boxes cover: the Android version and whether the phone runs GrapheneOS, the phone
model, whether a car connected that day, and each car's head unit, model, year, screen
size, and connection result, with whether that drive used the cable or wireless and
whether music went over USB or Bluetooth. If you tick it, how each drive went: how far
the connection got, how long it lasted, and how it ended. Settings shows the exact note. Untick a phone detail, and
the server drops the copy it kept.
- **What they leave out.** Your name, accounts, location, contacts, messages, and
anything you type or play.
- **How phones are counted.** A random number counts each phone once. The server keeps
only a salted hash of it.
- **Where they go.** To Gearslip's server. Like any website, it sees your IP address when a
note arrives, and it doesn't store it.
Turn the notes off, and they stop at once.
## How Gearslip is made
Gearslip is my beloved child. I decide what it does and how it looks, and Claude writes nearly
all of the code. I test every change myself before it ships: in a real car, with both a
GrapheneOS phone and a stock Android phone, and on [LIVI](https://github.com/f-io/LIVI), the
open-source head unit software. I watch the phone's temperature too, so a new feature doesn't
cook it. Then I beg my friends to try it in their cars, so it works in as many makes, models,
and years as possible. If something breaks, that's on me, so please open an issue.
## Contributing
Bug reports from real cars help most, and pull requests are welcome.
[CONTRIBUTING.md](CONTRIBUTING.md) explains what to put in a report, which branch to work
on, and the one-time agreement you sign before your first pull request merges.
## 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.
## How Gearslip talks to cars
Gearslip is an independent program, written to work with car head units. What it knows of
the protocol comes from public open-source projects such as
[aasdk](https://github.com/opencardev/aasdk) and open-android-auto, and it contains none
of Google's Android Auto code.
Gearslip ships without a certificate. On first launch you choose where yours comes from:
download the one the aasdk project publishes, or import a file of your own. The
certificate stays on your phone. Gearslip never uploads, backs up, or logs it.
## 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 belongs to Twink Software LLC and is licensed under AGPL-3.0; see [LICENSE](LICENSE).
The LLC also sells a separate commercial license for private use outside the AGPL's terms; see
[NOTICE.md](NOTICE.md).
-7
View File
@@ -1,7 +0,0 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
}
-477
View File
@@ -1,477 +0,0 @@
# 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.

Before

Width:  |  Height:  |  Size: 10 KiB

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

Before

Width:  |  Height:  |  Size: 1.0 KiB

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

Before

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 373 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

-10
View File
@@ -1,10 +0,0 @@
# Build output is redirected to <root>/build/gearslip/ (see build.gradle.kts), already
# covered by the root .gitignore's /build - this is just a safety net in case that ever
# changes back.
build/
# User-supplied certificate material - never checked in (see CertProvider.kt).
phone.p12
phone.pass
*.crt
*.key
-150
View File
@@ -1,150 +0,0 @@
import java.util.Properties
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
/**
* Gearslip: a phone-side Android Auto client built against the reverse-engineered AA wire
* protocol, so a non-Google app can authenticate to and project onto a real car head unit
* with no gearhead in the path. No third-party dependencies: the X.509 certificate is
* generated by hand (see `SelfSignedCert`) so there is no BouncyCastle version to track and
* no second crypto provider to collide with Conscrypt.
*/
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
}
// The release signing key, read from an untracked keystore.properties beside this file or at the
// repository root. Third-party apps allowlist Gearslip by the SHA-256 of this certificate, so the
// key must stay the same for the life of the project - and must never be committed.
val keystoreProperties = Properties().apply {
listOf(
rootProject.file("keystore.properties"),
project.file("keystore.properties"),
).firstOrNull { it.exists() }?.inputStream()?.use { load(it) }
}
// 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. Set before android {}, which works out the path to R8's default
// rules from it.
layout.buildDirectory.set(rootProject.layout.buildDirectory.dir("gearslip"))
android {
namespace = "app.seb3thehacker.gearslip"
compileSdk = 37
defaultConfig {
applicationId = "app.seb3thehacker.gearslip"
minSdk = 31
targetSdk = 37
versionCode = 12
// -PgearslipVersionName=0.1.99-test makes a build that thinks it's older, to try the updater.
versionName = project.findProperty("gearslipVersionName")?.toString() ?: "0.3.12-alpha"
manifestPlaceholders["appLabel"] = "Gearslip"
// Where opted-in phones send their daily usage note: the stats-worker/ Worker's address,
// set as gearslipStatsUrl in gradle.properties. Left empty, nothing is ever sent.
buildConfigField("String", "STATS_URL", "\"${project.findProperty("gearslipStatsUrl") ?: ""}\"")
}
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")
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
// Its own package, so a debug 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 Debug"
// Shrunk with R8 like a release, so a debug install behaves like what ships (a keep
// rule missing for release shows up here first). -PnoR8 skips it for a quicker build.
if (!project.hasProperty("noR8")) {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
}
buildFeatures {
buildConfig = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
defaultConfig {
// Speech-to-text (sherpa-onnx, in jniLibs) 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" }
}
testOptions {
// GearslipLog calls android.util.Log, which is a stub on the local JVM.
unitTests.isReturnDefaultValues = true
}
}
// Copies the produced APK 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. A copy, not a move: AGP packages incrementally into the APK it left
// last time, and with that file gone a build came out holding nothing but classes.dex.
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)
}
}
}
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)
}
}
-13
View File
@@ -1,13 +0,0 @@
# R8 shrinks the release build; these keep what it can't see being used.
# Names stay readable, so stack traces in shared logs need no mapping file. Shrinking does
# the real work on size.
-dontobfuscate
# Gearslip hosts other apps' car screens: their templates arrive as Bundles, and the car
# library rebuilds them by class name and reflection. Any model class an app might send has to
# survive, used by Gearslip's code or not.
-keep class androidx.car.app.** { *; }
# Speech-to-text: the native library calls back into these classes by name.
-keep class com.k2fsa.sherpa.onnx.** { *; }
-7
View File
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<!-- Debug builds only: renders the car UI to PNGs so it can be checked without a car. -->
<activity android:name="app.seb3thehacker.gearslip.CarPreviewActivity" android:exported="true" />
</application>
</manifest>
@@ -1,62 +0,0 @@
package app.seb3thehacker.gearslip
import app.seb3thehacker.gearslip.audio.CarAudio
import kotlin.math.abs
/**
* Debug-only stand-in for the head unit's media audio sink.
*
* The car end of the audio path can only be exercised in a car, but everything before it - the
* setting, the consent dialog, the capture service, and whether the media app allows itself to be
* heard at all - is phone-side and can be tested at a desk. This sink takes the place of
* [AudioLink] so that path runs to completion, and reports what arrived.
*
* adb shell am start -n app.seb3thehacker.gearslip/.CarPreviewActivity \
* --ei w 800 --ei h 480 --ez audio true --ei snaps 10
*
* A healthy run logs a rising byte count and a peak well above zero. A byte count that rises with
* a peak of zero means the app refused to be captured.
*/
class BenchAudioSink(override val sampleRate: Int = 48_000, override val channels: Int = 2) : CarAudio.Link {
private var chunks = 0
private var bytes = 0L
private var peak = 0
private var reportedAt = 0L
override fun start() {
chunks = 0
bytes = 0
peak = 0
reportedAt = System.currentTimeMillis()
GearslipLog.i("bench sink: started - pretending to be a ${sampleRate}Hz x$channels car sink")
}
override fun stop() {
GearslipLog.i("bench sink: stopped after $chunks chunks / $bytes bytes")
}
override fun write(pcm: ByteArray, size: Int) {
chunks++
bytes += size
for (i in 0 until size - 1 step 2) {
val sample = (pcm[i].toInt() and 0xFF) or (pcm[i + 1].toInt() shl 8)
peak = maxOf(peak, abs(sample.toShort().toInt()))
}
val now = System.currentTimeMillis()
if (now - reportedAt < REPORT_MS) return
reportedAt = now
val seconds = bytes / (sampleRate.toDouble() * channels * 2)
GearslipLog.i(
"bench sink: %d chunks / %d bytes (%.1fs of audio), peak %.3f%s".format(
chunks, bytes, seconds, peak / 32768f,
if (peak == 0) " - SILENT, the app is not letting itself be captured" else "",
),
)
peak = 0
}
companion object {
private const val REPORT_MS = 2_000L
}
}
@@ -1,171 +0,0 @@
package app.seb3thehacker.gearslip
import android.graphics.Bitmap
import android.graphics.PixelFormat
import android.hardware.display.DisplayManager
import android.media.ImageReader
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.MotionEvent
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import app.seb3thehacker.gearslip.audio.AudioCaptureService
import app.seb3thehacker.gearslip.audio.CarAudio
import app.seb3thehacker.gearslip.car.CarEnvironment
import app.seb3thehacker.gearslip.car.CarSettings
import app.seb3thehacker.gearslip.car.CarUi
import java.io.File
import java.io.FileOutputStream
/**
* Debug-only harness: runs the real car UI on a virtual display (the same path a head unit
* gets), taps where told, and saves what it rendered as PNGs in the external files dir.
*
* adb shell am start -n app.seb3thehacker.gearslip/.CarPreviewActivity \
* --ei w 800 --ei h 480 --ei dpi 160 --es taps "400,440;90,440"
*
* Writes preview-0.png (before any tap), then one more per tap.
*/
class CarPreviewActivity : ComponentActivity() {
private val main = Handler(Looper.getMainLooper())
private var projector: ScreenProjector? = null
private var reader: ImageReader? = null
@Volatile private var latest: Bitmap? = null
private val audioPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) askAudioCapture() else CarAudio.denied()
}
private val audioConsent = registerForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult(),
) { result ->
val data = result.data
if (result.resultCode == RESULT_OK && data != null) {
AudioCaptureService.start(this, result.resultCode, data)
} else {
GearslipLog.w("preview: audio capture consent declined")
CarAudio.denied()
}
}
private fun askAudioCapture() {
val manager = getSystemService(android.media.projection.MediaProjectionManager::class.java)
runCatching { audioConsent.launch(manager.createScreenCaptureIntent()) }
.onFailure { GearslipLog.e("preview: could not ask for audio capture", it); CarAudio.denied() }
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
GearslipLog.init(this)
CarSettings.init(this)
CarEnvironment.start(this)
val w = intent.getIntExtra("w", 800)
val h = intent.getIntExtra("h", 480)
val dpi = intent.getIntExtra("dpi", 160)
val taps = intent.getStringExtra("taps").orEmpty().split(";").filter { it.isNotBlank() }
.map { it.split(",").let { p -> p[0].toFloat() to p[1].toFloat() } }
val imageReader = ImageReader.newInstance(w, h, PixelFormat.RGBA_8888, 3)
reader = imageReader
imageReader.setOnImageAvailableListener({ r ->
r.acquireLatestImage()?.use { image ->
val plane = image.planes[0]
val padded = plane.rowStride / plane.pixelStride
val bmp = Bitmap.createBitmap(padded, h, Bitmap.Config.ARGB_8888)
bmp.copyPixelsFromBuffer(plane.buffer)
latest = Bitmap.createBitmap(bmp, 0, 0, w, h)
}
}, main)
val p = ScreenProjector(this).also { projector = it }
p.start(imageReader.surface, w, h, dpi) { CarUi() }
// Stand in for the car's media sink, so the phone half of the audio path runs for real.
if (intent.getBooleanExtra("audio", false)) {
CarAudio.onConsentRequested = {
runOnUiThread {
if (checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
) {
askAudioCapture()
} else {
audioPermission.launch(android.Manifest.permission.RECORD_AUDIO)
}
}
}
CarAudio.attach(BenchAudioSink())
}
GearslipLog.i("preview: displayId=${p.displayId}")
if (intent.getBooleanExtra("hide", false)) p.setOverlayVisible(false)
intent.getStringExtra("launch")?.let { pkg ->
main.postDelayed({
packageManager.getLaunchIntentForPackage(pkg)?.let { p.launchApp(it) }
?: GearslipLog.e("preview: no launch intent for $pkg")
}, 1500)
}
var step = 0
fun snap() {
val bmp = latest ?: return GearslipLog.e("preview: no frame yet for step $step")
val out = File(getExternalFilesDir(null), "preview-$step.png")
FileOutputStream(out).use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) }
GearslipLog.i("preview: wrote ${out.name}")
step++
}
// A two-finger pinch: "cx,cy,startGap,endGap" in projected pixels, after "pinchAfter" ms.
intent.getStringExtra("pinch")?.let { spec ->
val (cx, cy, gap0, gap1) = spec.split(",").map { it.toFloat() }
val pair = { gap: Float -> listOf(TouchPoint(3, cx - gap / 2, cy), TouchPoint(7, cx + gap / 2, cy)) }
val after = intent.getIntExtra("pinchAfter", 9000).toLong()
main.postDelayed({
snap()
p.dispatchTouch(MotionEvent.ACTION_DOWN, 0, listOf(pair(gap0)[0]))
p.dispatchTouch(MotionEvent.ACTION_POINTER_DOWN, 1, pair(gap0))
val steps = 14
for (i in 1..steps) main.postDelayed({
p.dispatchTouch(MotionEvent.ACTION_MOVE, 0, pair(gap0 + (gap1 - gap0) * i / steps))
}, 40L * i)
main.postDelayed({
p.dispatchTouch(MotionEvent.ACTION_POINTER_UP, 1, pair(gap1))
p.dispatchTouch(MotionEvent.ACTION_UP, 0, listOf(pair(gap1)[0]))
}, 40L * (steps + 1))
main.postDelayed({ snap() }, 40L * (steps + 1) + 2000)
}, after)
}
// Free-running mode: keep snapshotting on a timer so the display can be driven from
// outside (adb `am start --display` / `input -d`) while the harness records it.
val snaps = intent.getIntExtra("snaps", 0)
if (snaps > 0) {
val every = intent.getIntExtra("every", 3000).toLong()
repeat(snaps) { i -> main.postDelayed({ snap() }, 2000L + every * i) }
return
}
main.postDelayed({
snap()
taps.forEachIndexed { i, (x, y) ->
main.postDelayed({
val point = listOf(TouchPoint(0, x, y))
p.dispatchTouch(MotionEvent.ACTION_DOWN, 0, point)
main.postDelayed({ p.dispatchTouch(MotionEvent.ACTION_UP, 0, point) }, 80)
}, 2500L * (i + 1))
main.postDelayed({ snap() }, 2500L * (i + 1) + 1500)
}
}, 2000)
}
override fun onDestroy() {
super.onDestroy()
projector?.stop()
reader?.close()
CarAudio.onConsentRequested = null
CarAudio.release()
CarEnvironment.stop()
}
}
@@ -1,15 +0,0 @@
<?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>
@@ -1,33 +0,0 @@
<?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>
-267
View File
@@ -1,267 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- The projected WebView loads real pages; without this every load fails silently. -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!--
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" />
<!--
Wireless Android Auto: Bluetooth reaches the paired car's Android Auto service, which
hands over its Wi-Fi details; Gearslip then joins that network to reach the car.
-->
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Wireless auto-connect: Android tells Gearslip when a car it manages connects. -->
<uses-permission android:name="android.permission.REQUEST_OBSERVE_COMPANION_DEVICE_PRESENCE" />
<uses-feature android:name="android.software.companion_device_setup" android:required="false" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation" />
<!-- Android 17 drops an app's traffic to the car's Wi-Fi without this. -->
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<!-- 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" />
<!-- The opt-in usage note names the OS: GrapheneOS's setup app and /e/OS's app store give
them away (UsageStats.osName). -->
<package android:name="app.grapheneos.setupwizard" />
<package android:name="foundation.e.apps" />
<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:windowSoftInputMode="adjustResize"
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
android:name=".PermissionActivity"
android:exported="false"
android:excludeFromRecents="true"
android:theme="@style/Theme.Gearslip.Invisible" />
<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>
<!-- Told when a car approved for wireless auto-connect comes up over Bluetooth. -->
<service
android:name=".wireless.CarPresenceService"
android:exported="true"
android:permission="android.permission.BIND_COMPANION_DEVICE_SERVICE">
<intent-filter>
<action android:name="android.companion.CompanionDeviceService" />
</intent-filter>
</service>
</application>
</manifest>
@@ -1,88 +0,0 @@
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_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 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) }
}
}
@@ -1,28 +0,0 @@
package app.seb3thehacker.gearslip
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Who has the car's screen: Gearslip, or the car's own interface. Android Auto's "Exit" hands the
* screen back to the car with the session still running, so music keeps playing; the car's own
* Android Auto button brings Gearslip back. [GearslipRunner] fills in [request] while connected.
*/
object CarFocus {
private val projectedFlow = MutableStateFlow(true)
/** False while the car shows its own interface. */
val projected: StateFlow<Boolean> = projectedFlow
@Volatile internal var request: (() -> Unit)? = null
/** Asks the car to show its own interface. Does nothing when no car is connected. */
fun exitToCar() {
request?.invoke()
}
internal fun set(projected: Boolean) {
projectedFlow.value = projected
}
}
@@ -1,52 +0,0 @@
package app.seb3thehacker.gearslip
import java.io.ByteArrayOutputStream
import java.net.HttpURLConnection
import java.net.URL
/**
* Fetches the publicly available head unit certificate and key from the opencardev/aasdk
* repository on GitHub. The app ships no certificates; it downloads them only when the
* user chooses to, the same way a browser would.
*/
object CertDownloader {
private val log = GearslipLog.tagged("DOWNL")
private const val BASE = "https://raw.githubusercontent.com/opencardev/aasdk/main/cert"
private const val CRT = "$BASE/headunit.crt"
private const val KEY = "$BASE/headunit.key"
private const val CONNECT_TIMEOUT_MS = 10_000
private const val READ_TIMEOUT_MS = 15_000
class Downloaded(val certificatePem: String, val privateKeyPem: String)
/** Fetches both files; throws with a readable message on any failure. */
fun fetch(): Downloaded {
log.i("fetching certificate from $BASE")
val crt = fetch(CRT, "certificate")
val key = fetch(KEY, "private key")
log.i("downloaded certificate (${crt.length} bytes) and key (${key.length} bytes)")
return Downloaded(crt, key)
}
private fun fetch(url: String, label: String): String {
val body = try {
val conn = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = CONNECT_TIMEOUT_MS
readTimeout = READ_TIMEOUT_MS
}
conn.inputStream.use { input ->
val out = ByteArrayOutputStream()
val buf = ByteArray(4096)
var n: Int
while (input.read(buf).also { n = it } != -1) out.write(buf, 0, n)
out.toString(Charsets.UTF_8)
}
} catch (e: Exception) {
throw RuntimeException("could not download the $label: ${e.message}", e)
}
if (body.isBlank()) throw RuntimeException("the $label file is empty")
return body
}
}
@@ -1,252 +0,0 @@
package app.seb3thehacker.gearslip
import android.content.Context
import android.net.Uri
import java.io.ByteArrayOutputStream
import java.io.File
import java.security.KeyStore
import java.security.cert.X509Certificate
/**
* Supplies the certificate the phone presents to the head unit.
*
* FOSS posture: this code ships with NO certificate. Whoever wants to test the trusted-cert
* path supplies their own. Sources, in priority order:
*
* 1. **Imported** in the app (Settings > Certificate): a PKCS#12 file plus its password,
* copied into the app's private storage.
* 2. **Downloaded** from the public opencardev/aasdk repository. Fetched only when the
* user asks; the app never ships or hosts the certificate.
* 3. **Staged over adb** into the app's external files directory, the way the spike has
* always done it. Still honoured, so existing setups keep working untouched:
*
* openssl pkcs12 -export -in headunit.crt -inkey headunit.key \
* -name phone -passout pass:aaspike -out phone.p12
* adb push phone.p12 /sdcard/Android/data/app.seb3thehacker.gearslip/files/phone.p12
*
* Default password is "aaspike" (kept as-is so an already-staged phone.p12 doesn't need
* regenerating); a `phone.pass` file next to it overrides it.
* 4. A freshly generated self-signed cert, which a real head unit rejects (see
* SPIKE_FINDINGS.md).
*/
object CertProvider {
private val log = GearslipLog.tagged("CERT")
private const val P12_NAME = "phone.p12"
private const val PASS_NAME = "phone.pass"
private const val DEFAULT_PASSWORD = "aaspike"
private const val IMPORT_DIR = "identity"
enum class Kind { IMPORTED, DOWNLOADED, ADB_STAGED, SELF_SIGNED }
class Identity(
val keyStore: KeyStore,
val certificate: X509Certificate,
val password: CharArray,
val source: String,
val kind: Kind = Kind.SELF_SIGNED,
)
/**
* The identity for the next connection. A newly imported or pushed cert takes effect on the
* next connect: see [loadFromFiles] for how the cache notices.
*/
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)
/**
* Unlocking a PKCS#12 file takes about a second on a Pixel 6, which a head unit waiting for
* its first reply may not wait out. So the unlocked identity is kept, keyed on the files it
* could come from: importing, downloading, removing or pushing a cert changes the key.
*/
@Volatile private var cached: Pair<String, Identity>? = null
private fun sourcesKey(context: Context): String {
val dir = context.getExternalFilesDir(null)
val files = listOf(
importedFile(context, P12_NAME),
downloadedFile(context, P12_NAME),
dir?.let { File(it, P12_NAME) },
dir?.let { File(it, PASS_NAME) },
)
return files.joinToString("|") { f -> f?.let { "${it.path}:${it.lastModified()}:${it.length()}" } ?: "-" }
}
/** Unlocks the certificate ahead of time, so the first connection doesn't wait on it. */
fun warm(context: Context) {
runCatching { loadFromFiles(context) }
}
private fun loadFromFiles(context: Context): Identity? {
val key = sourcesKey(context)
cached?.let { (k, identity) -> if (k == key) return identity }
val identity = unlockFromFiles(context) ?: return null
cached = key to identity
return identity
}
private fun unlockFromFiles(context: Context): Identity? {
val imported = importedFile(context, P12_NAME)
if (imported.isFile) {
runCatching {
return loadP12(imported, importedPassword(context), "imported certificate", Kind.IMPORTED)
}.onFailure { log.e("could not load the imported certificate - trying the next source", it) }
}
val downloaded = downloadedFile(context, P12_NAME)
if (downloaded.isFile) {
runCatching {
return loadP12(downloaded, DEFAULT_PASSWORD, "downloaded certificate", Kind.DOWNLOADED)
}.onFailure { log.e("could not load the downloaded certificate - trying the next source", it) }
}
val dir = context.getExternalFilesDir(null)
val staged = dir?.let { File(it, P12_NAME) }
if (dir != null && staged != null && staged.isFile) {
runCatching {
val passFile = File(dir, PASS_NAME)
val password = if (passFile.isFile) {
passFile.readText().lineSequence().firstOrNull().orEmpty().trim()
} else {
DEFAULT_PASSWORD
}
return loadP12(staged, password, "$P12_NAME staged over adb", Kind.ADB_STAGED)
}.onFailure { log.e("could not load $P12_NAME - falling back to self-signed", it) }
}
return null
}
/**
* Validates [uri] as a PKCS#12 identity with [password] and, only if it is good, makes it
* the active certificate. A wrong password or a file with no private key throws and
* leaves whatever was active before untouched.
*/
fun importFrom(context: Context, uri: Uri, password: String): Identity {
val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
?: throw IllegalArgumentException("could not open the selected file")
val staging = File(context.cacheDir, "import-$P12_NAME")
try {
staging.writeBytes(bytes)
val identity = loadP12(staging, password, "imported certificate", Kind.IMPORTED)
val target = importedFile(context, P12_NAME)
target.parentFile?.mkdirs()
staging.copyTo(target, overwrite = true)
importedFile(context, PASS_NAME).writeText(password)
log.i("imported a certificate: ${identity.certificate.subjectX500Principal}")
return identity
} finally {
staging.delete()
}
}
fun hasImported(context: Context): Boolean = importedFile(context, P12_NAME).isFile
/** Whether a downloaded certificate is stored (may or may not also be imported). */
fun hasDownloaded(context: Context): Boolean = downloadedFile(context, P12_NAME).isFile
/** Drops the imported identity; the next source in line (downloaded, adb-staged, then self-signed) takes over. */
fun removeImported(context: Context) {
importedFile(context, P12_NAME).delete()
importedFile(context, PASS_NAME).delete()
log.i("removed the imported certificate")
}
/** Drops the downloaded identity. */
fun removeDownloaded(context: Context) {
downloadedFile(context, P12_NAME).delete()
downloadedFile(context, PASS_NAME).delete()
log.i("removed the downloaded certificate")
}
/** Whether any certificate is available (imported, downloaded, or adb-staged). */
fun hasAnyCert(context: Context): Boolean =
hasImported(context) || hasDownloaded(context) || hasAdbStaged(context)
private fun hasAdbStaged(context: Context): Boolean =
context.getExternalFilesDir(null)?.let { File(it, P12_NAME).isFile } == true
/**
* Validates raw PKCS#12 bytes with [password] and, if good, writes them as the imported
* identity. Used when the app assembles a certificate in memory (e.g. from a download)
* rather than importing from a file picker.
*/
fun importFromBytes(context: Context, p12Bytes: ByteArray, password: String): Identity {
val staging = File(context.cacheDir, "import-$P12_NAME")
try {
staging.writeBytes(p12Bytes)
val identity = loadP12(staging, password, "imported certificate", Kind.IMPORTED)
val target = importedFile(context, P12_NAME)
target.parentFile?.mkdirs()
staging.copyTo(target, overwrite = true)
importedFile(context, PASS_NAME).writeText(password)
log.i("imported a certificate from bytes: ${identity.certificate.subjectX500Principal}")
return identity
} finally {
staging.delete()
}
}
/**
* Downloads the publicly available head unit certificate from the opencardev/aasdk
* repository, converts it from PEM to PKCS#12, and stores it ready for use.
* Runs synchronously (caller is responsible for threading). Throws on any failure.
*/
fun downloadAndImport(context: Context): Identity {
val download = CertDownloader.fetch()
val result = PemKeyParser.build(download.certificatePem, download.privateKeyPem)
val target = downloadedFile(context, P12_NAME)
target.parentFile?.mkdirs()
val p12Password = DEFAULT_PASSWORD
val keyStore = result.keyStore
val p12Bytes = ByteArrayOutputStream().use { stream ->
keyStore.store(stream, p12Password.toCharArray())
stream.toByteArray()
}
target.writeBytes(p12Bytes)
downloadedFile(context, PASS_NAME).writeText(p12Password)
log.i("downloaded and stored certificate: ${result.certificate.subjectX500Principal}")
return Identity(keyStore, result.certificate, p12Password.toCharArray(),
"downloaded certificate (opencardev/aasdk)", Kind.DOWNLOADED)
}
// The imported files live in the app's private internal storage (not the external files
// dir), so other apps cannot read the key, and `adb uninstall` clearing it is expected.
private fun importedFile(context: Context, name: String) = File(File(context.filesDir, IMPORT_DIR), name)
private fun downloadedFile(context: Context, name: String) =
File(File(context.filesDir, "$IMPORT_DIR-downloaded"), name)
private fun importedPassword(context: Context): String =
importedFile(context, PASS_NAME).takeIf { it.isFile }?.readText().orEmpty()
private fun loadP12(file: File, password: String, label: String, kind: Kind): Identity {
val chars = password.toCharArray()
val keyStore = KeyStore.getInstance("PKCS12")
file.inputStream().use { keyStore.load(it, chars) }
val alias = keyStore.aliases().asSequence().firstOrNull { keyStore.isKeyEntry(it) }
?: throw IllegalStateException("the file contains no private-key entry")
val certificate = keyStore.getCertificate(alias) as X509Certificate
return Identity(keyStore, certificate, chars, "$label (alias=$alias)", kind)
}
}
@@ -1,96 +0,0 @@
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(
9, "0.3.00-alpha",
listOf(
"Wireless Android Auto: pair the phone with the car once, and leave the cable at home.",
"Voice control is built in. Say \"navigate to a gas station\", \"play\" a song or artist, or open an app by name.",
"\"Navigate to\" picks the nearest place and stops at the map app's start screen, so you only tap Start.",
"Gearslip turns speech into text on the phone, with no Google account. Download the voice models once in setup.",
"The car's rotary knob moves between buttons, so cars without a touchscreen work too.",
"Choose how music reaches the car: Bluetooth, or USB with no delay. Gearslip offers to pair with the car the first time.",
"The car speaks each turn from your map app, and shows the directions on its own display where it can.",
"The car shows what's playing, and music apps get their own like, shuffle and repeat buttons.",
"Pick how the car's buttons look. Change it any time in the car's settings.",
"A new destination mid-route replaces the old route, and switching map apps ends the old app's route.",
"The car warns you when the phone's location is off.",
"Gearslip finds new versions and installs them for you.",
"More cars play music over USB, show a sharper picture, and put taps where you press.",
"On GrapheneOS, Gearslip no longer needs exploit protection compatibility mode.",
"The app is half the size it was.",
),
),
Release(
8, "0.2.00-alpha",
listOf(
"Exit takes you to the car's own screen. Tap Android Auto on the car to come back.",
"Gearslip listens through the car's microphone when the car has one, so voice works with the phone in a pocket.",
"A voice reply sends when its bar runs out, even over road noise or the radio. Say \"cancel\" or \"change it\" before then.",
"Maps turn dark when the car switches to night mode.",
"A moving map no longer makes the car screen fall behind your taps.",
"Gearslip asks to send sound to the car as soon as you connect, so videos in the web browser play in the car too.",
"Cars that offer more than one kind of music audio, such as the 2022 Dacia Jogger, play sound again.",
"When the car shows a keyboard, you can type on the phone instead. The car's keyboard is taller too.",
"Every car screen has a back button.",
"App icons show whether an app works in Gearslip. Metrolist works and shows the green check.",
"LIVI connects again, and touch works on cars with a second screen.",
"The phone's Settings screen has a new layout, with What's new and the setup guide at the top.",
"You can choose to send short usage notes that help the project. They hold no name, account, or location, and they're off unless you turn them on.",
"Searching inside a media app moved to Settings > Experimental features.",
"Under Settings > Media audio, the choices are now Phone audio and Car audio.",
"Logs leave out your location and the car's serial number.",
),
),
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 }
}
@@ -1,97 +0,0 @@
package app.seb3thehacker.gearslip
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* Proves the H.264 encoder actually produces a bitstream on this device, before a drive.
*
* Without this, an unsupported colour format, a rejected Baseline profile, or a null
* getInputImage() would all present in the car as "the head unit shows nothing" - the same
* symptom as a protocol fault, and impossible to tell apart from the log. Same reasoning as
* the TLS self-test: make our own failures loud on the bench.
*/
object EncoderSelfTest {
private val log = GearslipLog.tagged("VIDEO")
fun run(width: Int = 800, height: Int = 480, frameRate: Int = 30): Boolean {
log.i("=== encoder self-test starting (${width}x$height @ ${frameRate}fps) ===")
var codecConfig: ByteArray? = null
var frames = 0
var sawKeyFrame = false
var sawResyncKeyFrame = false
var resyncRequestedAtFrame = -1
val done = CountDownLatch(1)
val source = VideoSource(
width = width,
height = height,
frameRate = frameRate,
onCodecConfig = { codecConfig = it },
onFrame = { _, _, keyFrame ->
frames++
if (keyFrame) sawKeyFrame = true
// At frame 5, ask for a resync keyframe - the same call sendVideoFrame makes
// after a drop - and confirm THIS encoder actually honours it. Trusting an
// API we have never exercised on this device is exactly the mistake that
// cost two drives already (the CONTROL message-type bit, the touch scaling).
if (frames == 5) {
resyncRequestedAtFrame = frames
}
if (resyncRequestedAtFrame in 1 until frames && keyFrame) {
sawResyncKeyFrame = true
}
if (frames >= 12) done.countDown()
},
)
return try {
source.start()
// Give it a couple of frames before requesting resync, matching how it is used
// for real: the request always follows at least one already-encoded frame.
Thread {
while (resyncRequestedAtFrame == -1) Thread.sleep(20)
source.requestSyncFrame()
}.apply { isDaemon = true; start() }
done.await(8, TimeUnit.SECONDS)
source.stop()
val csd = codecConfig
when {
csd == null -> {
log.e("encoder self-test FAILED: no CODEC_CONFIG (SPS/PPS) produced")
false
}
frames == 0 -> {
log.e("encoder self-test FAILED: no frames produced")
false
}
!sawKeyFrame -> {
log.e("encoder self-test FAILED: no keyframe - the car needs one first")
false
}
!sawResyncKeyFrame -> {
log.e(
"encoder self-test FAILED: requestSyncFrame() produced no keyframe - " +
"the resync-after-drop fix would silently do nothing on this device",
)
false
}
else -> {
log.i(
"SPS/PPS = ${csd.size} bytes, $frames frames, keyframe present, " +
"requestSyncFrame() confirmed working",
)
log.hex(" csd", csd, limit = 48)
log.i("=== encoder self-test PASSED ===")
true
}
}
} catch (t: Throwable) {
runCatching { source.stop() }
log.e("encoder self-test FAILED with an exception", t)
false
}
}
}
@@ -1,190 +0,0 @@
package app.seb3thehacker.gearslip
import android.graphics.SurfaceTexture
import android.opengl.EGL14
import android.opengl.EGLConfig
import android.opengl.EGLContext
import android.opengl.EGLDisplay
import android.opengl.EGLExt
import android.opengl.EGLSurface
import android.opengl.GLES11Ext
import android.opengl.GLES20
import android.os.Handler
import android.os.HandlerThread
import android.view.Surface
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* Stretches a [width] x [height] picture over the whole of an [outWidth] x [outHeight] encoder
* frame.
*
* For a head unit that squashes the frame onto a screen of another shape: Gearslip draws for the
* screen's real shape into [input], this stretches it the other way, and the car's squash brings
* it back. It works on the composited picture, so a map app's own surface is stretched with
* everything else - scaling Gearslip's UI alone would leave the map squashed.
*/
class FrameStretcher(
private val output: Surface,
val width: Int,
val height: Int,
private val outWidth: Int,
private val outHeight: Int,
) {
private val thread = HandlerThread("gearslip-stretch").apply { start() }
private val handler = Handler(thread.looper)
private var display: EGLDisplay = EGL14.EGL_NO_DISPLAY
private var context: EGLContext = EGL14.EGL_NO_CONTEXT
private var eglSurface: EGLSurface = EGL14.EGL_NO_SURFACE
private var program = 0
private var texture: SurfaceTexture? = null
private val transform = FloatArray(16)
/** Where to draw. Valid once [start] returns true. */
lateinit var input: Surface
private set
/** Sets up on its own thread and waits for it; false if the GPU wouldn't play along. */
fun start(): Boolean {
val done = CountDownLatch(1)
var ok = false
handler.post {
ok = runCatching { setUp() }
.onFailure { GearslipLog.e("stretch: could not set up", it) }
.isSuccess
done.countDown()
}
done.await(2, TimeUnit.SECONDS)
if (!ok) release()
return ok
}
fun release() {
handler.post {
runCatching { if (::input.isInitialized) input.release() }
runCatching { texture?.release() }
texture = null
if (display != EGL14.EGL_NO_DISPLAY) {
EGL14.eglMakeCurrent(display, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT)
if (eglSurface != EGL14.EGL_NO_SURFACE) EGL14.eglDestroySurface(display, eglSurface)
if (context != EGL14.EGL_NO_CONTEXT) EGL14.eglDestroyContext(display, context)
EGL14.eglTerminate(display)
}
display = EGL14.EGL_NO_DISPLAY
thread.quitSafely()
}
}
private fun setUp() {
display = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
val version = IntArray(2)
check(EGL14.eglInitialize(display, version, 0, version, 1)) { "eglInitialize failed" }
val configs = arrayOfNulls<EGLConfig>(1)
val count = IntArray(1)
val attributes = intArrayOf(
EGL14.EGL_RED_SIZE, 8, EGL14.EGL_GREEN_SIZE, 8, EGL14.EGL_BLUE_SIZE, 8,
EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT,
EGL_RECORDABLE_ANDROID, 1,
EGL14.EGL_NONE,
)
check(EGL14.eglChooseConfig(display, attributes, 0, configs, 0, 1, count, 0) && count[0] > 0) {
"no EGL config"
}
context = EGL14.eglCreateContext(
display, configs[0], EGL14.EGL_NO_CONTEXT,
intArrayOf(EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE), 0,
)
eglSurface = EGL14.eglCreateWindowSurface(display, configs[0], output, intArrayOf(EGL14.EGL_NONE), 0)
check(EGL14.eglMakeCurrent(display, eglSurface, eglSurface, context)) { "eglMakeCurrent failed" }
program = link(VERTEX, FRAGMENT)
val ids = IntArray(1)
GLES20.glGenTextures(1, ids, 0)
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, ids[0])
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR)
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR)
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
texture = SurfaceTexture(ids[0]).apply {
setDefaultBufferSize(width, height)
setOnFrameAvailableListener({ draw() }, handler)
}
input = Surface(texture)
GearslipLog.i("stretch: drawing ${width}x$height, stretched to fill ${outWidth}x$outHeight")
}
private fun draw() {
val tex = texture ?: return
runCatching {
tex.updateTexImage()
tex.getTransformMatrix(transform)
GLES20.glViewport(0, 0, outWidth, outHeight)
GLES20.glUseProgram(program)
val position = GLES20.glGetAttribLocation(program, "aPosition")
val coord = GLES20.glGetAttribLocation(program, "aCoord")
GLES20.glEnableVertexAttribArray(position)
GLES20.glVertexAttribPointer(position, 2, GLES20.GL_FLOAT, false, 0, QUAD)
GLES20.glEnableVertexAttribArray(coord)
GLES20.glVertexAttribPointer(coord, 2, GLES20.GL_FLOAT, false, 0, COORDS)
GLES20.glUniformMatrix4fv(GLES20.glGetUniformLocation(program, "uTransform"), 1, false, transform, 0)
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
EGLExt.eglPresentationTimeANDROID(display, eglSurface, tex.timestamp)
EGL14.eglSwapBuffers(display, eglSurface)
}.onFailure { GearslipLog.e("stretch: draw failed", it) }
}
private fun link(vertex: String, fragment: String): Int {
fun compile(type: Int, source: String): Int = GLES20.glCreateShader(type).also {
GLES20.glShaderSource(it, source)
GLES20.glCompileShader(it)
val status = IntArray(1)
GLES20.glGetShaderiv(it, GLES20.GL_COMPILE_STATUS, status, 0)
check(status[0] != 0) { "shader: ${GLES20.glGetShaderInfoLog(it)}" }
}
return GLES20.glCreateProgram().also {
GLES20.glAttachShader(it, compile(GLES20.GL_VERTEX_SHADER, vertex))
GLES20.glAttachShader(it, compile(GLES20.GL_FRAGMENT_SHADER, fragment))
GLES20.glLinkProgram(it)
val status = IntArray(1)
GLES20.glGetProgramiv(it, GLES20.GL_LINK_STATUS, status, 0)
check(status[0] != 0) { "program: ${GLES20.glGetProgramInfoLog(it)}" }
}
}
private companion object {
/** EGL_RECORDABLE_ANDROID: the config must be able to feed a video encoder. */
const val EGL_RECORDABLE_ANDROID = 0x3142
val QUAD = floats(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f)
val COORDS = floats(0f, 0f, 1f, 0f, 0f, 1f, 1f, 1f)
fun floats(vararg values: Float) = ByteBuffer.allocateDirect(values.size * 4)
.order(ByteOrder.nativeOrder()).asFloatBuffer().apply { put(values); position(0) }
const val VERTEX = """
attribute vec4 aPosition;
attribute vec4 aCoord;
uniform mat4 uTransform;
varying vec2 vCoord;
void main() {
gl_Position = aPosition;
vCoord = (uTransform * aCoord).xy;
}
"""
const val FRAGMENT = """
#extension GL_OES_EGL_image_external : require
precision mediump float;
varying vec2 vCoord;
uniform samplerExternalOES sTexture;
void main() {
gl_FragColor = texture2D(sTexture, vCoord);
}
"""
}
}
@@ -1,196 +0,0 @@
package app.seb3thehacker.gearslip
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.OutputStream
/** Android Auto framing with plaintext fragmentation before per-frame encryption. */
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
// Android Auto's default fragment budget includes the framing header. TLS adds overhead.
private const val FRAGMENT_SIZE = 0x3f00
private const val HEADER_SIZE = 4
private const val FIRST_HEADER_SIZE = 8
const val MAX_FRAME_PAYLOAD = FRAGMENT_SIZE - HEADER_SIZE
class Frame(
val channel: Int,
val frameType: Int,
val encrypted: Boolean,
val messageType: Int,
val payload: ByteArray,
val totalSize: Int? = null,
)
/**
* 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 {
require(channel in 0..255 && payload.size <= 0xffff)
require(frameType in TYPE_MIDDLE..TYPE_BULK)
require(messageType == MESSAGE_SPECIFIC || messageType == MESSAGE_CONTROL)
require((frameType == TYPE_FIRST) == (totalSize != null))
require(totalSize == null || totalSize in 2..MAX_MESSAGE_SIZE)
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 (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
}
/** Splits plaintext before encryption; FIRST carries the complete plaintext length. */
fun writeMessage(
output: OutputStream,
channel: Int,
encrypted: Boolean,
messageType: Int,
message: ByteArray,
encrypt: (ByteArray) -> ByteArray,
): Int {
require(message.size in 2..MAX_MESSAGE_SIZE)
val fragmented = message.size > FRAGMENT_SIZE - HEADER_SIZE
require(messageType != MESSAGE_CONTROL || !fragmented) { "Channel control messages cannot be fragmented" }
var offset = 0
var frames = 0
while (offset < message.size) {
val first = fragmented && offset == 0
val headerSize = if (first) FIRST_HEADER_SIZE else HEADER_SIZE
val end = minOf(offset + FRAGMENT_SIZE - headerSize, message.size)
val type = when {
!fragmented -> TYPE_BULK
first -> TYPE_FIRST
end == message.size -> TYPE_LAST
else -> TYPE_MIDDLE
}
val chunk = message.copyOfRange(offset, end)
output.write(build(channel, encrypted, if (encrypted) encrypt(chunk) else chunk,
messageType, type, if (type == TYPE_FIRST) message.size else null))
offset = end
frames++
}
return frames
}
// Local resource limits, not protocol constants. No supported media packet needs more.
private const val MAX_MESSAGE_SIZE = 16 * 1024 * 1024
private const val MAX_BUFFERED_SIZE = 32 * 1024 * 1024
/**
* 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) {
require(length in 0..data.size)
if (length > MAX_BUFFERED_SIZE - pending.size) throw IOException("Frame buffer limit exceeded")
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 messageSize = if (frameType == TYPE_FIRST) {
var size = 0L
for (i in 4..7) size = (size shl 8) or (pending[i].toLong() and 0xff)
if (size !in 2..MAX_MESSAGE_SIZE.toLong()) throw IOException("Invalid fragmented message length: $size")
size.toInt()
} else 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, messageSize,
)
}
}
/** Rejoins messages on independent channels, validating their plaintext length and flags. */
class Assembler {
private class Partial(val first: Frame, val data: ByteArrayOutputStream = ByteArrayOutputStream())
private val partial = HashMap<Int, Partial>()
private var buffered = 0
fun offer(frame: Frame, payload: ByteArray): ByteArray? {
if (frame.frameType == TYPE_BULK) {
if (partial.containsKey(frame.channel)) fail("Complete message interrupted fragments")
return payload
}
val message = if (frame.frameType == TYPE_FIRST) {
if (frame.totalSize == null || partial.containsKey(frame.channel)) fail("Invalid first fragment")
Partial(frame).also { partial[frame.channel] = it }
} else partial[frame.channel] ?: fail("Fragment without a first frame")
val first = message.first
if (first.encrypted != frame.encrypted || first.messageType != frame.messageType) fail("Fragment flags changed")
val expected = first.totalSize ?: fail("Missing total size")
if (expected !in 2..MAX_MESSAGE_SIZE || payload.size > expected - message.data.size()) fail("Fragment exceeds message length")
if (payload.size > MAX_BUFFERED_SIZE - buffered) fail("Fragment buffer limit exceeded")
message.data.write(payload)
buffered += payload.size
if (frame.frameType != TYPE_LAST) return null
if (message.data.size() != expected) fail("Truncated fragmented message")
partial.remove(frame.channel)
buffered -= message.data.size()
return message.data.toByteArray()
}
private fun fail(message: String): Nothing {
partial.clear()
buffered = 0
throw IOException(message)
}
}
}
@@ -1,487 +0,0 @@
package app.seb3thehacker.gearslip
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.hardware.usb.UsbAccessory
import android.hardware.usb.UsbManager
import android.media.projection.MediaProjection
import android.media.projection.MediaProjectionManager
import android.net.Uri
import android.os.Bundle
import android.os.ParcelFileDescriptor
import android.provider.Settings
import android.view.MotionEvent
import android.view.Surface
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.stats.UsageStats
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 val mainHandler = android.os.Handler(android.os.Looper.getMainLooper())
private var descriptor: ParcelFileDescriptor? = null
/** The wireless session's socket, when the car came over Wi-Fi instead of USB. */
private var wirelessSocket: java.net.Socket? = 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 if (!shouldShowRequestPermissionRationale(android.Manifest.permission.RECORD_AUDIO)) {
// Refused twice: Android now says no without showing anything, so the phone must.
GearslipLog.w("audio: record permission blocked - Android no longer asks")
CarAudio.micBlocked()
micBlockedNotice.value = true
} 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()
if (!CarSettings.audioChoiceAsked) offerBluetooth.value = true
}
}
/** Raised when the microphone permission is blocked; see [MicBlockedDialog]. */
private val micBlockedNotice = kotlinx.coroutines.flow.MutableStateFlow(false)
/** Raised once, after the first "no" to the capture dialog; see [AudioChoiceDialog]. */
private val offerBluetooth = kotlinx.coroutines.flow.MutableStateFlow(false)
private val detachReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == UsbManager.ACTION_USB_ACCESSORY_DETACHED) {
GearslipLog.w("accessory detached")
// A wireless session has no accessory to lose; leave it running.
if (wirelessSocket == null) closeAccessory()
}
}
}
/** Android's one-time "Allow" for a car to connect by itself; see [WirelessAuto]. */
private var approvingCar: String? = null
private val carApproval = registerForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult(),
) { result ->
val address = approvingCar ?: return@registerForActivityResult
approvingCar = null
if (result.resultCode == RESULT_OK) app.seb3thehacker.gearslip.wireless.WirelessAuto.approved(this, address)
else app.seb3thehacker.gearslip.wireless.WirelessAuto.declined(this, address)
}
/** 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()
GearslipLog.init(this)
GearslipLog.installCrashHandler()
GearslipLog.i("Gearslip ready")
app.seb3thehacker.gearslip.wireless.WirelessLink.onConnected = { socket ->
runOnUiThread { startWireless(socket) }
}
app.seb3thehacker.gearslip.wireless.WirelessAuto.showApproval = { chooser, address ->
runOnUiThread {
approvingCar = address
carApproval.launch(androidx.activity.result.IntentSenderRequest.Builder(chooser).build())
}
}
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)
val offer by offerBluetooth.collectAsState()
if (offer) {
fun close() {
CarSettings.markAudioChoiceAsked()
offerBluetooth.value = false
}
app.seb3thehacker.gearslip.ui.AudioChoiceDialog(
onUseBluetooth = {
CarSettings.setPipeAudio(false)
close()
},
onKeepAsking = ::close,
)
}
val micNotice by micBlockedNotice.collectAsState()
if (micNotice) {
app.seb3thehacker.gearslip.ui.MicBlockedDialog(
onOpenSettings = {
micBlockedNotice.value = false
startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", packageName, null)),
)
},
onDismiss = { micBlockedNotice.value = false },
)
}
}
}
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)
UsageStats.onAppOpened(this)
app.seb3thehacker.gearslip.update.UpdateChecker.onAppOpened(this)
// Back from the app's settings with the microphone turned on: try car audio again.
if (checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) == android.content.pm.PackageManager.PERMISSION_GRANTED) {
CarAudio.micAllowed()
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIntent(intent)
}
override fun onDestroy() {
super.onDestroy()
app.seb3thehacker.gearslip.wireless.WirelessLink.onConnected = null
app.seb3thehacker.gearslip.wireless.WirelessAuto.showApproval = null
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 == app.seb3thehacker.gearslip.wireless.WirelessAuto.ACTION_CAR_NEARBY) {
connectToNearbyCar(intent.getStringExtra(app.seb3thehacker.gearslip.wireless.WirelessAuto.EXTRA_ADDRESS))
return
}
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)
}
/** A known car came up over Bluetooth: connect wirelessly, unless USB or another try is already on it. */
private fun connectToNearbyCar(address: String?) {
if (address == null) return
if (!AppSettings.gearslipEnabled(this)) {
GearslipLog.i("auto-connect: Gearslip is switched off - not connecting")
return
}
if (descriptor != null) {
GearslipLog.i("auto-connect: the car is on USB already - staying on USB")
return
}
// Only a session that's still running counts. One the car ended (a ByeBye, a dropped
// network) leaves its runner behind, since only Gearslip ending a session clears it, and
// that used to make every later auto-connect quietly do nothing.
if (worker?.isAlive == true || app.seb3thehacker.gearslip.wireless.WirelessLink.busy) {
GearslipLog.i("auto-connect: a session is already running")
return
}
if (runner != null) closeAccessory(stopWireless = false)
if (checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) != android.content.pm.PackageManager.PERMISSION_GRANTED) {
GearslipLog.w("auto-connect: needs the Nearby devices permission")
return
}
val adapter = getSystemService(android.bluetooth.BluetoothManager::class.java)?.adapter
val device = adapter?.let { runCatching { it.getRemoteDevice(address) }.getOrNull() }
if (device == null) {
GearslipLog.w("auto-connect: Bluetooth couldn't hand over the car")
return
}
GearslipLog.i("auto-connect: connecting")
app.seb3thehacker.gearslip.wireless.WirelessLink.start(this, device)
}
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() }
}
/** A wireless session: the same runner, on the socket [WirelessLink] opened to the car. */
private fun startWireless(socket: java.net.Socket) {
closeAccessory(stopWireless = false)
if (!AppSettings.gearslipEnabled(this)) {
GearslipLog.i("wireless: Gearslip is switched off - not connecting")
runCatching { socket.close() }
return
}
GearslipLog.i("wireless: starting a session over Wi-Fi to ${socket.inetAddress?.hostAddress}:${socket.port}")
wirelessSocket = socket
val runner = GearslipRunner(
input = socket.getInputStream(),
output = socket.getOutputStream(),
identityProvider = { CertProvider.load(this) },
projection = this,
vehicleProfileFor = { info -> VehicleProfiles.find(VehicleProfiles.load(this), info) },
wireless = true,
)
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() = closeAccessory(stopWireless = true)
/** Ends the session, wired or wireless. [stopWireless] also drops the car's Bluetooth and Wi-Fi. */
private fun closeAccessory(stopWireless: Boolean) {
runner?.stop()
SessionReport.print()
runCatching { descriptor?.close() }
descriptor = null
runCatching { wirelessSocket?.close() }
wirelessSocket = null
if (stopWireless) app.seb3thehacker.gearslip.wireless.WirelessLink.stop(this)
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>) {
// To the front of the main thread's queue: a touch waiting behind other work is lag the
// driver feels, while the work it jumps ahead of can wait a frame.
mainHandler.postAtFrontOfQueue {
// 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() {
// Before the car UI goes, while the media connection is still up.
app.seb3thehacker.gearslip.car.CarServices.pausePhoneMedia()
runOnUiThread {
PhoneMirror.requestStop()
app.seb3thehacker.gearslip.car.CarKeys.focusSink = null
screenProjector?.stop()
screenProjector = null
surface = null
CarEnvironment.stop()
}
}
}
@@ -1,237 +0,0 @@
package app.seb3thehacker.gearslip
import android.content.Context
import android.util.Log
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
/**
* Logs to logcat and to a file at once.
*
* The file copy is the safety net: entering USB accessory mode tears down adb-over-USB, so
* if wireless adb drops in the car the file is the only surviving record of the run. It
* lives under getExternalFilesDir, which `adb pull` can reach without root.
*/
object GearslipLog {
const val TAG = "Gearslip"
private val stamp = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
private val io = Executors.newSingleThreadExecutor()
private const val MAX_LIVE_LINES = 3000
private var file: File? = null
/**
* 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)
// One file per launch adds up; the newest are the ones anyone asks for.
io.execute { logFiles(dir).drop(KEEP_FILES).forEach { it.delete() } }
}
private const val KEEP_FILES = 30
private fun logFiles(dir: File): List<File> =
dir.listFiles { f -> f.isFile && f.name.startsWith("gearslip-") && f.name.endsWith(".log") }
?.sortedByDescending { it.name }.orEmpty()
/** A log from an earlier launch: when it started, and what a glance at it tells. */
class PastLog(val file: File, val started: Date?, val crashed: Boolean, val car: String?)
/**
* Earlier launches' logs, newest first. Each launch starts a fresh file, so after a crash the
* one that saw it is here rather than on the live screen.
*/
fun pastLogs(context: Context): List<PastLog> {
val dir = context.getExternalFilesDir(null) ?: return emptyList()
val parse = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US)
return logFiles(dir).filter { it != file }.map { f ->
val text = runCatching { f.readText() }.getOrDefault("")
PastLog(
file = f,
started = runCatching { parse.parse(f.name.removePrefix("gearslip-").removeSuffix(".log")) }.getOrNull(),
crashed = "FATAL uncaught" in text,
car = Regex("""car="([^"]+)"""").findAll(text).lastOrNull()?.groupValues?.get(1),
)
}
}
/** An earlier launch's log, copied to the cache dir for sharing like [shareableFile]. */
fun shareableFile(context: Context, past: File): android.net.Uri {
val dir = File(context.cacheDir, "shared").apply { mkdirs() }
dir.listFiles()?.forEach { it.delete() }
val out = File(dir, "gearslip-log-${past.name.removePrefix("gearslip-").removeSuffix(".log")}.txt")
past.copyTo(out, overwrite = true)
return androidx.core.content.FileProvider.getUriForFile(
context, "${context.packageName}.logshare", out,
)
}
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
@@ -1,125 +0,0 @@
package app.seb3thehacker.gearslip
import java.io.ByteArrayInputStream
import java.math.BigInteger
import java.security.KeyFactory
import java.security.KeyStore
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.security.spec.RSAPrivateCrtKeySpec
import java.security.spec.RSAPublicKeySpec
/**
* Converts a PEM-encoded X.509 certificate and PEM-encoded RSA PKCS#1 private key into a
* PKCS#12 [KeyStore], using only standard JCA APIs — no BouncyCastle.
*
* The DER parsing of the PKCS#1 key is done by hand (matching the same approach used in
* [SelfSignedCert] for writing). Android's Conscrypt [KeyFactory] only accepts PKCS#8,
* so we translate PKCS#1 → [RSAPrivateCrtKeySpec] ourselves.
*/
object PemKeyParser {
private const val CERT_HEADER = "-----BEGIN CERTIFICATE-----"
private const val CERT_FOOTER = "-----END CERTIFICATE-----"
private const val KEY_HEADER = "-----BEGIN RSA PRIVATE KEY-----"
private const val KEY_FOOTER = "-----END RSA PRIVATE KEY-----"
class Result(val keyStore: KeyStore, val certificate: X509Certificate)
/**
* Parses PEM strings for the certificate and private key, verifies they match, and
* returns a PKCS#12 [KeyStore] ready for [CertProvider] to consume.
*/
fun build(certificatePem: String, privateKeyPem: String, alias: String = "phone"): Result {
val cert = parseCertificate(certificatePem)
val keySpec = parsePrivateKey(privateKeyPem)
val kf = KeyFactory.getInstance("RSA")
val pubSpec = RSAPublicKeySpec(keySpec.spec.modulus, keySpec.spec.publicExponent)
require(kf.generatePublic(pubSpec) == cert.publicKey) {
"the certificate's public key does not match the private key"
}
val keyStore = KeyStore.getInstance("PKCS12").apply {
load(null, null)
setKeyEntry(alias, keySpec.getPrivate(), CharArray(0), arrayOf(cert))
}
return Result(keyStore, cert)
}
/** Strips PEM headers/footers and decodes the base64 body. */
fun parseCertificate(pem: String): X509Certificate {
val der = pemToDer(pem, CERT_HEADER, CERT_FOOTER)
return CertificateFactory.getInstance("X.509")
.generateCertificate(ByteArrayInputStream(der)) as X509Certificate
}
/** Parses an RSA PKCS#1 private key (ASN.1 DER) into a key spec. */
fun parsePrivateKey(pem: String): KeySpec {
val der = pemToDer(pem, KEY_HEADER, KEY_FOOTER)
val integers = readPkcs1Sequence(der)
require(integers.size == 9) { "PKCS#1 RSA key needs 9 integers, got ${integers.size}" }
val spec = RSAPrivateCrtKeySpec(integers[1], integers[2], integers[3], integers[4], integers[5], integers[6], integers[7], integers[8])
val privateKey = KeyFactory.getInstance("RSA").generatePrivate(spec)
return KeySpec(spec, privateKey)
}
/** Holds both the spec and the constructed key for matching against a certificate's public key. */
class KeySpec(val spec: RSAPrivateCrtKeySpec, private val privateKey: java.security.PrivateKey) {
fun getPrivate() = privateKey
}
/** Strips headers and decodes base64. */
private fun pemToDer(pem: String, header: String, footer: String): ByteArray {
val start = pem.indexOf(header)
val end = pem.indexOf(footer)
if (start == -1 || end == -1 || end <= start) {
throw IllegalArgumentException("not a valid PEM $header block")
}
val b64 = pem.substring(start + header.length, end)
.replace(Regex("\\s+"), "")
return java.util.Base64.getDecoder().decode(b64)
}
/**
* Parses the inner SEQUENCE of an RSA PKCS#1 DER blob into a list of [BigInteger]s.
* Throws if the ASN.1 structure is wrong, so caller gets a clear error rather than a
* silent wrong key.
*/
private fun readPkcs1Sequence(der: ByteArray): List<BigInteger> {
var pos = 0
fun readTag(): Int {
require(pos < der.size) { "unexpected end of DER at tag" }
return der[pos++].toInt() and 0xFF
}
fun readLength(): Int {
require(pos < der.size) { "unexpected end of DER at length" }
val b0 = der[pos++].toInt() and 0xFF
if (b0 < 0x80) return b0
val numBytes = b0 and 0x7F
require(pos + numBytes <= der.size) { "truncated long-form length" }
var result = 0
repeat(numBytes) {
result = (result shl 8) or (der[pos++].toInt() and 0xFF)
}
return result
}
val outerTag = readTag()
require(outerTag == 0x30) { "expected SEQUENCE (0x30), got 0x${outerTag.toString(16)}" }
val outerLen = readLength()
require(pos + outerLen <= der.size) { "SEQUENCE length $outerLen exceeds remaining data" }
val integers = mutableListOf<BigInteger>()
while (pos < der.size) {
val tag = readTag()
require(tag == 0x02) { "expected INTEGER (0x02), got 0x${tag.toString(16)} at offset ${pos - 1}" }
val len = readLength()
require(pos + len <= der.size) { "INTEGER length $len exceeds remaining data" }
integers.add(BigInteger(der.copyOfRange(pos, pos + len)))
pos += len
}
require(integers.size == 9) { "PKCS#1 RSA key needs 9 integers, got ${integers.size}" }
return integers
}
}
@@ -1,49 +0,0 @@
package app.seb3thehacker.gearslip
import android.app.Activity
import android.app.ActivityOptions
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.Display
/**
* Asks for runtime permissions on the phone's own screen, for a car screen that can't show
* Android's dialog itself. It has no look of its own: the dialog appears, the answer is given,
* and it finishes.
*/
class PermissionActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val wanted = intent.getStringArrayExtra(EXTRA_PERMISSIONS)
?.filter { checkSelfPermission(it) != android.content.pm.PackageManager.PERMISSION_GRANTED }
.orEmpty()
if (wanted.isEmpty() || savedInstanceState != null) {
if (wanted.isEmpty()) finish()
return
}
requestPermissions(wanted.toTypedArray(), REQUEST)
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
GearslipLog.i("permissions: ${permissions.zip(grantResults.toList()).joinToString { (p, r) -> "${p.substringAfterLast('.')}=${r == 0}" }}")
finish()
}
companion object {
private const val EXTRA_PERMISSIONS = "permissions"
private const val REQUEST = 1
/** Shows the permission dialog on the phone, whichever screen [context] belongs to. */
fun ask(context: Context, permissions: Array<String>) {
val intent = Intent(context, PermissionActivity::class.java)
.putExtra(EXTRA_PERMISSIONS, permissions)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val options = ActivityOptions.makeBasic().setLaunchDisplayId(Display.DEFAULT_DISPLAY)
runCatching { context.startActivity(intent, options.toBundle()) }
.onFailure { GearslipLog.w("permissions: could not ask on the phone: ${it.message}") }
}
}
}
@@ -1,210 +0,0 @@
package app.seb3thehacker.gearslip
import java.io.ByteArrayOutputStream
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.SSLException
import javax.net.ssl.X509ExtendedTrustManager
/** TLS server for projection. Handshake records travel in control message 3, not a TLS socket. */
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)
@Volatile 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.
// Homemade head units may have none.
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) throw SSLException("TLS closed during handshake")
}
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 -> throw SSLException("TLS closed during handshake")
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 = 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
}
if (result.status != SSLEngineResult.Status.OK || result.bytesConsumed() == 0) {
throw SSLException("TLS could not encrypt application data: ${result.status}")
}
netBuffer.flip()
val chunk = ByteArray(netBuffer.remaining())
netBuffer.get(chunk)
out.write(chunk)
}
return out.toByteArray()
}
/** Each encrypted projection frame contains complete TLS records. */
fun decrypt(cipher: ByteArray): ByteArray {
val source = ByteBuffer.wrap(cipher)
val out = 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 -> {
throw SSLException("Incomplete TLS record in an encrypted frame")
}
SSLEngineResult.Status.CLOSED -> throw SSLException("TLS peer closed the connection")
else -> Unit
}
appBuffer.flip()
val chunk = ByteArray(appBuffer.remaining())
appBuffer.get(chunk)
out.write(chunk)
if (result.bytesConsumed() == 0 && result.bytesProduced() == 0) break
}
return out.toByteArray()
}
private fun describeSession() {
val session = engine.session
log.i("TLS handshake COMPLETE - protocol=${session.protocol} cipher=${session.cipherSuite}")
SessionReport.tls(session.protocol, session.cipherSuite)
try {
val peers = session.peerCertificates
log.i("head unit presented ${peers.size} certificate(s):")
peers.forEachIndexed { index, certificate ->
if (certificate is X509Certificate) {
log.i(" [$index] subject=${certificate.subjectX500Principal}")
log.i(" issuer =${certificate.issuerX500Principal}")
log.i(" serial =${certificate.serialNumber} valid=${certificate.notBefore}..${certificate.notAfter}")
}
}
} catch (_: SSLPeerUnverifiedException) {
log.i("head unit presented NO certificate (we asked but did not require one)")
}
}
/** Accepts everything and reports what it saw. We want the data, not a verdict. */
private class LoggingTrustManager : X509ExtendedTrustManager() {
private val log = GearslipLog.tagged("TLS")
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {
log.i("peer certificate offered (authType=$authType, chain=${chain?.size ?: 0}) - accepting")
}
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?, socket: Socket?) =
checkClientTrusted(chain, authType)
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?, engine: SSLEngine?) =
checkClientTrusted(chain, authType)
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) =
checkClientTrusted(chain, authType)
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?, socket: Socket?) =
checkClientTrusted(chain, authType)
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?, engine: SSLEngine?) =
checkClientTrusted(chain, authType)
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
}
companion object {
private val EMPTY: ByteBuffer = ByteBuffer.allocate(0)
}
}
@@ -1,67 +0,0 @@
package app.seb3thehacker.gearslip
import android.content.Context
import java.io.File
/** Decides what the car screen shows. */
object ProjectedContent {
/**
* Precedence: the URL saved in Settings, then a `url.txt` in the external files dir (the
* pre-Settings way, still honoured so existing setups keep working), then [DEFAULT_URL].
*/
fun resolve(context: Context): String {
val saved = AppSettings.startupUrl(context)
if (saved.isNotEmpty()) {
val url = normalize(saved)
GearslipLog.i("projecting startup URL from settings: $url")
return url
}
val legacy = context.getExternalFilesDir(null)?.let { File(it, "url.txt") }
if (legacy != null && legacy.isFile) {
val url = legacy.readText().trim()
if (url.isNotEmpty()) {
GearslipLog.i("projecting url.txt override: $url")
return url
}
}
return DEFAULT_URL
}
/** What the Web app opens until a page is chosen in Settings. */
const val DEFAULT_URL = "https://duckduckgo.com"
/** A bare "example.com" is a URL the user meant; raw HTML is passed through untouched. */
private fun normalize(value: String): String =
if (value.startsWith("<") || "://" in value) value else "https://$value"
/** Big targets, a live clock, and tap feedback - all verifiable at a glance in a car. */
val TEST_PAGE = """
<html><head><meta name="viewport" content="width=device-width,initial-scale=1">
<style>
html{height:100%}
body{margin:0;font-family:sans-serif;background:#101418;color:#fff;
display:flex;flex-direction:column;height:100%}
h1{margin:12px;font-size:28px}
#t{margin:0 12px 8px;font-size:20px;color:#8ab4f8}
.row{display:flex;flex:1;gap:8px;padding:8px}
.b{flex:1;display:flex;align-items:center;justify-content:center;
font-size:26px;border-radius:12px;background:#243043}
.hit{background:#2e7d32}
</style></head><body>
<h1>Gearslip &mdash; projected from the phone</h1>
<div id="t">starting&hellip;</div>
<div class="row">
<div class="b" id="a">TAP A</div><div class="b" id="b">TAP B</div>
<div class="b" id="c">TAP C</div>
</div>
<script>
setInterval(function(){document.getElementById('t').textContent=new Date().toLocaleTimeString();},1000);
['a','b','c'].forEach(function(id){
var e=document.getElementById(id);
e.addEventListener('touchstart',function(){e.classList.add('hit');e.textContent='HIT '+id.toUpperCase();});
e.addEventListener('touchend',function(){setTimeout(function(){e.classList.remove('hit');e.textContent='TAP '+id.toUpperCase();},400);});
});
</script></body></html>
""".trimIndent()
}
@@ -1,30 +0,0 @@
package app.seb3thehacker.gearslip
import android.view.Surface
/**
* Lets [GearslipRunner] drive on-screen content without owning any Android UI itself.
*
* The protocol side decides *when* a surface is needed and *where* touches landed; the
* activity decides *what* to render and how to inject events, because both need a UI thread
* and a Context.
*/
interface Projection {
/** Called once the encoder's input Surface exists and content should start rendering. */
fun onSurfaceReady(surface: Surface, width: Int, height: Int, densityDpi: Int)
/**
* A touch from the head unit, already scaled into projected-video coordinates.
* [action] is an Android MotionEvent action - Android Auto's PointerAction enum uses
* identical values (DOWN=0, UP=1, MOVED=2, POINTER_DOWN=5, POINTER_UP=6). [points] is every
* finger currently down, and [actionIndex] says which of them [action] is about, so a pinch
* arrives as it would on the phone's own screen.
*/
fun onTouch(action: Int, actionIndex: Int, points: List<TouchPoint>)
fun onProjectionStopped()
}
/** One finger on the car's screen: a stable [id] for as long as it stays down, and where it is. */
class TouchPoint(val id: Int, val x: Float, val y: Float)
@@ -1,181 +0,0 @@
package app.seb3thehacker.gearslip
import java.io.ByteArrayOutputStream
/** Protobuf field encoding and diagnostic rendering for the projection protocol. */
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 bytesField(field: Int, bytes: ByteArray): ByteArray =
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? = Wire.varint(Wire.fields(data), field)
/**
* 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 {
if (indent.length > 32) return "$indent(nested message)\n"
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
if (len < 0 || len > data.size - afterLen) 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
if (shift == 63 && b > 1) return null
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) ?: return emptyList()
pos = tag.second
val number = (tag.first ushr 3).toInt()
if (tag.first ushr 3 !in 1..0x1fff_ffffL) return emptyList()
when ((tag.first and 7L).toInt()) {
0 -> {
val v = readVarint(data, pos) ?: return emptyList()
pos = v.second
out += Field(number, 0, v.first, null)
}
1 -> { if (data.size - pos < 8) return emptyList(); pos += 8 }
2 -> {
val len = readVarint(data, pos) ?: return emptyList()
if (len.first < 0 || len.first > data.size - len.second) return emptyList()
val end = len.second + len.first.toInt()
if (end > data.size || end < len.second) return emptyList()
out += Field(number, 2, 0, data.copyOfRange(len.second, end))
pos = end
}
5 -> { if (data.size - pos < 4) return emptyList(); pos += 4 }
else -> return emptyList()
}
}
return out
}
fun varint(fields: List<Field>, number: Int): Long? =
fields.lastOrNull { it.number == number && it.wireType == 0 }?.varint
fun bytes(fields: List<Field>, number: Int): ByteArray? =
fields.lastOrNull { 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 }
/** Protobuf readers accept both packed and unpacked repeated numeric fields. */
fun repeatedInts(fields: List<Field>, number: Int): List<Int> =
repeatedLongs(fields, number)?.map(Long::toInt).orEmpty()
/** Null means a packed field contained an incomplete or overflowing varint. */
fun repeatedLongs(fields: List<Field>, number: Int): List<Long>? = buildList {
for (field in fields.filter { it.number == number }) {
if (field.wireType == 0) add(field.varint)
else if (field.wireType == 2) {
val bytes = field.bytes ?: continue
var position = 0
while (position < bytes.size) {
val (value, next) = readVarint(bytes, position) ?: return null
add(value)
position = next
}
}
}
}
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
if (shift == 63 && b > 1) return null
result = result or ((b and 0x7F).toLong() shl shift)
pos++
if (b and 0x80 == 0) return result to pos
shift += 7
}
return null
}
}
@@ -1,17 +0,0 @@
package app.seb3thehacker.gearslip
/**
* Advertises our supported 1.x version, as Android Auto does for older head units. Head units that
* offer a newer major version (LIVI asks for 6.1, a RAV4 and the DHU for 4.2) still accept a 1.x
* answer, so only a major version of 0 is refused.
*/
internal object ProtocolVersion {
data class Response(val major: Int, val minor: Int, val supported: Boolean) {
val bytes: ByteArray get() = byteArrayOf(
(major ushr 8).toByte(), major.toByte(), (minor ushr 8).toByte(), minor.toByte(),
if (supported) 0 else -1, if (supported) 0 else -1,
)
}
fun respond(major: Int, minor: Int): Response = Response(1, 7, major >= 1 && minor in 0..0xffff)
}
@@ -1,259 +0,0 @@
package app.seb3thehacker.gearslip
import android.app.ActivityOptions
import android.app.Presentation
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.hardware.display.DisplayManager
import android.hardware.display.VirtualDisplay
import android.os.SystemClock
import android.view.MotionEvent
import android.view.Surface
import android.view.View
import android.view.ViewGroup
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.ComposeView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
import androidx.lifecycle.setViewTreeLifecycleOwner
import androidx.lifecycle.setViewTreeViewModelStoreOwner
import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
/**
* Renders the car UI into the encoder's input Surface.
*
* Uses the same mechanism as Android screen mirroring: a VirtualDisplay backed by the
* MediaCodec input Surface, with a Presentation hosting a Compose view on that display.
* Anything drawn there is encoded and projected - no hand-written EGL/GL.
*
* Everything here must run on the main thread: Presentation is a Dialog.
*/
class ScreenProjector(private val context: Context) {
private var virtualDisplay: VirtualDisplay? = null
private var presentation: Presentation? = null
private var owner: ViewTreeOwner? = null
private var root: View? = null
/** Tracks the synthetic gesture so injected MotionEvents have a coherent downTime. */
private var downTime = 0L
/**
* [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
// Keys handed straight to the view skip the step where Android leaves touch mode, and in
// touch mode Compose's buttons refuse focus, so the knob had nothing to move between.
if (target.isInTouchMode) {
target.requestFocusFromTouch()
GearslipLog.i("keys: left touch mode for the knob/D-pad (now in touch mode: ${target.isInTouchMode})")
}
// 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)
}
/**
* Points the display at another surface, or none, without restarting what it shows. The
* preview's SurfaceView comes and goes as the phone turns; the car UI on it carries on.
*/
fun setSurface(surface: Surface?) {
virtualDisplay?.surface = surface
}
fun stop() {
runCatching { presentation?.dismiss() }
runCatching { owner?.destroy() }
runCatching { virtualDisplay?.release() }
presentation = null
owner = null
root = null
virtualDisplay = null
downTime = 0L
}
private class ViewTreeOwner : LifecycleOwner, SavedStateRegistryOwner, ViewModelStoreOwner {
private val registry = LifecycleRegistry(this)
private val savedState = SavedStateRegistryController.create(this)
override val lifecycle: Lifecycle get() = registry
override val savedStateRegistry: SavedStateRegistry get() = savedState.savedStateRegistry
override val viewModelStore = ViewModelStore()
init {
savedState.performRestore(null)
registry.currentState = Lifecycle.State.RESUMED
}
fun destroy() {
registry.currentState = Lifecycle.State.DESTROYED
viewModelStore.clear()
}
}
}
@@ -1,124 +0,0 @@
package app.seb3thehacker.gearslip
import java.io.ByteArrayInputStream
import java.math.BigInteger
import java.security.KeyPair
import java.security.KeyPairGenerator
import java.security.KeyStore
import java.security.SecureRandom
import java.security.Signature
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
/**
* Generates a self-signed RSA-2048 certificate - the thing under test.
*
* Written by hand rather than with BouncyCastle so the spike has no dependencies and no
* second security provider sitting next to Conscrypt. A self-signed X.509 is a fixed
* structure, so this is mechanical; the risk is a silent malformation, which
* [generate] closes off by parsing its own output back through CertificateFactory and
* verifying the signature before returning. A bad certificate therefore fails loudly on the
* bench instead of looking like a head-unit rejection in the car.
*
* RSA-2048 matches aasdk's own key (cert/headunit.key) - see cert/README.md.
*/
object SelfSignedCert {
const val ALIAS = "phone"
val PASSWORD: CharArray = "spike".toCharArray()
// Pre-encoded OIDs, so there is no OID encoder to get wrong.
private val OID_SHA256_RSA = byteArrayOf(
0x06, 0x09, 0x2A, 0x86.toByte(), 0x48, 0x86.toByte(), 0xF7.toByte(), 0x0D, 0x01, 0x01, 0x0B
)
private val OID_CN = byteArrayOf(0x06, 0x03, 0x55, 0x04, 0x03)
private val OID_O = byteArrayOf(0x06, 0x03, 0x55, 0x04, 0x0A)
class Result(val keyStore: KeyStore, val certificate: X509Certificate)
fun generate(commonName: String = "Gearslip", organisation: String = "Gearslip"): Result {
val keyPair = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair()
val now = System.currentTimeMillis()
val notBefore = Date(now - 86_400_000L) // a day of slack for head-unit clock skew
val notAfter = Date(now + 20L * 365 * 86_400_000L) // ~2046, still inside UTCTime's pre-2050 range
val der = buildCertificate(keyPair, commonName, organisation, notBefore, notAfter)
// Self-check: malformed DER throws here, on the bench, not in the car.
val certificate = CertificateFactory.getInstance("X.509")
.generateCertificate(ByteArrayInputStream(der)) as X509Certificate
certificate.verify(keyPair.public)
certificate.checkValidity()
val keyStore = KeyStore.getInstance("PKCS12").apply {
load(null, null)
setKeyEntry(ALIAS, keyPair.private, PASSWORD, arrayOf(certificate))
}
return Result(keyStore, certificate)
}
private fun buildCertificate(
keyPair: KeyPair,
commonName: String,
organisation: String,
notBefore: Date,
notAfter: Date,
): ByteArray {
val algorithmId = seq(OID_SHA256_RSA, tlv(0x05, ByteArray(0))) // sha256WithRSAEncryption, NULL params
val name = seq(rdn(OID_CN, commonName), rdn(OID_O, organisation))
val serial = BigInteger(64, SecureRandom()).add(BigInteger.ONE)
val tbs = seq(
tlv(0xA0, integer(BigInteger.valueOf(2))), // [0] EXPLICIT version, v3
integer(serial),
algorithmId,
name,
seq(utcTime(notBefore), utcTime(notAfter)),
name,
keyPair.public.encoded, // already a DER SubjectPublicKeyInfo
)
val signature = Signature.getInstance("SHA256withRSA").run {
initSign(keyPair.private)
update(tbs)
sign()
}
return seq(tbs, algorithmId, bitString(signature))
}
// --- minimal DER writer ------------------------------------------------------------
private fun length(n: Int): ByteArray {
if (n < 0x80) return byteArrayOf(n.toByte())
var value = n
val out = ArrayList<Byte>()
while (value > 0) {
out.add(0, (value and 0xFF).toByte())
value = value ushr 8
}
return byteArrayOf((0x80 or out.size).toByte()) + out.toByteArray()
}
private fun tlv(tag: Int, body: ByteArray) = byteArrayOf(tag.toByte()) + length(body.size) + body
private fun seq(vararg parts: ByteArray) = tlv(0x30, parts.reduce { a, b -> a + b })
private fun integer(v: BigInteger) = tlv(0x02, v.toByteArray())
private fun bitString(body: ByteArray) = tlv(0x03, byteArrayOf(0x00) + body)
private fun rdn(oid: ByteArray, value: String) =
tlv(0x31, seq(oid, tlv(0x0C, value.toByteArray(Charsets.UTF_8))))
private fun utcTime(date: Date): ByteArray {
val format = SimpleDateFormat("yyMMddHHmmss", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
return tlv(0x17, (format.format(date) + "Z").toByteArray(Charsets.US_ASCII))
}
}
@@ -1,351 +0,0 @@
package app.seb3thehacker.gearslip
/**
* Pulls the video service out of a ServiceDiscoveryResponse.
*
* Shape from aasdk (`service/Service.proto`, `service/media/sink/MediaSinkService.proto`,
* `.../message/VideoConfiguration.proto`):
*
* ServiceDiscoveryResponse { repeated Service channels = 1; ... }
* Service { required int32 id = 1; optional MediaSinkService media_sink_service = 3; ... }
* MediaSinkService { optional MediaCodecType available_type = 1;
* repeated VideoConfiguration video_configs = 4; ... }
* VideoConfiguration { codec_resolution = 1; frame_rate = 2; density = 5; ... }
*
* The channel id used in the frame header is this service's `id` - real Android Auto assigns
* channel ids dynamically (aasdk's static ChannelId enum is an approximation; see the TODO
* in include/aasdk/Messenger/ChannelId.hpp).
*/
object ServiceDiscovery {
/**
* [widthMargin]/[heightMargin] are the head unit's own numbers (VideoConfiguration fields 3
* and 4): pixels of the frame it 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"
}
/** [displayId] is MediaSinkService field 6: 0, the default, is the main screen. */
class VideoService(val serviceId: Int, val codecType: Int, val configs: List<VideoConfig>, val displayId: Int = 0) {
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 = serviceId(service) ?: 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,
)
},
),
)
}
}
/**
* The media sink to stream to. Gearslip sends raw PCM, so a PCM sink wins: the 2022 Dacia
* Jogger (LGE ULC 4.5) lists an AAC media sink on channel 3 before a PCM one on channel 5,
* and PCM pushed into the AAC sink was acked but played as silence. With no PCM sink, the
* first media sink is the best there is.
*/
fun pickMediaSink(sinks: List<AudioService>): AudioService? {
val media = sinks.filter { it.streamType == STREAM_MEDIA }
return media.firstOrNull { it.codecType == CODEC_PCM } ?: media.firstOrNull()
}
/**
* The channel the car takes "now playing" on (MediaPlaybackStatusService, Service field 9),
* which feeds its cluster and media screens. It carries no settings. Null when the car has none.
*/
fun findMediaStatusChannel(response: ByteArray): Int? = findChannelWith(response, 9)
class NavStatusService(val serviceId: Int, val minimumIntervalMs: Int)
/** The cluster advertises its minimum update interval in NavigationStatusService field 1. */
fun findNavStatusService(response: ByteArray): NavStatusService? =
Wire.allBytes(Wire.fields(response), 1).firstNotNullOfOrNull { channel ->
val service = Wire.fields(channel)
val id = serviceId(service) ?: return@firstNotNullOfOrNull null
val navigation = Wire.bytes(service, 8) ?: return@firstNotNullOfOrNull null
val interval = Wire.varint(Wire.fields(navigation), 1) ?: return@firstNotNullOfOrNull null
if (interval !in 0L..Int.MAX_VALUE.toLong()) return@firstNotNullOfOrNull null
NavStatusService(id, interval.toInt())
}
private fun serviceId(fields: List<Wire.Field>): Int? =
Wire.varint(fields, 1)?.takeIf { it in 1L..255L }?.toInt()
private fun findChannelWith(response: ByteArray, serviceField: Int): Int? =
Wire.allBytes(Wire.fields(response), 1).firstNotNullOfOrNull { channel ->
val service = Wire.fields(channel)
if (Wire.bytes(service, serviceField) != null) serviceId(service) else null
}
/**
* The car's microphone, from MediaSourceService (Service field 5):
* MediaSourceService { MediaCodecType available_type = 1; AudioConfiguration audio_config = 2;
* bool available_while_in_call = 3; }
* Null when the head unit has none.
*/
fun findMicService(response: ByteArray): AudioService? {
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
val service = Wire.fields(channel)
val id = serviceId(service) ?: continue
val source = Wire.fields(Wire.bytes(service, 5) ?: continue)
val f = Wire.fields(Wire.bytes(source, 2) ?: continue)
return AudioService(
serviceId = id,
streamType = 0,
codecType = Wire.varint(source, 1)?.toInt() ?: CODEC_PCM,
configs = listOf(
AudioConfig(
sampleRate = Wire.varint(f, 1)?.toInt() ?: 16_000,
bits = Wire.varint(f, 2)?.toInt() ?: 16,
channels = Wire.varint(f, 3)?.toInt() ?: 1,
),
),
)
}
return null
}
/**
* The car's Bluetooth, from BluetoothService (Service field 6):
* BluetoothService { string car_address = 1; repeated BluetoothPairingMethod methods = 2 [packed]; }
* Null when the car offers no pairing over the link.
*/
class BluetoothService(val serviceId: Int, val carAddress: String, val pairingMethods: List<Int>)
/** A carmaker's own channel (service field 12), for its apps on the phone: GM's is "com.gm.vip". */
class VendorService(val serviceId: Int, val name: String)
fun findVendorServices(response: ByteArray): List<VendorService> =
Wire.allBytes(Wire.fields(response), 1).mapNotNull { channel ->
val service = Wire.fields(channel)
val id = Wire.varint(service, 1)?.toInt() ?: return@mapNotNull null
val fields = Wire.fields(Wire.bytes(service, 12) ?: return@mapNotNull null)
val name = Wire.bytes(fields, 1)?.let { String(it, Charsets.UTF_8) } ?: return@mapNotNull null
VendorService(id, name)
}
fun findBluetoothService(response: ByteArray): BluetoothService? {
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
val service = Wire.fields(channel)
val id = Wire.varint(service, 1)?.toInt() ?: continue
val fields = Wire.fields(Wire.bytes(service, 6) ?: continue)
val address = Wire.bytes(fields, 1)?.let { String(it, Charsets.UTF_8) } ?: continue
// Packed as declared, though a car may send each method as its own varint.
val methods = Wire.repeatedInts(fields, 2)
return BluetoothService(id, address, methods)
}
return null
}
const val STREAM_GUIDANCE = 1
const val STREAM_MEDIA = 3
const val CODEC_PCM = 1
const val CODEC_AAC_LC = 2
const val CODEC_AAC_LC_ADTS = 4
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 reports use projected video coordinates; these dimensions describe the input
* hardware and help select a video configuration. [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(),
/** InputSourceService field 5: which screen this input belongs to, 0 for the main one. */
val displayId: Int = 0,
)
/**
* The input for the screen Gearslip projects to ([displayId]). A head unit with two screens
* offers an input service for each: LIVI lists an empty one for its second screen first,
* and taking that one left every touch on the real touchscreen ignored.
*/
fun findInputService(response: ByteArray, displayId: Int = 0): InputService? {
val inputs = inputServices(response)
return inputs.firstOrNull { it.displayId == displayId }
?: inputs.firstOrNull { it.touchWidth > 0 && it.touchHeight > 0 }
?: inputs.firstOrNull()
}
private fun inputServices(response: ByteArray): List<InputService> = buildList {
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
val service = Wire.fields(channel)
val id = serviceId(service) ?: 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
val keycodes = Wire.repeatedInts(inputFields, 1)
add(InputService(id, width, height, keycodes, Wire.varint(inputFields, 5)?.toInt() ?: 0))
}
}
/**
* The main screen's video sink. A head unit with a second screen, such as LIVI with a
* cluster display, offers a sink for each; the one without a display id (0) is the main one.
*/
fun findVideoService(response: ByteArray): VideoService? {
val sinks = videoServices(response)
return sinks.firstOrNull { it.displayId == 0 } ?: sinks.firstOrNull()
}
private fun videoServices(response: ByteArray): List<VideoService> = buildList {
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
val service = Wire.fields(channel)
val id = serviceId(service) ?: 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,
)
}
add(VideoService(id, codecType, configs, Wire.varint(sinkFields, 6)?.toInt() ?: 0))
}
}
/**
* The head unit's sensor feed, from SensorSourceService (Service field 2; field 5 is the car's mic):
* SensorSourceService { repeated Sensor sensors = 1; }
* Sensor { required SensorType sensor_type = 1; }
*
* SensorBatch contains repeated messages in fields 1 through 22 matching these type ids.
* The vehicle data screen keeps their raw contents for diagnostics.
*/
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 = serviceId(service) ?: continue
val sensorSource = Wire.bytes(service, 2) ?: 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))
}
}
@@ -1,244 +0,0 @@
package app.seb3thehacker.gearslip
import android.content.Context
import app.seb3thehacker.gearslip.stats.UsageStats
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"),
}
/**
* How far a session got, in the order a connection is built. The usage note's "Where
* connections fail" share sends the furthest one reached, so a failure says where it broke.
*/
enum class Stage(val key: String) {
USB("usb"), // the link opened; the car may have said nothing
VERSIONS("versions"), // the car said which protocol it speaks
TLS("tls"), // the secure link is up
ACCEPTED("accepted"), // the car accepted the certificate
CHANNELS("channels"), // the car listed its channels and said who it is
VIDEO("video"), // the car's screen is set up for Gearslip
STREAMING("streaming"), // video frames are reaching the car
}
private var appVersion = "?"
private var appCode = 0L
@Volatile private var startedAt = 0L
@Volatile var category = Category.NONE; private set
@Volatile private var failure = ""
@Volatile var stage = Stage.USB; private set
/** What the car or the phone said when it broke: a TLS alert, an auth status, a ByeBye reason, an errno. */
@Volatile private var failCode = ""
@Volatile private var failedAfter = 0L
@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
private var appContext: Context? = null
fun init(context: Context) {
appContext = context.applicationContext
val info = runCatching { context.packageManager.getPackageInfo(context.packageName, 0) }.getOrNull()
appVersion = info?.versionName ?: "?"
appCode = info?.longVersionCode ?: 0
}
/** How this drive reached the car, and how its music did: "usb" or "wireless", "usb" or "bluetooth". */
@Volatile var link = ""; private set
@Volatile var audioMode = ""; private set
fun begin(wireless: Boolean) {
startedAt = System.currentTimeMillis()
link = if (wireless) "wireless" else "usb"
audioMode = if (app.seb3thehacker.gearslip.car.CarSettings.pipeAudio.value) "usb" else "bluetooth"
category = Category.NONE
failure = ""; protocolVersion = ""; certSource = ""; certIssuer = ""
stage = Stage.USB; failCode = ""; failedAfter = 0
tlsProtocol = ""; tlsCipher = ""; authStatus = null
headUnitInfo = null
carScreen = ""; carDpi = 0
headUnit = ""; profile = ""; video = ""; lastStream = ""; lastState = ""
printed = false
}
/** Moves the session's furthest stage on; never back. */
private fun reached(next: Stage) { if (next > stage) stage = next }
fun protocolVersion(major: Int, minor: Int) { protocolVersion = "$major.$minor"; reached(Stage.VERSIONS) }
fun certificate(source: String, issuer: String) { certSource = source; certIssuer = issuer }
fun tls(protocol: String, cipher: String) { tlsProtocol = protocol; tlsCipher = cipher; reached(Stage.TLS) }
fun auth(status: Int) { authStatus = status; if (status == 0) reached(Stage.ACCEPTED) }
fun headUnit(info: String) { headUnit = info }
/** Who the car said it is, for the opt-in usage note; null until service discovery. */
@Volatile var headUnitInfo: ServiceDiscovery.HeadUnitInfo? = null
set(value) { field = value; if (value != null) reached(Stage.CHANNELS) }
/** The car screen's usable size once its margins are cropped, and its density; blank before video starts. */
@Volatile var carScreen = ""; private set
@Volatile var carDpi = 0; private set
fun carScreen(size: String, dpi: Int) { carScreen = size; carDpi = dpi; reached(Stage.VIDEO) }
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; reached(Stage.STREAMING) }
/**
* Records the first failure only: later ones are usually fallout from it. [code] is the short
* thing the car or the phone said, like "auth -3" or "bye 1", for the usage note.
*/
fun fail(category: Category, detail: String, state: String = "", code: String = "") {
if (this.category != Category.NONE) return
this.category = category
failure = detail
lastState = state
failCode = code
failedAfter = if (startedAt == 0L) 0 else (System.currentTimeMillis() - startedAt) / 1000
}
fun fail(category: Category, detail: String, t: Throwable, state: String = "") =
fail(category, "$detail (${GearslipLog.rootCause(t)})", state, codeOf(t))
/**
* A short code from an exception, never its whole message: the TLS alert's name, the errno
* of a USB failure, or else the exception's class.
*/
private fun codeOf(t: Throwable): String {
var root = t
while (root.cause != null && root.cause !== root) root = root.cause!!
val text = "${t.message} ${root.message}"
Regex("(?i)alert[ _:]*\\(?([a-z_ ]{3,40}?)\\)?(?:\\s|$|[,;.])").find(text)?.let {
return "alert ${it.groupValues[1].trim().lowercase().replace(' ', '_')}"
}
Regex("\\b(E[A-Z]{2,12})\\b").find(text)?.let { return it.groupValues[1] }
return root.javaClass.simpleName.take(40)
}
/**
* One session for the usage note's "Where connections fail" share: how far it got, how and
* when it ended, and with which kind of certificate. Counts and codes only, nothing personal.
*/
fun sessionNote(): org.json.JSONObject {
val seconds = if (startedAt == 0L) 0 else (System.currentTimeMillis() - startedAt) / 1000
val source = certSource.lowercase()
return org.json.JSONObject()
.put("stage", stage.key)
.put("ended", category.name.lowercase())
.put("code", failCode)
.put("failed_at", if (category == Category.NONE) 0 else rounded(failedAfter))
.put("seconds", rounded(seconds))
.put("cert", when {
"imported" in source -> "imported"
"downloaded" in source -> "downloaded"
"self" in source -> "self-signed"
source.isEmpty() -> ""
else -> "other"
})
}
/** Seconds rounded so a drive's length can't be matched to anything: 5 s, then minutes, then 5 minutes. */
private fun rounded(s: Long): Long = when {
s < 60 -> (s + 2) / 5 * 5
s < 3600 -> (s + 30) / 60 * 60
else -> (s + 150) / 300 * 300
}
/** 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) }
appContext?.let { context ->
// Video started means the car showed Gearslip: that's a working car, and unplugging or
// switching the car off at the end shows up as a USB or ByeBye "failure" that isn't one.
val reached = carScreen.isNotEmpty()
val outcome = when {
reached && category in setOf(Category.NONE, Category.USB, Category.BYEBYE) -> "connected"
reached -> "connected, then ${category.label}"
category == Category.NONE -> "ended before video"
else -> category.label
}
runCatching {
UsageStats.recordSession(context, headUnitInfo, protocolVersion, outcome, carScreen, carDpi, sessionNote(), link, audioMode)
}
if (outcome == "connected") runCatching {
app.seb3thehacker.gearslip.stats.WorkedPrompt.onDriveWorked(context, headUnitInfo, protocolVersion, carScreen, carDpi)
app.seb3thehacker.gearslip.ui.DonatePrompt.onDriveWorked(context)
}
runCatching { app.seb3thehacker.gearslip.update.UpdateChecker.onDriveEnded(context) }
}
GearslipLog.flush()
}
private fun authName(status: Int) = when (status) {
0 -> "success"
-2 -> "certificate error"
-3 -> "authentication failure"
else -> "unknown"
}
}
@@ -1,46 +0,0 @@
package app.seb3thehacker.gearslip
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* What the phone UI shows about the current car session.
*
* [GearslipRunner] reports progress here as it moves through the handshake, so the UI reads
* a plain state instead of parsing log lines. A failure stays on screen until the next
* attach starts, so the reason is still there when the user picks the phone back up.
*/
object SessionStatus {
enum class Phase { IDLE, CONNECTING, PROJECTING, FAILED, DISCONNECTED }
data class Snapshot(val phase: Phase, val headline: String, val detail: String)
private val flow = MutableStateFlow(
Snapshot(
Phase.IDLE,
"Not connected",
"Plug into the car's USB port, or connect wirelessly. Gearslip opens by itself when the car answers.",
),
)
val state: StateFlow<Snapshot> = flow
fun connecting(stage: String) {
flow.value = Snapshot(Phase.CONNECTING, "Connecting", stage)
}
fun projecting() {
flow.value = Snapshot(Phase.PROJECTING, "Projecting", "Streaming to the head unit.")
}
fun failed(headline: String, detail: String) {
flow.value = Snapshot(Phase.FAILED, headline, detail)
}
/** Keeps an earlier failure visible: a rejection is followed by a disconnect every time. */
fun disconnected() {
if (flow.value.phase == Phase.FAILED) return
flow.value = Snapshot(Phase.DISCONNECTED, "Disconnected", "The head unit is no longer connected.")
}
}
@@ -1,147 +0,0 @@
package app.seb3thehacker.gearslip
import java.net.Socket
import java.nio.ByteBuffer
import java.security.SecureRandom
import java.security.cert.X509Certificate
import javax.net.ssl.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.X509ExtendedTrustManager
/** Checks mutual TLS and an encrypted data round trip with an in-process head-unit peer. */
object TlsSelfTest {
private val log = GearslipLog.tagged("TLS")
fun run(identity: CertProvider.Identity, withClientCertificate: Boolean = true): 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(withClientCertificate))
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
}
// Exercise the same encrypt path used by 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(withCertificate: Boolean): SSLEngine {
val context = SSLContext.getInstance("TLSv1.2")
val keyManagers = if (withCertificate) {
val client = SelfSignedCert.generate(commonName = "Self-test head unit")
KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()).apply {
init(client.keyStore, SelfSignedCert.PASSWORD)
}.keyManagers
} else null
context.init(keyManagers, arrayOf(TrustAll()), SecureRandom())
return context.createSSLEngine().apply {
useClientMode = true
enabledProtocols = arrayOf("TLSv1.2")
beginHandshake()
}
}
/** Deliberately a second, independent implementation - it should not share PhoneTls's bugs. */
private class Driver(private val engine: SSLEngine) {
private var netIn = ByteArray(0)
private var app = ByteBuffer.allocate(32 * 1024)
private var net = ByteBuffer.allocate(32 * 1024)
fun pump(incoming: ByteArray?): List<ByteArray> {
if (incoming != null) netIn += incoming
val out = mutableListOf<ByteArray>()
val source = ByteBuffer.wrap(netIn)
loop@ while (true) {
when (engine.handshakeStatus) {
HandshakeStatus.NEED_TASK -> {
var task = engine.delegatedTask
while (task != null) { task.run(); task = engine.delegatedTask }
}
HandshakeStatus.NEED_WRAP -> {
net.clear()
val r = engine.wrap(ByteBuffer.allocate(0), net)
if (r.status == SSLEngineResult.Status.BUFFER_OVERFLOW) {
net = ByteBuffer.allocate(net.capacity() * 2); continue@loop
}
net.flip()
if (net.hasRemaining()) out += ByteArray(net.remaining()).also { net.get(it) }
if (r.status == SSLEngineResult.Status.CLOSED) break@loop
}
HandshakeStatus.NEED_UNWRAP -> {
app.clear()
val r = engine.unwrap(source, app)
when (r.status) {
SSLEngineResult.Status.BUFFER_UNDERFLOW -> break@loop
SSLEngineResult.Status.BUFFER_OVERFLOW -> {
app = ByteBuffer.allocate(app.capacity() * 2); continue@loop
}
SSLEngineResult.Status.CLOSED -> break@loop
else -> Unit
}
}
else -> break@loop
}
}
netIn = ByteArray(source.remaining()).also { source.get(it) }
return out
}
fun decrypt(cipher: ByteArray): ByteArray {
val source = ByteBuffer.wrap(cipher)
val out = java.io.ByteArrayOutputStream()
while (source.hasRemaining()) {
app.clear()
val r = engine.unwrap(source, app)
if (r.status == SSLEngineResult.Status.BUFFER_OVERFLOW) {
app = ByteBuffer.allocate(app.capacity() * 2); continue
}
app.flip()
out.write(ByteArray(app.remaining()).also { app.get(it) })
if (r.bytesConsumed() == 0 && r.bytesProduced() == 0) break
}
return out.toByteArray()
}
}
private class TrustAll : X509ExtendedTrustManager() {
override fun checkClientTrusted(c: Array<out X509Certificate>?, a: String?) = Unit
override fun checkClientTrusted(c: Array<out X509Certificate>?, a: String?, s: Socket?) = Unit
override fun checkClientTrusted(c: Array<out X509Certificate>?, a: String?, e: SSLEngine?) = Unit
override fun checkServerTrusted(c: Array<out X509Certificate>?, a: String?) = Unit
override fun checkServerTrusted(c: Array<out X509Certificate>?, a: String?, s: Socket?) = Unit
override fun checkServerTrusted(c: Array<out X509Certificate>?, a: String?, e: SSLEngine?) = Unit
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
}
}
@@ -1,114 +0,0 @@
package app.seb3thehacker.gearslip
import android.content.Context
import java.io.File
import org.json.JSONArray
import org.json.JSONObject
/** Pixels of the video frame the car UI keeps clear on each side (the frame is painted, just not used). */
data class Insets(val top: Int = 0, val bottom: Int = 0, val left: Int = 0, val right: Int = 0) {
companion object { val NONE = Insets() }
}
/**
* Per-vehicle display tuning. Head units disagree about how they crop, offset and scale the
* projected frame, so nothing here is applied unless a profile matches the connected unit.
*
* Profiles live in `vehicles.json` in the app's external files dir, so they can be edited and
* pushed with adb without a rebuild:
*
* adb push vehicles.json /sdcard/Android/data/app.seb3thehacker.gearslip/files/vehicles.json
*
* A profile matches when every key in [match] equals the head unit's value (case-insensitive);
* keys it leaves out match anything. The first matching profile wins.
*/
data class VehicleProfile(
val name: String,
val match: Map<String, String>,
/** Preferred video resolution, e.g. "800x480". Null keeps the default choice. */
val resolution: String?,
val insets: Insets,
)
object VehicleProfiles {
private const val FILE = "vehicles.json"
private val DEFAULT_JSON = """
{
"_comment": "Per-vehicle display tuning. match keys: headUnitName, carModel, carYear, headUnitMake, headUnitModel (see the log line 'head unit: ...'). insets are pixels of the video frame to keep clear.",
"vehicles": [
{
"name": "2018 Dodge Challenger (Uconnect 4)",
"match": { "carModel": "Dodge Challenger", "headUnitName": "Uconnect" },
"resolution": "800x480",
"insets": { "top": 30, "bottom": 30, "left": 0, "right": 0 }
}
]
}
""".trimIndent()
/** Reads the file, writing the default one first if there is none so there is something to edit. */
fun load(context: Context): List<VehicleProfile> {
val file = context.getExternalFilesDir(null)?.let { File(it, FILE) } ?: return emptyList()
if (!file.isFile) runCatching { file.writeText(DEFAULT_JSON) }
return runCatching { parse(file.readText()) }
.onFailure { GearslipLog.e("could not read $FILE - ignoring vehicle profiles", it) }
.getOrDefault(emptyList())
}
/** Writes [insets] into the profile called [name] in the file, leaving everything else as it was. */
fun saveInsets(context: Context, name: String, insets: Insets): Boolean {
val file = context.getExternalFilesDir(null)?.let { File(it, FILE) } ?: return false
return runCatching {
val root = JSONObject(file.readText())
val vehicles = root.getJSONArray("vehicles")
var found = false
for (i in 0 until vehicles.length()) {
val v = vehicles.getJSONObject(i)
if (v.optString("name") == name) {
v.put(
"insets",
JSONObject()
.put("top", insets.top).put("bottom", insets.bottom)
.put("left", insets.left).put("right", insets.right),
)
found = true
}
}
if (found) file.writeText(root.toString(2))
found
}.onFailure { GearslipLog.e("could not save insets to $FILE", it) }.getOrDefault(false)
}
fun parse(json: String): List<VehicleProfile> {
val vehicles: JSONArray = JSONObject(json).optJSONArray("vehicles") ?: return emptyList()
return (0 until vehicles.length()).map { i ->
val v = vehicles.getJSONObject(i)
val match = v.optJSONObject("match")?.let { m -> m.keys().asSequence().associateWith { m.getString(it) } }
?: emptyMap()
val ins = v.optJSONObject("insets")
VehicleProfile(
name = v.optString("name", "vehicle $i"),
match = match,
resolution = v.optString("resolution").ifEmpty { null },
insets = if (ins == null) Insets.NONE else Insets(
ins.optInt("top"), ins.optInt("bottom"), ins.optInt("left"), ins.optInt("right"),
),
)
}
}
fun find(profiles: List<VehicleProfile>, info: ServiceDiscovery.HeadUnitInfo): VehicleProfile? {
val values = mapOf(
"headUnitName" to info.headUnitName,
"carModel" to info.carModel,
"carYear" to info.carYear,
"headUnitMake" to info.headUnitMake,
"headUnitModel" to info.headUnitModel,
)
return profiles.firstOrNull { p ->
p.match.isNotEmpty() && p.match.all { (k, want) -> values[k].equals(want, ignoreCase = true) }
}
}
}
@@ -1,316 +0,0 @@
package app.seb3thehacker.gearslip
import android.media.MediaCodec
import android.media.MediaCodecInfo
import android.media.MediaFormat
import android.os.Bundle
import android.media.Image
import android.view.Surface
/**
* Encodes a generated test card to H.264 Baseline and hands the bitstream to a callback.
*
* Feeds the encoder YUV420 buffers via getInputImage() rather than a Surface: a MediaCodec
* input Surface requires an EGL/OpenGL context to render into (Surface.lockCanvas is not
* supported on it), and for a static test card that is a lot of machinery for no benefit.
* Byte-buffer input keeps the spike dependency-free and fully deterministic. Phase C swaps
* this for a real Surface + GL path when the source becomes a live WebView.
*
* The head unit negotiated MEDIA_CODEC_VIDEO_H264_BP, so the profile is pinned to Baseline.
*/
class VideoSource(
private val width: Int,
private val height: Int,
private val frameRate: Int,
private val mode: Mode = Mode.TEST_CARD,
/** Over Wi-Fi: a fraction of the cable bitrate, so the link keeps up and sound isn't starved. */
private val wireless: Boolean = false,
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
/** The bitrate the encoder started at, the most [setBitRate] ever goes back up to. */
var fullBitRate = 0
private set
/** What the encoder is asked for right now. */
@Volatile var bitRate = 0
private set
private var worker: Thread? = null
@Volatile private var running = false
private var frameIndex = 0L
fun start() {
val level = levelFor(width, height, frameRate)
val bitRate = defaultBitRate(width, height, frameRate, level).let {
if (wireless) (it * WIRELESS_SHARE).toInt().coerceAtMost(WIRELESS_MAX_BIT_RATE) else it
}
fullBitRate = bitRate
this.bitRate = bitRate
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)
if (mode == Mode.SURFACE) {
// A surface encoder only makes a frame when the screen changes, and many decoders
// show a frame only once the next one arrives. So the last change before the
// screen goes still (a menu closing) sat in the car's decoder until something
// else moved. Repeating the last frame after a short pause pushes it through;
// a repeat of an unchanged screen costs a few bytes. scrcpy does the same.
setLong(MediaFormat.KEY_REPEAT_PREVIOUS_FRAME_AFTER, REPEAT_AFTER_US)
// An app that draws nonstop (a moving map) renders at the phone's refresh rate,
// faster than the car acks. Every extra frame queued inside the encoder, and the
// car's screen fell 25 seconds behind. Capping the input drops the extras before
// they're encoded, so the car always gets the newest frame.
setFloat(MediaFormat.KEY_MAX_FPS_TO_ENCODER, frameRate.toFloat())
}
setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline)
setInteger(MediaFormat.KEY_LEVEL, level.codecLevel)
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${if (wireless) " (wireless)" else ""}, H.264 Baseline level ${level.label}, 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}") }
}
/**
* Changes the bitrate while streaming, without a new keyframe or a restart. Used to back off
* when the car (or the Wi-Fi to it) can't keep up, and to climb back once it can.
*/
fun setBitRate(bps: Int) {
val target = bps.coerceIn(fullBitRate / 4, fullBitRate)
if (target == bitRate) return
runCatching {
codec?.setParameters(Bundle().apply { putInt(MediaCodec.PARAMETER_KEY_VIDEO_BITRATE, target) })
bitRate = target
}.onFailure { log.w("setBitRate failed: ${it.message}") }
}
fun stop() {
running = false
// Stop is called from threads that are themselves being shut down. An interrupt here
// used to escape as an uncaught exception and take the whole app down with the session.
try {
worker?.join(1_000)
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
}
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
/**
* 800x480 at 30fps gets 8 Mbps: about the most that size can show, enough that a moving
* video in the browser stays sharp. Bigger screens get more, but less than in proportion,
* since large frames compress better: bitrate grows with pixels^0.75. 60fps gets 1.5x,
* because frames that close together are mostly alike.
*/
const val BASE_BIT_RATE = 8_000_000.0
const val BASE_PIXELS = 800.0 * 480
/** No car's decoder is asked for more than this, whatever its level allows. */
const val MAX_BIT_RATE = 25_000_000
/**
* The H.264 levels a car screen can need, smallest first: macroblocks a second each
* decodes, and the Baseline bitrate it must handle. The encoder declares the smallest
* that fits, and the bitrate stays within it, so a car decoder isn't asked for more than
* the level it was told about.
*/
enum class Level(val label: String, val codecLevel: Int, val macroblocksPerSecond: Int, val maxBitRate: Int) {
L31("3.1", MediaCodecInfo.CodecProfileLevel.AVCLevel31, 108_000, 14_000_000),
L32("3.2", MediaCodecInfo.CodecProfileLevel.AVCLevel32, 216_000, 20_000_000),
L4("4", MediaCodecInfo.CodecProfileLevel.AVCLevel4, 245_760, 20_000_000),
L42("4.2", MediaCodecInfo.CodecProfileLevel.AVCLevel42, 522_240, 50_000_000),
}
fun levelFor(width: Int, height: Int, frameRate: Int): Level {
val perSecond = ((width + 15) / 16) * ((height + 15) / 16) * frameRate
return Level.entries.firstOrNull { perSecond <= it.macroblocksPerSecond } ?: Level.L42
}
fun defaultBitRate(width: Int, height: Int, frameRate: Int, level: Level): Int {
val size = Math.pow(width.toDouble() * height / BASE_PIXELS, 0.75)
val rate = if (frameRate > 30) 1.5 else 1.0
return (BASE_BIT_RATE * size * rate).toInt().coerceAtMost(minOf(level.maxBitRate, MAX_BIT_RATE))
}
/**
* A car's Wi-Fi is shared, noisy and far slower than its nominal rate, and the sound rides
* the same link behind the video. Real Android Auto sends a few Mbps wirelessly; at the
* cable rate (15-20 Mbps for 720p) a laptop hotspot fell seconds behind and the music
* cut in and out. 720p30 comes out near 5.4 Mbps.
*/
const val WIRELESS_SHARE = 0.35
const val WIRELESS_MAX_BIT_RATE = 8_000_000
const val I_FRAME_INTERVAL_S = 5
private const val REPEAT_AFTER_US = 100_000L
/** (Y, Cb, Cr) - white, yellow, cyan, green, magenta, red, blue. */
val BAR_COLOURS = listOf(
Triple(180, 128, 128),
Triple(162, 44, 142),
Triple(131, 156, 44),
Triple(112, 72, 58),
Triple(84, 184, 198),
Triple(65, 100, 212),
Triple(35, 212, 114),
)
}
}
@@ -1,94 +0,0 @@
package app.seb3thehacker.gearslip.audio
import android.media.MediaCodec
import android.media.MediaCodecInfo
import android.media.MediaFormat
/**
* Turns captured PCM into AAC-LC for a car whose music channel takes nothing else. Cars that
* offer PCM never get here: AAC costs a little delay and quality, and every car that works today
* is a PCM car.
*
* Android's encoder hands back raw access units, which is what AUDIO_AAC_LC expects. For
* AUDIO_AAC_LC_ADTS each unit gets the 7-byte ADTS header in front, so the car can find the
* frame edges itself. The encoder's first output is its configuration, not audio, and is skipped.
*/
internal class AacEncoder(private val sampleRate: Int, private val channels: Int, private val adts: Boolean) {
private val codec: MediaCodec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC).apply {
val format = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_AAC, sampleRate, channels).apply {
setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC)
setInteger(MediaFormat.KEY_BIT_RATE, if (channels == 1) 96_000 else 192_000)
setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 16 * 1024)
}
configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
start()
}
private val info = MediaCodec.BufferInfo()
private var samplesIn = 0L
/** Encodes [size] bytes of 16-bit PCM and returns the AAC frames that came out, oldest first. */
fun encode(pcm: ByteArray, size: Int): List<ByteArray> {
var offset = 0
val out = ArrayList<ByteArray>()
while (offset < size) {
val index = codec.dequeueInputBuffer(INPUT_WAIT_US)
if (index < 0) {
drain(out)
continue
}
val buffer = codec.getInputBuffer(index) ?: continue
val n = minOf(buffer.remaining(), size - offset)
buffer.put(pcm, offset, n)
val presentationUs = samplesIn * 1_000_000L / sampleRate
codec.queueInputBuffer(index, 0, n, presentationUs, 0)
samplesIn += n / (2 * channels)
offset += n
drain(out)
}
return out
}
private fun drain(out: MutableList<ByteArray>) {
while (true) {
val index = codec.dequeueOutputBuffer(info, 0)
if (index < 0) return
if (info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG == 0 && info.size > 0) {
val buffer = codec.getOutputBuffer(index)
if (buffer != null) {
val frame = ByteArray(info.size)
buffer.position(info.offset)
buffer.get(frame)
out.add(if (adts) adtsHeader(frame.size) + frame else frame)
}
}
codec.releaseOutputBuffer(index, false)
}
}
/** ADTS for one AAC-LC frame: sync word, no CRC, and the frame's length including this header. */
private fun adtsHeader(payload: Int): ByteArray {
val length = payload + 7
val freq = FREQUENCIES.indexOf(sampleRate).let { if (it < 0) 3 else it }
val profile = 1 // AAC-LC is object type 2, written as type minus one
return byteArrayOf(
0xFF.toByte(),
0xF1.toByte(),
((profile shl 6) or (freq shl 2) or (channels shr 2)).toByte(),
(((channels and 3) shl 6) or (length shr 11)).toByte(),
((length shr 3) and 0xFF).toByte(),
(((length and 7) shl 5) or 0x1F).toByte(),
0xFC.toByte(),
)
}
fun release() {
runCatching { codec.stop() }
runCatching { codec.release() }
}
private companion object {
const val INPUT_WAIT_US = 10_000L
val FREQUENCIES = listOf(96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350)
}
}
@@ -1,272 +0,0 @@
package app.seb3thehacker.gearslip.audio
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.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 android.os.Process
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({
// A busy phone (a map app drawing, the video encoder) would otherwise starve this
// loop, and the car hears every late read as a skip.
Process.setThreadPriority(Process.THREAD_PRIORITY_URGENT_AUDIO)
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))
}
}
}
@@ -1,394 +0,0 @@
package app.seb3thehacker.gearslip.audio
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.Protobuf
import app.seb3thehacker.gearslip.ServiceDiscovery
import app.seb3thehacker.gearslip.Wire
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* 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,
/** Log name: "audio" for the media sink, "guidance" for the directions sink. */
private val tag: String = "audio",
private val sessionId: Int = SESSION_ID,
/**
* Media asks the car for focus when it starts. Directions ask only around each announcement,
* through [askForSpeakers], and only when the music isn't already ours.
*/
private val asksFocus: Boolean = true,
private val onReady: (AudioLink) -> Unit = { CarAudio.attach(it) },
private val onClose: (AudioLink) -> Unit = { CarAudio.detach(it) },
/** The car took the sound back after giving it, as when the driver switches to the radio. */
private val onTakenBack: () -> 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 stalls = 0
private var chunks = 0
private var bytes = 0L
private var dropped = 0
@Volatile private var configTimer: Thread? = null
private val ackLock = Object()
/** Only for a sink that takes AAC and nothing else; PCM sinks get the capture as it is. */
@Volatile private var aac: AacEncoder? = null
private val aacSink get() = service.codecType == ServiceDiscovery.CODEC_AAC_LC || service.codecType == ServiceDiscovery.CODEC_AAC_LC_ADTS
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
/** Sending audio now, which for media means the car has handed us the speakers. */
val streaming get() = started
@Volatile private var holdsSpeakers = false
@Volatile private var focusAnswer: CountDownLatch? = null
@Volatile private var answerDueBy = 0L
/** True just after [askForSpeakers] or [handBackSpeakers], when the car's next focus answer is ours. */
fun expectsFocusAnswer() = System.currentTimeMillis() < answerDueBy
/**
* For directions when nothing of ours is playing, so the car's speakers belong to the radio
* or a Bluetooth phone. Android Auto asks for them before each announcement, letting the
* other source duck, and hands them back afterwards. Speaking without asking left a 2025
* Nissan's Bosch head unit rebooting mid-drive (issue #35).
*
* Waits briefly for the car's answer and speaks anyway without one, as some cars never answer.
*/
fun askForSpeakers() {
if (holdsSpeakers) return
val answer = CountDownLatch(1)
focusAnswer = answer
answerDueBy = System.currentTimeMillis() + FOCUS_WAIT_MS
holdsSpeakers = true
sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_GAIN_TRANSIENT_MAY_DUCK.toLong()))
GearslipLog.i("$tag: -> AudioFocusRequest(GAIN_TRANSIENT_MAY_DUCK)")
if (!answer.await(FOCUS_WAIT_MS, TimeUnit.MILLISECONDS)) {
GearslipLog.i("$tag: no focus answer after ${FOCUS_WAIT_MS}ms - speaking anyway")
}
focusAnswer = null
}
/** Gives back what [askForSpeakers] took, so the other source comes back to full volume. */
fun handBackSpeakers() {
if (!holdsSpeakers) return
holdsSpeakers = false
answerDueBy = System.currentTimeMillis() + FOCUS_WAIT_MS
runCatching { sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_RELEASE.toLong())) }
GearslipLog.i("$tag: -> AudioFocusRequest(RELEASE)")
}
fun opened(status: Int) {
GearslipLog.i("$tag: <- ChannelOpenResponse: status=$status")
if (status != 0) {
GearslipLog.w("$tag: the head unit refused the $tag channel (status $status)")
return
}
// Setup { required MediaCodecType type = 1 }: the codec the car advertised for this sink
// (aasdk's current protos, and LIVI reads it as the codec). Gearslip used to send 0 here,
// read from an older proto as a config index; 0 is no codec at all, and the 2024 VW
// answered UNEXPECTED_MESSAGE (id 255), never acked a chunk and played nothing.
sendOnChannel(MSG_SETUP, Protobuf.varintField(1, service.codecType.toLong()))
GearslipLog.i("$tag: -> Setup(codec=${service.codecName}) on channel ${service.serviceId}")
armConfigFallback()
}
/**
* The 2018 Uconnect, a Dacia ULC 4.5 and the 2024 VW answered the old Setup (codec 0) with
* UNEXPECTED_MESSAGE (id 255, no body) rather than a Config, so the sink never reached READY.
* [MSG_SETUP_ANSWER] still takes that as the go-ahead, in case a car rejects the real codec too.
*
* Service discovery already told us everything a Config would have: the sink's rate, channels
* and its one configuration. So for a car that answers nothing at all, wait a moment and then
* assume the advertised configuration. 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
}
useAdvertisedSink("no usable Config after ${CONFIG_WAIT_MS}ms")
}, "gearslip-audio-config").apply { isDaemon = true }
configTimer = timer
timer.start()
}
@Synchronized
private fun useAdvertisedSink(why: String) {
if (ready) return
configTimer?.interrupt()
GearslipLog.i(
"$tag: $why - using the sink the head unit advertised " +
"(${sampleRate}Hz x$channels, config_index=0, max_unacked=$maxUnacked)",
)
configIndex = 0
ready = true
onReady(this)
}
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("$tag: <- 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("$tag: sink ready - ${sampleRate}Hz x$channels ${config?.bits}bit")
onReady(this)
} else {
GearslipLog.w(
"$tag: 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 else the head unit answers Setup with, we need its bytes to identify it.
else -> if (messageId == MSG_SETUP_ANSWER && body.isEmpty()) {
useAdvertisedSink("<- empty Setup answer (id $messageId)")
} else {
GearslipLog.i("$tag: <- 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("$tag: <- AudioFocusResponse state=$name")
if (!asksFocus) {
// Directions speak whatever the answer: a lost turn is worse than a loud one.
answerDueBy = 0L
focusAnswer?.countDown()
return
}
val had = focusGranted
focusGranted = state == FOCUS_STATE_GAIN || state == FOCUS_STATE_GAIN_TRANSIENT ||
state == FOCUS_STATE_GAIN_MEDIA_ONLY
if (focusGranted) startStream() else stop()
// A ducking request leaves the music playing; a loss means another source has the speakers.
if (had && (state == FOCUS_STATE_LOSS || state == FOCUS_STATE_LOSS_TRANSIENT)) onTakenBack()
}
override fun start() {
if (!ready || started) return
if (asksFocus && !focusRequested) {
focusRequested = true
sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_GAIN.toLong()))
GearslipLog.i("$tag: -> 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()
}
/**
* Guards starting and stopping. [start] starts the stream at once for head units that never
* answer the focus request, and the car's answer arrives on another thread and starts it
* too: without this both got through, so the car got Start twice and two AAC encoders, and a
* 2014 head unit dropped the connection on the first audio.
*/
private val startLock = Any()
private fun startStream() {
synchronized(startLock) { startStreamLocked() }
}
private fun startStreamLocked() {
if (!ready || started) return
sendOnChannel(
MSG_START,
Protobuf.varintField(1, sessionId.toLong()) + Protobuf.varintField(2, configIndex.toLong()),
)
inFlight = 0
lastAckAt = System.currentTimeMillis()
if (aacSink && aac == null) {
aac = runCatching { AacEncoder(sampleRate, channels, adts = service.codecType == ServiceDiscovery.CODEC_AAC_LC_ADTS) }
.onSuccess { GearslipLog.i("$tag: the car takes ${service.codecName} only - encoding to AAC") }
.onFailure { GearslipLog.e("$tag: couldn't start the AAC encoder", it) }
.getOrNull()
}
started = true
GearslipLog.i("$tag: -> Start(session=$sessionId, config=$configIndex) - streaming ${sampleRate}Hz x$channels")
}
override fun stop() {
synchronized(startLock) { stopLocked() }
}
private fun stopLocked() {
if (!started) return
started = false
synchronized(ackLock) { ackLock.notifyAll() }
runCatching {
sendOnChannel(MSG_STOP, Protobuf.varintField(1, sessionId.toLong()))
if (focusRequested) sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_RELEASE.toLong()))
}
focusRequested = false
focusGranted = false
aac?.release()
aac = null
GearslipLog.i("$tag: -> Stop, AudioFocusRequest(RELEASE) after $chunks chunks / $bytes bytes")
chunks = 0
bytes = 0
}
override fun write(pcm: ByteArray, size: Int) {
if (!started) return
val encoder = aac
if (encoder == null) {
send(pcm.copyOf(size))
return
}
val frames = try {
encoder.encode(pcm, size)
} catch (e: Exception) {
// A codec that dies mid-drive leaves the car silent, as it was before AAC; log it once.
GearslipLog.e("$tag: AAC encoding failed - no more audio this drive", e)
aac = null
started = false
return
}
frames.forEach(::send)
}
/** One Data message, once the car has room for it. */
private fun send(payload: ByteArray) {
// 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.
// A car that never acks (the 2024 VW, the Bolt) hits this every chunk: say so
// once, then now and then, so the rest of the log stays readable.
if (stalls++ % 500 == 0) {
GearslipLog.w("$tag: no ack for ${STALL_MS}ms with $inFlight in flight - reopening the window ($stalls times)")
}
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 + payload)
} catch (e: java.io.IOException) {
GearslipLog.e("$tag: the link died mid-stream", e)
started = false
return
}
synchronized(ackLock) { inFlight++ }
chunks++
bytes += payload.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(
"$tag: -> $chunks chunks / $bytes bytes, inFlight=$inFlight/$maxUnacked, " +
"dropped=$dropped, focus=${if (focusGranted) "granted" else "not granted"}",
)
}
}
fun close() {
configTimer?.interrupt()
configTimer = null
stop()
handBackSpeakers()
ready = false
onClose(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 MSG_SETUP_ANSWER = 255
private const val STATUS_READY = 2
private const val FOCUS_GAIN = 1
private const val FOCUS_GAIN_TRANSIENT_MAY_DUCK = 3
private const val FOCUS_RELEASE = 4
private const val FOCUS_WAIT_MS = 500L
private const val FOCUS_STATE_GAIN = 1
private const val FOCUS_STATE_GAIN_TRANSIENT = 2
private const val FOCUS_STATE_GAIN_MEDIA_ONLY = 6
private const val FOCUS_STATE_LOSS = 3
private const val FOCUS_STATE_LOSS_TRANSIENT = 5
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 = 1_000L
// AudioFocusStateNotification.AudioFocusState in aasdk.
private val FOCUS_STATES = mapOf(
1 to "GAIN", 2 to "GAIN_TRANSIENT", 3 to "LOSS", 4 to "LOSS_TRANSIENT_CAN_DUCK",
5 to "LOSS_TRANSIENT", 6 to "GAIN_MEDIA_ONLY", 7 to "GAIN_TRANSIENT_GUIDANCE_ONLY",
)
}
}
@@ -1,142 +0,0 @@
package app.seb3thehacker.gearslip.audio
import app.seb3thehacker.gearslip.GearslipLog
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Joins the two ends of the audio path: [AudioCaptureService] on the phone, which hears what the
* phone is playing, and the car's audio channel, which is where it has to end up.
*
* Capture belongs to the car session, not to any one app. Android grants it through a consent
* dialog that cannot be answered in advance or remembered, so Gearslip asks once, when a head unit
* first offers a sink to play through, and then holds that grant for as long as the car is
* connected. Switching between media apps, or starting playback from the phone, costs nothing
* further: the capture is by audio usage rather than by app, so whatever the phone plays as media
* goes to the car.
*/
object CarAudio {
/** What the head unit's media sink accepts, and how to hand it audio. */
interface Link {
val sampleRate: Int
val channels: Int
fun start()
fun stop()
fun write(pcm: ByteArray, size: Int)
}
enum class Capture { OFF, ASKING, ON, DENIED }
private val _capture = MutableStateFlow(Capture.OFF)
val capture: StateFlow<Capture> = _capture.asStateFlow()
/** "-12 dB" style summary of what was last heard, for the media screen. */
private val _level = MutableStateFlow(0f)
val level: StateFlow<Float> = _level.asStateFlow()
/** Set by the activity: shows the permission and consent dialogs, which only it can. */
var onConsentRequested: (() -> Unit)? = null
/** Whether the driver has asked for the car to be fed at all; see CarSettings.pipeAudio. */
@Volatile private var wanted = false
@Volatile private var link: Link? = null
/** Opens the car's media channel, left closed while music went over Bluetooth; null once open. */
@Volatile var openSink: (() -> Unit)? = 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) {
openSink?.invoke()
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
}
private val _micBlocked = MutableStateFlow(false)
/** The microphone permission is off for good: Android refuses without asking, so say why. */
val micBlocked: StateFlow<Boolean> = _micBlocked.asStateFlow()
fun micBlocked() {
_micBlocked.value = true
denied()
}
/** The driver turned the permission back on mid-drive: ask for capture again. */
fun micAllowed() {
if (!_micBlocked.value) return
_micBlocked.value = false
if (_capture.value == Capture.DENIED) {
_capture.value = Capture.OFF
if (wanted) request()
}
}
internal fun captureStarted() {
_capture.value = Capture.ON
link?.start()
}
internal fun captureStopped() {
link?.stop()
_level.value = 0f
if (_capture.value == Capture.ON) _capture.value = Capture.OFF
}
internal fun deliver(pcm: ByteArray, size: Int, peak: Float) {
_level.value = peak
link?.write(pcm, size)
}
}
@@ -1,136 +0,0 @@
package app.seb3thehacker.gearslip.audio
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.Protobuf
import app.seb3thehacker.gearslip.ServiceDiscovery
import app.seb3thehacker.gearslip.Wire
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
/**
* The car's own microphone, which most head units offer over the same link as the screen:
*
* -> CHANNEL_OPEN_REQUEST <- CHANNEL_OPEN_RESPONSE
* -> Setup { config_index } <- Config
* -> VoiceSessionNotification { START } (control channel)
* -> MicrophoneRequest { open } <- MicrophoneResponse { status, session_id }
* <- Data { 8-byte timestamp, PCM }... -> Ack { session_id, ack }
* -> MicrophoneRequest { open = false }
* -> VoiceSessionNotification { END }
*
* The channel opens with the session, but the car only records between [start] and [stop], and
* only inside a voice session: the 2024 VW answered a bare MicrophoneRequest with status -7.
* Speech wants 16 kHz mono; a car that records at a multiple of that is averaged down to it, and
* one that doesn't isn't used, so voice falls back to the phone's microphone.
*/
class CarMic(
private val service: ServiceDiscovery.AudioService,
private val sendOnChannel: (messageId: Int, body: ByteArray) -> Unit,
private val voiceSession: (start: Boolean) -> Unit,
) {
val channelId get() = service.serviceId
private val config = service.configs.first()
private val step = config.sampleRate / RATE
private val usable = service.codecType == ServiceDiscovery.CODEC_PCM && config.bits == 16 &&
config.sampleRate % RATE == 0 && step >= 1 && config.channels in 1..2
@Volatile private var ready = false
@Volatile private var listening = false
@Volatile private var session = 0
private var packets = 0
private val chunks = LinkedBlockingQueue<ShortArray>()
fun onMessage(messageId: Int, body: ByteArray) {
when (messageId) {
MSG_CHANNEL_OPEN_RESPONSE -> {
val status = Protobuf.readInt32Field(body, 1) ?: 0
GearslipLog.i("mic: <- ChannelOpenResponse: status=$status")
if (status != 0) return
// Setup { type = the codec }, as for audio: 0 got UNEXPECTED_MESSAGE from the VW.
sendOnChannel(MSG_SETUP, Protobuf.varintField(1, service.codecType.toLong()))
ready = usable
if (!usable) {
GearslipLog.w(
"mic: the car records ${service.codecName} ${config.sampleRate}Hz/${config.bits}bit/x${config.channels}, " +
"which speech can't use - voice stays on the phone's microphone",
)
}
}
MSG_CONFIG -> GearslipLog.i("mic: <- Config status=${Wire.varint(Wire.fields(body), 1)}")
MSG_MIC_RESPONSE -> {
val fields = Wire.fields(body)
session = Wire.varint(fields, 2)?.toInt() ?: 0
GearslipLog.i("mic: <- MicrophoneResponse status=${Wire.varint(fields, 1)} session=$session")
}
MSG_DATA -> {
sendOnChannel(MSG_ACK, Protobuf.varintField(1, session.toLong()) + Protobuf.varintField(2, 1L))
if (!listening || body.size <= TIMESTAMP_BYTES) return
if (packets++ == 0) GearslipLog.i("mic: first audio from the car (${body.size - TIMESTAMP_BYTES} bytes)")
chunks.offer(toSpeechRate(body))
}
else -> GearslipLog.i("mic: <- unhandled message id=$messageId (${body.size} bytes)")
}
}
/** Little-endian PCM after the timestamp, mixed to mono and averaged down to [RATE]. */
private fun toSpeechRate(body: ByteArray): ShortArray {
val frameBytes = 2 * config.channels
val frames = (body.size - TIMESTAMP_BYTES) / frameBytes
val out = ShortArray(frames / step)
for (i in out.indices) {
var sum = 0
for (s in 0 until step) {
val at = TIMESTAMP_BYTES + (i * step + s) * frameBytes
for (c in 0 until config.channels) {
val o = at + 2 * c
sum += (body[o].toInt() and 0xFF) or (body[o + 1].toInt() shl 8)
}
}
out[i] = (sum / (step * config.channels)).toShort()
}
return out
}
/** Asks the car to start recording. False when it can't, so the phone's microphone is used. */
fun start(): Boolean {
if (!ready) return false
chunks.clear()
packets = 0
listening = true
voiceSession(true)
sendOnChannel(MSG_MIC_REQUEST, Protobuf.varintField(1, 1L) + Protobuf.varintField(4, 1L))
GearslipLog.i("mic: -> MicrophoneRequest(open)")
return true
}
/** The next piece of audio, or null if the car sent nothing for [timeoutMs]. */
fun read(timeoutMs: Long): ShortArray? = chunks.poll(timeoutMs, TimeUnit.MILLISECONDS)
fun stop() {
if (!listening) return
listening = false
sendOnChannel(MSG_MIC_REQUEST, Protobuf.varintField(1, 0L))
voiceSession(false)
GearslipLog.i("mic: -> MicrophoneRequest(close) after $packets packets, voice session ended")
}
companion object {
/** The connected car's microphone, or null with no car or one that has none. */
@Volatile var current: CarMic? = null
const val RATE = 16_000
private const val TIMESTAMP_BYTES = 8
private const val MSG_CHANNEL_OPEN_RESPONSE = 8
private const val MSG_DATA = 0
private const val MSG_SETUP = 32768
private const val MSG_CONFIG = 32771
private const val MSG_ACK = 32772
private const val MSG_MIC_REQUEST = 32773
private const val MSG_MIC_RESPONSE = 32774
}
}
@@ -1,64 +0,0 @@
package app.seb3thehacker.gearslip.audio
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.provider.Settings
import app.seb3thehacker.gearslip.GearslipLog
/**
* Keeps the phone's notifications readable while audio is being sent to the car.
*
* Playback capture hangs off a MediaProjection, and since Android 15 any full-screen projection
* makes SystemUI hide notification contents, on the assumption that someone is watching the
* screen. Nobody is: the audio projection never makes a display. The one exemption an ordinary app
* can reach is the developer setting that switches the protection off, so Gearslip turns it off for
* the length of the capture and back on afterwards.
*
* Writing it needs WRITE_SECURE_SETTINGS, granted once over adb. Without the grant this does
* nothing and notifications stay hidden while music plays, which is the platform's default.
*
* Phone mirroring deliberately does not use this: there the screen really is being shown.
*/
internal object ScreenShareGuard {
/** Settings.Global.DISABLE_SCREEN_SHARE_PROTECTIONS_FOR_APPS_AND_NOTIFICATIONS, hidden API. */
private const val SETTING = "disable_screen_share_protections_for_apps_and_notifications"
private const val PREFS = "gearslip_screen_share_guard"
/** Set while Gearslip has the protection switched off, so a crash can be undone at next launch. */
private const val KEY_LIFTED = "lifted"
fun canLift(context: Context): Boolean =
context.checkSelfPermission(Manifest.permission.WRITE_SECURE_SETTINGS) ==
PackageManager.PERMISSION_GRANTED
/** Call before the projection starts: the system decides what to hide at that moment. */
fun lift(context: Context) {
if (!canLift(context)) {
GearslipLog.i("audio: notifications will be hidden while capturing (no WRITE_SECURE_SETTINGS)")
return
}
val resolver = context.contentResolver
// Already off, whether by the driver in Developer options or by us: leave it as found.
if (Settings.Global.getInt(resolver, SETTING, 0) != 0) return
runCatching {
prefs(context).edit().putBoolean(KEY_LIFTED, true).commit()
Settings.Global.putInt(resolver, SETTING, 1)
GearslipLog.i("audio: screen share protection off for the drive, notifications stay visible")
}.onFailure { GearslipLog.w("audio: could not lift screen share protection: ${it.message}") }
}
/** Put the protection back, if Gearslip is the one that took it away. */
fun restore(context: Context) {
if (!prefs(context).getBoolean(KEY_LIFTED, false)) return
runCatching {
Settings.Global.putInt(context.contentResolver, SETTING, 0)
prefs(context).edit().remove(KEY_LIFTED).commit()
GearslipLog.i("audio: screen share protection back on")
}.onFailure { GearslipLog.w("audio: could not restore screen share protection: ${it.message}") }
}
private fun prefs(context: Context) =
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
}
@@ -1,178 +0,0 @@
package app.seb3thehacker.gearslip.call
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.provider.ContactsContract
import android.telecom.TelecomManager
import android.telephony.TelephonyCallback
import android.telephony.TelephonyManager
import app.seb3thehacker.gearslip.GearslipLog
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/** A caller as the car shows it: whatever the contacts app knows, or just the number. */
data class Caller(val number: String, val name: String?) {
val label: String get() = name ?: number.ifBlank { "Unknown number" }
}
sealed interface CallUiState {
data object None : CallUiState
data class Ringing(val caller: Caller, val canDecline: Boolean) : CallUiState
/** [since] is the uptime the call went off-hook, for the timer. */
data class Active(val caller: Caller?, val since: Long) : CallUiState
}
/**
* Tracks the phone's call state for the car screen, and answers or declines through the
* doors a sideloaded app can use: [android.telecom.TelecomManager.acceptRingingCall] to answer,
* [GearslipCallScreening]'s response to decline one that is still ringing, and
* [android.telecom.TelecomManager.endCall] to hang up, which needs only ANSWER_PHONE_CALLS for an
* ordinary carrier call. Mute is the phone's microphone mute, put back when the call ends.
*
* Call audio stays on the phone's normal voice path; 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
private val _muted = MutableStateFlow(false)
val muted: StateFlow<Boolean> = _muted.asStateFlow()
private var audio: android.media.AudioManager? = null
fun start(context: Context) {
if (watcher != null) return
if (context.checkSelfPermission(Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) return
audio = context.getSystemService(android.media.AudioManager::class.java)
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
unmute()
_state.value = CallUiState.None
}
private fun onPhoneState(state: Int) {
when (state) {
TelephonyManager.CALL_STATE_IDLE -> {
pendingRespond = null
ringingCaller = null
// A mute left on would silence the microphone for everything after the call.
unmute()
_state.value = CallUiState.None
}
TelephonyManager.CALL_STATE_OFFHOOK -> {
pendingRespond = null
if (_state.value !is CallUiState.Active) {
_state.value = CallUiState.Active(ringingCaller, android.os.SystemClock.uptimeMillis())
}
}
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}") }
}
/** A call Gearslip itself is placing, so the call pill can say who it's to. */
fun outgoingCall(context: Context, number: String) {
ringingCaller = Caller(number, lookUpName(context, number))
}
fun end(context: Context) {
if (context.checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) != PackageManager.PERMISSION_GRANTED) {
GearslipLog.w("calls: ending a call needs ANSWER_PHONE_CALLS, which was never granted")
return
}
val telecom = context.getSystemService(TelecomManager::class.java) ?: return
@Suppress("DEPRECATION")
runCatching { telecom.endCall() }
.onSuccess { if (!it) GearslipLog.w("calls: the phone wouldn't end the call") }
.onFailure { GearslipLog.w("calls: end failed: ${it.message}") }
}
fun toggleMute() {
val manager = audio ?: return
val want = !_muted.value
runCatching { manager.isMicrophoneMute = want }
.onFailure { GearslipLog.w("calls: mute failed: ${it.message}") }
// Read back rather than trusted: some phones let the call app overrule it.
_muted.value = manager.isMicrophoneMute
if (_muted.value != want) GearslipLog.w("calls: the phone didn't take the mute change")
}
private fun unmute() {
if (!_muted.value) return
runCatching { audio?.isMicrophoneMute = false }
_muted.value = false
}
fun answer(context: Context) {
if (context.checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) != PackageManager.PERMISSION_GRANTED) {
GearslipLog.w("calls: answer needs ANSWER_PHONE_CALLS, which was never granted")
return
}
val telecom = context.getSystemService(TelecomManager::class.java) ?: return
runCatching { telecom.acceptRingingCall() }
.onFailure { GearslipLog.w("calls: answer failed: ${it.message}") }
}
private fun lookUpName(context: Context, number: String): String? {
if (context.checkSelfPermission(Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) return null
return runCatching {
val uri = Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI, Uri.encode(number))
context.contentResolver.query(uri, arrayOf(ContactsContract.PhoneLookup.DISPLAY_NAME), null, null, null)
?.use { c -> if (c.moveToFirst()) c.getString(0) else null }
}.getOrNull()
}
}
/** Posts to the main thread after a delay - used to give up on a screening response Gearslip never sent. */
internal fun mainThreadDelay(ms: Long, action: () -> Unit) {
Handler(Looper.getMainLooper()).postDelayed(action, ms)
}
@@ -1,53 +0,0 @@
package app.seb3thehacker.gearslip.call
import android.telecom.Call
import android.telecom.CallScreeningService
import app.seb3thehacker.gearslip.GearslipLog
/**
* Sees every incoming call before it rings, so the car screen can show who is calling and
* decline it in the same window a spam-blocker would use. Inert until the driver sets Gearslip
* as the phone's caller ID and spam app - the same on-device consent Truecaller-style apps ask
* for, not a privileged permission.
*
* The system gives an app a few seconds to answer before it lets the call ring on its own, so a
* response is sent either when the driver taps a button or when that window is about to close,
* whichever comes first. Answering a call that has already passed this point still works, since
* [CarCalls.answer] goes through the ordinary ringing-call API rather than through screening.
*/
class GearslipCallScreening : CallScreeningService() {
override fun onScreenCall(callDetails: Call.Details) {
val number = callDetails.handle?.schemeSpecificPart
var responded = false
val respond: (Boolean) -> Unit = { allow ->
if (!responded) {
responded = true
runCatching {
respondToCall(
callDetails,
CallResponse.Builder()
.setDisallowCall(!allow)
.setRejectCall(!allow)
.setSkipNotification(false)
.build(),
)
}.onFailure { GearslipLog.w("calls: screening response failed: ${it.message}") }
}
}
CarCalls.incomingCall(applicationContext, number, respond)
// Answer for the system if nobody has by the time its own patience runs out, so a call
// Gearslip is slow to show never gets silently blocked.
mainThreadDelay(SCREENING_WINDOW_MS) {
respond(true)
CarCalls.screeningExpired()
}
}
private companion object {
const val SCREENING_WINDOW_MS = 3_500L
}
}
@@ -1,385 +0,0 @@
package app.seb3thehacker.gearslip.car
import android.content.Context
import app.seb3thehacker.gearslip.media.CarMedia
import app.seb3thehacker.gearslip.speech.VoiceFocus
import java.text.DateFormat
import java.util.Date
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
/**
* 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 = natural(heardRaw)
VoiceHelp.hide()
if (heard in HELP_WORDS || heard.startsWith("what can i say") || heard.startsWith("what can you do")) {
VoiceHelp.show()
return "Here's what you can say."
}
if (heard.matches(Regex("(fuck|f\\*+) (you|off)( too)?"))) return "Fuck you too."
navigateCommand(context, navigator, frame, heard)?.let { return it }
playSearchCommand(context, navigator, 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."
}
/**
* What was said, minus the padding people wrap a command in: "hey", "can you", "I want to",
* "please", "thanks". Each command below then only has to know its own words.
*/
private fun natural(raw: String): String {
var t = raw.lowercase().replace(Regex("[.,!?]"), " ").replace(Regex("\\s+"), " ").trim()
var changed = true
while (changed) {
changed = false
for (lead in LEAD_FILLERS) if (t.startsWith("$lead ")) { t = t.removePrefix("$lead ").trim(); changed = true }
for (tail in TAIL_FILLERS) if (t.endsWith(" $tail")) { t = t.removeSuffix(" $tail").trim(); changed = true }
}
return t
}
/** The part after the first of [triggers] that [text] starts with, or null. */
private fun after(text: String, triggers: List<String>): String? =
triggers.firstOrNull { text.startsWith(it) }?.let { text.removePrefix(it).trim() }
private suspend fun allEntries(context: Context): List<Entry> {
val vehicleData = CarSettings.experimentalFeaturesEnabled.value && CarSettings.carSensorsEnabled.value
val builtIn = BuiltInApps.entries.filter { it.builtIn !== BuiltInApps.VehicleData || vehicleData }
return builtIn + (LauncherCache.entries ?: withContext(Dispatchers.IO) { LauncherCache.load(context) })
}
/**
* Best label match: exact, then a label starting with [name], then one containing it. Spaces
* and punctuation don't count, since the recognizer may hear "MapQuest" as "map quest".
*/
private fun <T> bestMatch(items: List<T>, name: String, label: (T) -> String): T? {
val n = squash(name.removePrefix("the ").removePrefix("my ").removeSuffix(" app"))
if (n.isEmpty()) return null
items.firstOrNull { squash(label(it)) == n }?.let { return it }
// Loose matches need a real word: "pizza in a" mustn't pick "Organic Maps" for its "a".
if (n.length < 3) return null
return items.firstOrNull { squash(label(it)).startsWith(n) }
?: items.firstOrNull { squash(label(it)).contains(n) }
}
private fun squash(s: String) = s.lowercase().filter { it.isLetterOrDigit() }
/**
* "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? {
// "use Organic Maps to get to X", "open OsmAnd and navigate to X": the app comes first.
var text = heard
var leadingApp: String? = null
Regex("^(?:use|using|with|open|in|on) (.+?) (?:to |and )(.+)$").find(text)?.let { m ->
if (after(m.groupValues[2], NAVIGATE_TRIGGERS) != null) {
leadingApp = m.groupValues[1]
text = m.groupValues[2]
}
}
var destination = after(text, NAVIGATE_TRIGGERS) ?: return null
if (destination.isBlank()) return "Where to?"
val entries = allEntries(context)
// "go to Spotify" means the app, and "go to settings" the screen; "go to Target" is a place.
val asApp = destination.removePrefix("the ").removeSuffix(" app")
if (text.startsWith("go to ") &&
(asApp in SCREEN_WORDS || asApp in APP_NICKNAMES || entries.any { it.label.lowercase() == asApp })
) {
return null
}
val navApps = entries.filter { it.template?.isNavigation == true }
if (navApps.isEmpty()) return "You don't have any navigation apps installed."
// "X with Organic Maps", "X on OsmAnd": split only when the end names a nav app, so a
// place like "Starbucks on Main Street" stays whole.
var chosenApp = leadingApp?.let { name -> bestMatch(navApps, name) { it.label } ?: return "I couldn't find a navigation app called $name." }
if (chosenApp == null) {
for (sep in listOf(" with ", " using ", " on ", " via ", " in ", " through ")) {
val at = destination.lastIndexOf(sep)
if (at <= 0) continue
val app = bestMatch(navApps, destination.substring(at + sep.length)) { it.label } ?: continue
chosenApp = app
destination = destination.substring(0, at).trim()
break
}
}
// "the nearest gas station" -> "gas station": the map app already searches around the car
// and AutoRoute picks the nearest result, and Organic Maps took "nearest" as part of the name.
val (place, nearby) = plainPlace(destination)
destination = place
if (destination.isBlank()) return "Where to?"
val chosen = chosenApp ?: when {
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 if (nearby) "Finding the nearest $destination with ${chosen.label}." else "Navigating to $destination with ${chosen.label}."
}
/** [destination] without "a", "the nearest", "near me" and the like, and whether any of that said "close by". */
private fun plainPlace(destination: String): Pair<String, Boolean> {
var t = destination
var nearby = false
var changed = true
while (changed) {
changed = false
for (lead in PLACE_LEADS) if (t.startsWith(lead)) {
t = t.removePrefix(lead).trim(); changed = true
if (lead.trim() !in ARTICLES) nearby = true
}
for (tail in PLACE_TAILS) if (t.endsWith(tail)) { t = t.removeSuffix(tail).trim(); changed = true; nearby = true }
}
return t to nearby
}
/**
* "play X" with something after it: a song, artist, album or playlist, asked of the music app
* that's open through its own search ([CarMedia.search]). A bare "play", or "play music",
* falls through to resume.
*/
private suspend fun playSearchCommand(context: Context, navigator: CarNavigator, heard: String): String? {
var query = after(heard, PLAY_TRIGGERS) ?: return null
query = query.removePrefix("some ").trim()
if (query.isEmpty() || query in RESUME_WORDS) return null
val media = CarServices.media
// "play Daft Punk on Metrolist": switch to that app first, if it's one we know.
for (sep in listOf(" on ", " in ", " from ", " with ", " using ")) {
val at = query.lastIndexOf(sep)
if (at <= 0) continue
val apps = withContext(Dispatchers.IO) { app.seb3thehacker.gearslip.media.MediaCatalog.installed(context) }
val wanted = bestMatch(apps, query.substring(at + sep.length)) { it.label } ?: continue
query = query.substring(0, at).trim()
if (query.isEmpty() || query in RESUME_WORDS) {
CarServices.openMedia(wanted, autoplay = true)
navigator.media()
return "Opening ${wanted.label}."
}
if (CarServices.mediaApp.value?.component != wanted.component) {
CarServices.openMedia(wanted)
navigator.media()
// Give it a moment to connect before asking it to search.
withTimeoutOrNull(APP_CONNECT_MS) {
while (media.appLabel == null || (!media.canSearchLibrary && !media.now.value.canSearch)) delay(200)
}
}
break
}
// No music app open, or its connection is gone (the app was closed): use the one played
// last, the way "play X" works on Android Auto.
val phase = media.phase.value
if (media.appLabel == null || phase == CarMedia.Phase.IDLE || phase == CarMedia.Phase.REJECTED) {
if (!CarServices.reopenLastMedia()) return "Open a music app first, then ask again."
navigator.media()
}
withTimeoutOrNull(APP_CONNECT_MS) {
while (media.phase.value != CarMedia.Phase.READY || media.appLabel == null ||
(!media.canSearchLibrary && !media.now.value.canSearch)
) delay(200)
}
val app = media.appLabel ?: return "Your music app didn't open in time. Try again."
if (!media.canSearchLibrary && !media.now.value.canSearch) {
return "$app ignores voice searches. Pick something on its screen instead."
}
media.playBestMatch(query)
return "Looking for $query on $app."
}
private fun mediaCommand(heard: String): String? {
val media = CarServices.media
return when {
"pause" in heard || heard.startsWith("stop") || heard == "quiet" || heard == "shut up" -> {
media.pause()
VoiceFocus.afterRelease { media.pause() }
"Paused."
}
"resume" in heard || "unpause" in heard || heard in RESUME_PHRASES ||
(heard.startsWith("play") && "playing" !in heard) -> {
if (!media.now.value.playing) media.togglePlay()
"Playing."
}
"next" in heard || "skip" in heard || heard.startsWith("change the song") -> {
media.next()
"Skipping."
}
"previous" in heard || "last song" in heard || "back song" in heard || "go back a song" in heard ||
"play that again" in heard || "replay" in heard -> {
media.previous()
"Playing the previous track."
}
("what" in heard && "playing" in heard) || "what song" in heard || "who sings" in heard ||
"who's singing" in heard || "who is singing" in heard || "what's this song" in heard ||
"name of this song" 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 {
heard == "go back" || heard == "back" -> {
navigator.back()
"Going back."
}
heard in EXIT_WORDS -> {
BuiltInApps.Exit.open(navigator)
"Back to the car's screen."
}
"go home" in heard || heard == "home" || heard == "home screen" || "go to the home screen" in heard -> {
navigator.home()
"Heading home."
}
"open apps" in heard || heard == "apps" || "app list" in heard || "all apps" in heard || "my apps" in heard || heard == "go to apps" -> {
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 || heard == "time" || "the time" in heard ->
"It's ${DateFormat.getTimeInstance(DateFormat.SHORT).format(Date())}."
"weather" in heard || "going to rain" in heard || "gonna rain" in heard || "how hot" in heard ||
"how cold" in heard || "temperature outside" 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 = (
after(heard, CALL_TRIGGERS)
?: Regex("^give (.+) a (?:call|ring)$").find(heard)?.groupValues?.get(1)
?: return null
).replace(Regex(" (?:on|at) (?:their |his |her )?(?:mobile|cell|phone|home|work)$"), "").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 = (after(heard, OPEN_TRIGGERS) ?: return null)
.removePrefix("the ").removePrefix("my ").removeSuffix(" app").trim()
if (name.isBlank()) return null
val wanted = APP_NICKNAMES[name] ?: name
// Gearslip's own apps come first in the list, so "phone" opens its dialer, not the phone's.
val match = bestMatch(allEntries(context), wanted) { it.label }
?: return "I couldn't find an app called $name."
launchEntry(match, navigator, frame)
return "Opening ${match.label}."
}
private val EXIT_WORDS = setOf("exit", "exit gearslip", "close gearslip", "quit", "quit gearslip", "open exit")
/** Other names people use for Gearslip's own apps. */
private val APP_NICKNAMES = mapOf(
"web" to "browser", "internet" to "browser", "the web" to "browser", "web browser" to "browser",
"dialer" to "phone", "the dialer" to "phone", "keypad" to "phone",
"screen share" to "screen sharing", "screen mirroring" to "screen sharing", "mirroring" to "screen sharing",
)
private val NAVIGATE_TRIGGERS = listOf(
"navigate to ", "navigate me to ", "directions to ", "get directions to ", "give me directions to ",
"take me to ", "drive to ", "drive me to ", "get me to ", "go to ", "head to ", "route to ",
"how do i get to ", "show me the way to ", "find a route to ", "navigation to ", "get to ",
// Last, so "navigate to" wins: the recognizer sometimes drops the "to".
"navigate me ", "navigate ", "find me the nearest ", "find the nearest ", "find the closest ",
)
private val ARTICLES = setOf("a", "an", "the", "some")
// Longest first, so "the nearest " goes before "the ".
private val PLACE_LEADS = listOf(
"the nearest ", "the closest ", "a nearby ", "the nearby ", "nearest ", "closest ", "nearby ",
"a ", "an ", "some ",
)
private val PLACE_TAILS = listOf(" near me", " nearby", " near here", " around here", " close by", " closest to me")
private val PLAY_TRIGGERS = listOf("play me ", "play ", "put on ", "listen to ", "i want to hear ", "shuffle ")
private val CALL_TRIGGERS = listOf("call ", "phone ", "dial ", "ring ", "make a call to ", "place a call to ", "start a call with ")
private val OPEN_TRIGGERS = listOf("open ", "launch ", "start ", "go to ", "switch to ", "show me ", "bring up ", "pull up ")
/** Screens "go to X" means rather than a place. */
private val SCREEN_WORDS = setOf("settings", "home", "the home screen", "home screen", "apps", "my apps", "all apps")
/** Padding stripped from the front of a command, longest first where they overlap. */
private val LEAD_FILLERS = listOf(
"hey gearslip", "ok gearslip", "okay gearslip", "gearslip", "hey", "ok", "okay", "um", "uh", "so",
"please", "can you please", "can you", "could you", "would you", "will you", "can i", "could i",
"i want you to", "i want to", "i'd like to", "i would like to", "i wanna", "i need to", "i need you to",
"let's", "lets", "go ahead and", "just", "now", "quickly",
)
/** Longest first, so "right now" goes whole rather than leaving "right". */
private val TAIL_FILLERS = listOf("thank you very much", "thank you", "right now", "for me", "please", "thanks", "now")
private const val APP_CONNECT_MS = 5_000L
private val HELP_WORDS = setOf("help", "commands", "voice commands", "list commands", "show commands")
private val RESUME_WORDS = setOf("music", "the music", "some music", "something", "it", "again", "that", "my music")
private val RESUME_PHRASES = setOf("keep playing", "continue", "keep going", "start the music", "music on", "turn the music back on")
}
@@ -1,140 +0,0 @@
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.animation.core.FastOutSlowInEasing
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.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
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
/**
* What the assistant hears and says, laid over the bottom of whatever screen is up: a dark shade
* so white words read over a bright map, and a glow along the bottom edge in the phone's own
* colours that breathes while listening. The shade passes touches through; only the buttons take them.
*/
@Composable
fun AssistantOverlay(modifier: Modifier = Modifier) {
val state by CarAssistant.state.collectAsState()
if (state.phase == CarAssistant.Phase.IDLE) return
val context = LocalContext.current
val listening = state.phase == CarAssistant.Phase.LISTENING && state.ready
val glow = MaterialTheme.colorScheme.primary
val glow2 = MaterialTheme.colorScheme.tertiary
val breathe by rememberInfiniteTransition(label = "glow").animateFloat(
0.55f, 1f, infiniteRepeatable(tween(900, easing = FastOutSlowInEasing), RepeatMode.Reverse), label = "glow",
)
val strength = when (state.phase) {
CarAssistant.Phase.LISTENING -> if (state.ready) breathe else 0.3f
CarAssistant.Phase.ERROR -> 0.3f
else -> 0.6f
}
Box(
modifier
.fillMaxWidth()
.drawBehind {
drawRect(Brush.verticalGradient(0f to Color.Transparent, 0.45f to Color.Black.copy(alpha = 0.6f), 1f to Color.Black.copy(alpha = 0.85f)))
// A wide, flat oval of light rising from the bottom edge.
scale(1f, 0.22f, Offset(size.width / 2, size.height)) {
drawCircle(
Brush.radialGradient(
0f to glow.copy(alpha = 0.9f * strength), 0.5f to glow2.copy(alpha = 0.35f * strength), 1f to Color.Transparent,
center = Offset(size.width / 2, size.height), radius = size.width * 0.55f,
),
radius = size.width * 0.55f, center = Offset(size.width / 2, size.height),
)
}
}
.padding(start = 16.dp, end = 16.dp, top = 56.dp, bottom = 20.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
PulsingMic(listening)
Column(Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) {
val (small, big) = when (state.phase) {
CarAssistant.Phase.LISTENING -> "" to state.heard.ifBlank { if (state.ready) "Listening…" else "One moment…" }
CarAssistant.Phase.THINKING -> "" to state.heard.ifBlank { "…" }
else -> state.heard to state.reply
}
if (small.isNotBlank()) {
Text(small, color = Color.White.copy(alpha = 0.7f), style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
Text(
big,
color = if (big == "Listening…" || big == "One moment…" || big == "…") Color.White.copy(alpha = 0.7f) else Color.White,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
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)
}
@@ -1,146 +0,0 @@
package app.seb3thehacker.gearslip.car
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothA2dp
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.os.Handler
import android.os.Looper
import app.seb3thehacker.gearslip.GearslipLog
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* With Bluetooth audio chosen, music only reaches the car if the phone is connected to it over
* Bluetooth. Twenty seconds after the car shows Gearslip, long enough for a car to connect on its
* own, this checks. If the phone isn't connected, the car and the phone's Home screen both say so,
* until it connects or the drive ends.
*
* When the car named its Bluetooth address, only that device counts. Otherwise any audio
* connection does, since there's no telling which device is the car.
*/
object BluetoothAudioCheck {
private val _missing = MutableStateFlow(false)
/** True while Bluetooth audio is chosen and the phone isn't connected to the car for it. */
val missing: StateFlow<Boolean> = _missing
private val _carDismissed = MutableStateFlow(false)
/** The driver tapped OK on the car's card; it stays away for the rest of this drive. */
val carDismissed: StateFlow<Boolean> = _carDismissed
private val log = GearslipLog.tagged("bluetooth")
private val main = Handler(Looper.getMainLooper())
private var appContext: Context? = null
private var armed = false
private var receiver: BroadcastReceiver? = null
fun init(context: Context) {
appContext = context.applicationContext
}
/** The car shows Gearslip. Called again whenever the video restarts, so it arms once per drive. */
fun onProjecting() = main.post {
if (armed) return@post
armed = true
main.postDelayed(::start, WAIT_MS)
}
fun onCarDisconnected() = main.post {
armed = false
main.removeCallbacksAndMessages(null)
stopWatching()
_missing.value = false
_carDismissed.value = false
}
fun dismissOnCar() {
_carDismissed.value = true
}
/** "Don't show again" on the car: turns the warning off in Settings, for every car. */
fun neverAgain() {
CarSettings.setBluetoothWarning(false)
log.i("Bluetooth audio warning turned off")
_carDismissed.value = true
_missing.value = false
}
private fun start() {
if (!armed) return
check()
// Connecting later clears the warning, and losing the connection brings it back.
val context = appContext ?: return
val r = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
main.postDelayed(::check, SETTLE_MS)
}
}
context.registerReceiver(
r,
IntentFilter(BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED).apply {
addAction(BluetoothAdapter.ACTION_STATE_CHANGED)
},
Context.RECEIVER_EXPORTED,
)
receiver = r
}
@SuppressLint("MissingPermission")
private fun check() {
if (!armed) return
val context = appContext ?: return
if (CarSettings.pipeAudio.value) {
set(false, "USB audio is on")
return
}
if (!CarSettings.bluetoothWarning.value) {
set(false, "the warning is turned off")
return
}
if (context.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
log.i("can't check the audio connection: the Bluetooth permission isn't granted")
return
}
val adapter = context.getSystemService(BluetoothManager::class.java)?.adapter ?: return
if (!adapter.isEnabled) {
set(true, "Bluetooth is off")
return
}
val car = CarBluetooth.carAddress?.uppercase()
adapter.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
val connected = runCatching { proxy.connectedDevices }.getOrDefault(emptyList())
adapter.closeProfileProxy(profile, proxy)
val ok = if (car != null) connected.any { it.address.uppercase() == car } else connected.isNotEmpty()
main.post {
set(!ok, if (ok) "connected to the car" else "not connected to the car")
}
}
override fun onServiceDisconnected(profile: Int) {}
}, BluetoothProfile.A2DP)
}
private fun set(missing: Boolean, why: String) {
if (!armed || _missing.value == missing) return
_missing.value = missing
log.i("Bluetooth audio: $why")
}
private fun stopWatching() {
val r = receiver ?: return
receiver = null
runCatching { appContext?.unregisterReceiver(r) }
}
private const val WAIT_MS = 20_000L
private const val SETTLE_MS = 1_000L
}
@@ -1,118 +0,0 @@
package app.seb3thehacker.gearslip.car
import app.seb3thehacker.gearslip.car.theme.*
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.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.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.text.style.TextAlign
import androidx.compose.ui.unit.dp
/**
* Asks once per car whether to pair it over Bluetooth, then waits while the phone shows its
* pairing dialog. See [CarBluetooth]. With no pairing to do, says when Bluetooth audio isn't
* connected (see [BluetoothAudioCheck]), or else when the phone's location is off (see
* [LocationCheck]). Unlike the call prompt, a tap outside the card does nothing:
* the driver picks Pair or Not now.
*/
@Composable
internal fun BluetoothPairOverlay() {
val prompt by CarBluetooth.prompt.collectAsState()
val bluetoothAudio = !CarSettings.pipeAudio.collectAsState().value
when (prompt) {
CarBluetooth.Prompt.NONE -> {
val missing by BluetoothAudioCheck.missing.collectAsState()
val dismissed by BluetoothAudioCheck.carDismissed.collectAsState()
val warn by CarSettings.bluetoothWarning.collectAsState()
val locationOff by LocationCheck.off.collectAsState()
val locationDismissed by LocationCheck.carDismissed.collectAsState()
if (missing && bluetoothAudio && warn && !dismissed) PairCard(
title = "Bluetooth isn't connected",
body = "Music plays over Bluetooth, but your phone isn't connected to the car. " +
"Connect it from the car's Bluetooth menu or your phone's.",
secondary = "Don't show again" to BluetoothAudioCheck::neverAgain,
primary = "OK" to BluetoothAudioCheck::dismissOnCar,
) else if (locationOff && !locationDismissed) PairCard(
title = "Location is off",
body = "Map apps can't show where you are or give directions. Turn on location " +
"in your phone's quick settings when you're parked.",
secondary = "Don't show again" to LocationCheck::neverAgain,
primary = "OK" to LocationCheck::dismissOnCar,
)
}
CarBluetooth.Prompt.ASK -> PairCard(
title = "Pair with this car?",
body = (if (bluetoothAudio) "Calls and music play" else "Calls play") +
" through the car's speakers once your phone and the car are paired. Your phone " +
"then asks you to confirm a code.",
secondary = "Not now" to CarBluetooth::notNow,
primary = "Pair" to CarBluetooth::pair,
)
CarBluetooth.Prompt.PAIRING -> PairCard(
title = "Confirm on your phone",
body = "Check the code on your phone and tap Pair. The car may ask too.",
secondary = null,
primary = "Hide" to CarBluetooth::hide,
)
}
}
@Composable
private fun PairCard(title: String, body: String, secondary: Pair<String, () -> Unit>?, primary: Pair<String, () -> Unit>) {
Box(
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.6f)).clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },
onClick = {},
),
contentAlignment = Alignment.Center,
) {
Surface(
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(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text(
body,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
secondary?.let { (label, onClick) ->
GsButton(onClick = onClick, tone = GsTone.Outline, modifier = Modifier.weight(1f).height(56.dp)) {
Text(label, style = MaterialTheme.typography.titleMedium)
}
}
GsButton(onClick = primary.second, modifier = Modifier.weight(1f).height(56.dp)) {
Text(primary.first, style = MaterialTheme.typography.titleMedium)
}
}
}
}
}
}
@@ -1,108 +0,0 @@
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
/**
* Takes the whole car screen while a call is ringing, because nothing else matters more right
* now. A call under way moves into the nav bar instead (see NavCallPill), so it covers nothing.
*/
@Composable
fun CallOverlay() {
val state by CarCalls.state.collectAsState()
val context = LocalContext.current.applicationContext
when (val s = state) {
is CallUiState.Ringing -> IncomingCall(s, onAnswer = { CarCalls.answer(context) }, onDecline = CarCalls::decline)
else -> {}
}
}
@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)
}
}
@@ -1,254 +0,0 @@
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.foundation.layout.height
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.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.material.icons.filled.Close
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()
val cover = remember { ChromeCover() }
val navBar = LocalNavBarInset.current
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.
androidx.compose.runtime.CompositionLocalProvider(LocalChromeCover provides cover, LocalCarAppConnection provides connection) {
if (status.surfaceRequested) {
AppSurface(connection, frame, template, Modifier.fillMaxSize())
}
TemplateChrome(
template,
Modifier.fillMaxSize().padding(top = if (embedded) 0.dp else CHROME_BAR, bottom = navBar),
)
BottomCover(navBar, Modifier.align(Alignment.BottomCenter))
}
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) }
var bounds by remember { mutableStateOf<androidx.compose.ui.geometry.Rect?>(null) }
val cover = LocalChromeCover.current
// The app lays its map out around whatever the host covers, so a template change or a card
// changing size moves the visible area even though the surface itself hasn't changed.
val (width, height) = size
val measured = bounds?.takeIf { width > 0 && height > 0 }?.let { cover?.visibleWithin(it, width, height) }
// The card column moves the map's centre but isn't taken out of the stable area: apps size
// their own overlays from it, and Vela's speedometer shrank to fit the strip right of the cards.
val measuredStable = bounds?.takeIf { width > 0 && height > 0 }?.let { cover?.visibleWithin(it, width, height, skip = CoverEdge.START) }
LaunchedEffect(template, size, measured, measuredStable) {
if (width > 0 && height > 0) {
val visible = visibleAreaFor(template, width, height, measured)
val stable = if (!isContentTemplate(template)) {
visibleAreaFor(template, width, height, measuredStable)
} else {
visible
}
connection.reportAreas(visible = visible, stable = stable)
}
}
// 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.onGloballyPositioned { bounds = it.boundsInWindow() },
) {
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,
) {
// The app's name and a way out, nothing else: which template is showing, or whether the
// app has drawn yet, is for the log, not the driver.
Surface(
color = MaterialTheme.colorScheme.surfaceContainer,
contentColor = MaterialTheme.colorScheme.onSurface,
modifier = modifier.fillMaxWidth().height(CHROME_BAR),
) {
Row(
Modifier.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
status.app ?: "Car app",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
GsIconButton(
androidx.compose.material.icons.Icons.Filled.Close,
"Close ${status.app ?: "the app"}",
onDisconnect,
size = 36.dp,
iconSize = 20.dp,
shape = androidx.compose.foundation.shape.CircleShape,
)
}
}
}
/** 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
/** The app connection a template on screen belongs to: the map app's, or the one opened from the launcher. */
internal val LocalCarAppConnection = androidx.compose.runtime.staticCompositionLocalOf { CarServices.nav }
@@ -1,40 +0,0 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Settings
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import app.seb3thehacker.gearslip.ProjectedContent
/** One tile in the launcher and the screen it opens. */
class CarApp(
val id: String,
val label: String,
val icon: ImageVector,
val content: @Composable () -> Unit,
)
/** Built-in apps. Third-party (Android Auto) apps are a later step and will register here too. */
object CarApps {
val all: List<CarApp> = listOf(
CarApp("web", "Browser", Icons.Filled.Search) {
WebApp(content = ProjectedContent.resolve(LocalContext.current))
},
CarApp("phone", "Screen sharing", Icons.Filled.Share) {
PhoneAppsScreen()
},
CarApp("dialer", "Phone", Icons.Filled.Call) {
PhoneDialerScreen()
},
CarApp("settings", "Settings", Icons.Filled.Settings) {
CarSettingsScreen()
},
)
fun find(id: String): CarApp? = all.firstOrNull { it.id == id }
}
@@ -1,276 +0,0 @@
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 app.seb3thehacker.gearslip.speech.VoiceFocus
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,
/** False from the press until the microphone is actually open, so the overlay doesn't claim to listen early. */
val ready: 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(
onListening = { _state.value = _state.value.copy(ready = true) },
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
}
appContext?.let(VoiceFocus::acquire)
engine.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
override fun onStart(utteranceId: String?) {}
override fun onDone(utteranceId: String?) {
VoiceFocus.release()
_state.value = _state.value.copy(phase = Phase.IDLE)
}
@Deprecated("Deprecated in Java, no replacement on this API level")
override fun onError(utteranceId: String?) {
VoiceFocus.release()
_state.value = _state.value.copy(phase = Phase.IDLE)
}
override fun onStop(utteranceId: String?, interrupted: Boolean) { VoiceFocus.release() }
})
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
val context = appContext
if (engine == null || context == null || !ttsReady || text.isBlank()) {
mainHandler.post(onDone)
return
}
VoiceFocus.acquire(context)
engine.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
override fun onStart(utteranceId: String?) {}
override fun onDone(utteranceId: String?) { VoiceFocus.release(); mainHandler.post(onDone) }
@Deprecated("Deprecated in Java, no replacement on this API level")
override fun onError(utteranceId: String?) { VoiceFocus.release(); mainHandler.post(onDone) }
// Stopped part way: whoever stopped it has already moved on.
override fun onStop(utteranceId: String?, interrupted: Boolean) { VoiceFocus.release() }
})
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 = onResult, onError = onError, cue = cue, onSpeaking = onSpeaking)
}
/**
* One phrase, through Gearslip's own [SpeechEngine], or the phone's recognizer when the driver
* chose it in settings. Never the phone's on its own: on most phones that's Google's.
*/
private fun listenOnce(
onPartial: (String) -> Unit,
onListening: () -> Unit = {},
onHeard: () -> Unit,
onResult: (String) -> Unit,
onError: (String) -> Unit,
cue: Boolean = true,
onSpeaking: () -> Unit = {},
) {
val context = appContext ?: return onError("Speech isn't ready yet.")
if (CarSettings.systemSpeech.value && SpeechRecognizer.isRecognitionAvailable(context)) {
return systemListen(context, onListening, onPartial, onHeard, onResult, onError)
}
SpeechEngine.listen(
context,
cue = cue,
onSpeaking = onSpeaking,
onPartial = onPartial,
onListening = onListening,
onHeard = onHeard,
onResult = onResult,
onError = onError,
)
}
private fun systemListen(
context: Context,
onListening: () -> Unit,
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?) = onListening()
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
}
}
@@ -1,255 +0,0 @@
package app.seb3thehacker.gearslip.car
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.os.Handler
import android.os.Looper
import androidx.car.app.CarToast
import app.seb3thehacker.gearslip.AppSettings
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.Protobuf
import app.seb3thehacker.gearslip.ServiceDiscovery
import app.seb3thehacker.gearslip.Wire
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.security.MessageDigest
/**
* Pairs the phone with the car over Bluetooth the first time they meet, so calls (and music, when
* the driver chose Bluetooth audio) reach the car's speakers. Android Auto does this on its own;
* Gearslip asks first, on the car screen.
*
* The car names its Bluetooth address in its service list. When the driver taps Pair, Gearslip
* opens the car's Bluetooth channel and sends what Android Auto sends:
*
* -> CHANNEL_OPEN_REQUEST <- CHANNEL_OPEN_RESPONSE
* -> PairingRequest { phone_address, method }
* <- PairingResponse { status, already_paired }
*
* then starts pairing from the phone. Two things Android Auto has that Gearslip can't: the
* phone's own Bluetooth address (apps get a placeholder), and confirming the pairing code without
* asking. So a car may turn the request down, and the phone always shows its pairing dialog. The
* channel stays closed unless the driver taps Pair: every new channel is a risk to cars that work.
*/
object CarBluetooth {
enum class Prompt { NONE, ASK, PAIRING }
private val _prompt = MutableStateFlow(Prompt.NONE)
val prompt: StateFlow<Prompt> = _prompt
private val log = GearslipLog.tagged("bluetooth")
private val main = Handler(Looper.getMainLooper())
private var appContext: Context? = null
/** This session's car: its channel, and how to open and write to it. */
private class Car(
val service: ServiceDiscovery.BluetoothService,
val open: () -> Unit,
val send: (messageId: Int, body: ByteArray) -> Unit,
)
@Volatile private var car: Car? = null
@Volatile private var bondStarted = false
/** From the Pair tap until it succeeds or fails, whether or not the card still shows. */
@Volatile private var pairing = false
private var receiver: BroadcastReceiver? = null
/** The car's Bluetooth address, when it named one in its service list. */
val carAddress: String? get() = car?.service?.carAddress
/** The car's Bluetooth channel once the driver has asked to pair, -1 otherwise. */
val channelId: Int get() = car?.takeIf { pairing }?.service?.serviceId ?: -1
fun init(context: Context) {
appContext = context.applicationContext
}
/** Called once the car lists its services. Asks on the car screen only if there's pairing to do. */
fun onCarConnected(
service: ServiceDiscovery.BluetoothService,
open: () -> Unit,
send: (messageId: Int, body: ByteArray) -> Unit,
) {
car = Car(service, open, send)
bondStarted = false
log.i("the car offers Bluetooth pairing, methods=${service.pairingMethods}")
val device = device() ?: return
when {
device.bondState == BluetoothDevice.BOND_BONDED -> log.i("already paired with this car")
declined(service.carAddress) -> log.i("not paired, and the driver said not now for this car")
else -> _prompt.value = Prompt.ASK
}
}
fun onCarDisconnected() {
car = null
pairing = false
main.post {
stopWatching()
_prompt.value = Prompt.NONE
}
}
/** The driver tapped Pair. */
fun pair() {
val c = car ?: return
val device = device() ?: return
pairing = true
_prompt.value = Prompt.PAIRING
watchBonding(device)
c.open()
log.i("-> ChannelOpenRequest on channel ${c.service.serviceId}")
// If the car never answers on its channel, pair from the phone anyway.
main.postDelayed({ startBond("the car didn't answer the pairing request") }, CAR_REPLY_WAIT_MS)
main.postDelayed({
if (pairing) finish("Pairing didn't finish. Pair from the car's Bluetooth menu instead.")
}, GIVE_UP_MS)
}
/** The driver tapped Not now: this car isn't asked about again. */
fun notNow() {
car?.service?.carAddress?.let { address ->
val context = appContext ?: return@let
val list = AppSettings.getString(context, KEY_DECLINED, "").split(",").filter { it.isNotEmpty() }
AppSettings.putString(context, KEY_DECLINED, (list + hash(address)).distinct().joinToString(","))
}
_prompt.value = Prompt.NONE
}
/** Hides the pairing card. The phone's own dialog carries on. */
fun hide() {
_prompt.value = Prompt.NONE
}
fun onMessage(messageId: Int, body: ByteArray) {
val c = car ?: return
when (messageId) {
MSG_CHANNEL_OPEN_RESPONSE -> {
val status = Protobuf.readInt32Field(body, 1) ?: 0
log.i("<- ChannelOpenResponse: status=$status")
if (status != 0) {
main.post { startBond("the car wouldn't open its Bluetooth channel") }
return
}
val method = c.service.pairingMethods.firstOrNull { it == METHOD_NUMERIC_COMPARISON }
?: c.service.pairingMethods.firstOrNull() ?: METHOD_NUMERIC_COMPARISON
c.send(
MSG_PAIRING_REQUEST,
Protobuf.stringField(1, phoneAddress()) + Protobuf.varintField(2, method.toLong()),
)
log.i("-> PairingRequest(method=$method)")
}
MSG_PAIRING_RESPONSE -> {
val fields = Wire.fields(body)
log.i("<- PairingResponse status=${Wire.varint(fields, 1)} already_paired=${Wire.varint(fields, 2) == 1L}")
// Some cars start pairing themselves once they answer; give them a moment first.
main.postDelayed({ startBond("the car answered") }, CAR_BOND_WAIT_MS)
}
// Carries the pairing code. Android shows it to the driver, so it stays out of the log.
MSG_AUTHENTICATION_DATA -> log.i("<- AuthenticationData (${body.size} bytes)")
else -> log.i("<- unhandled message id=$messageId (${body.size} bytes)")
}
}
@SuppressLint("MissingPermission")
private fun startBond(why: String) {
if (bondStarted || !pairing) return
val device = device() ?: return
if (device.bondState != BluetoothDevice.BOND_NONE) return
bondStarted = true
val started = runCatching { device.createBond() }.getOrDefault(false)
log.i("pairing from the phone ($why): ${if (started) "started" else "refused"}")
if (!started) finish("Pairing didn't start. Pair from the car's Bluetooth menu instead.")
}
private fun watchBonding(device: BluetoothDevice) {
val context = appContext ?: return
stopWatching()
val r = object : BroadcastReceiver() {
@Suppress("DEPRECATION")
override fun onReceive(context: Context, intent: Intent) {
val changed = intent.getParcelableExtra<BluetoothDevice>(BluetoothDevice.EXTRA_DEVICE) ?: return
if (changed.address != device.address) return
val state = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, BluetoothDevice.BOND_NONE)
val previous = intent.getIntExtra(BluetoothDevice.EXTRA_PREVIOUS_BOND_STATE, BluetoothDevice.BOND_NONE)
log.i("bond state $previous -> $state")
when {
state == BluetoothDevice.BOND_BONDING -> bondStarted = true
state == BluetoothDevice.BOND_BONDED -> finish("Paired. Calls now play through the car.")
previous == BluetoothDevice.BOND_BONDING -> finish("Pairing didn't finish. Pair from the car's Bluetooth menu instead.")
}
}
}
context.registerReceiver(r, IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED), Context.RECEIVER_EXPORTED)
receiver = r
}
private fun finish(message: String) {
pairing = false
stopWatching()
main.removeCallbacksAndMessages(null)
_prompt.value = Prompt.NONE
CarToasts.show(this, message, CarToast.LENGTH_LONG)
}
private fun stopWatching() {
val r = receiver ?: return
receiver = null
runCatching { appContext?.unregisterReceiver(r) }
}
/** The car as a Bluetooth device, or null (and logged) when Gearslip can't reach Bluetooth. */
private fun device(): BluetoothDevice? {
val context = appContext ?: return null
val address = car?.service?.carAddress ?: return null
if (context.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
log.i("can't check pairing: the Bluetooth permission isn't granted")
return null
}
val adapter = context.getSystemService(BluetoothManager::class.java)?.adapter
if (adapter == null || !adapter.isEnabled) {
log.i("can't check pairing: Bluetooth is off")
return null
}
if (!BluetoothAdapter.checkBluetoothAddress(address)) {
log.w("the car sent an address that isn't one")
return null
}
return adapter.getRemoteDevice(address)
}
/** Apps get 02:00:00:00:00:00 here, not the real address. Sent anyway, as the field is required. */
@SuppressLint("MissingPermission", "HardwareIds")
private fun phoneAddress(): String =
runCatching { appContext?.getSystemService(BluetoothManager::class.java)?.adapter?.address }.getOrNull()
?: "02:00:00:00:00:00"
private fun declined(address: String): Boolean {
val context = appContext ?: return false
return hash(address) in AppSettings.getString(context, KEY_DECLINED, "").split(",")
}
/** Settings keep a short hash, not the car's address. */
private fun hash(address: String): String =
MessageDigest.getInstance("SHA-256").digest(address.uppercase().toByteArray())
.take(8).joinToString("") { "%02x".format(it) }
private const val KEY_DECLINED = "bluetooth_pair_declined"
private const val CAR_REPLY_WAIT_MS = 5_000L
private const val CAR_BOND_WAIT_MS = 3_000L
private const val GIVE_UP_MS = 90_000L
private const val METHOD_NUMERIC_COMPARISON = 2
private const val MSG_CHANNEL_OPEN_RESPONSE = 8
private const val MSG_PAIRING_REQUEST = 32769
private const val MSG_PAIRING_RESPONSE = 32770
private const val MSG_AUTHENTICATION_DATA = 32771
}
@@ -1,211 +0,0 @@
package app.seb3thehacker.gearslip.car
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.database.Cursor
import android.location.LocationManager
import android.provider.CalendarContract
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.produceState
import 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),
) {
ScreenHeader("Today")
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
}
@@ -1,140 +0,0 @@
package app.seb3thehacker.gearslip.car
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.res.Configuration
import android.os.BatteryManager
import android.os.Handler
import android.os.Looper
import java.util.Calendar
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* What the car UI and the apps inside it can know about the world outside the UI.
*
* [phoneDark] is the phone's own theme and picks the car UI's colours. [darkOutside] is a
* separate signal, "is it dark out", for apps that want to adapt (a map, a web page).
*
* Where [darkOutside] comes from is deliberately swappable. Today it is a time-of-day guess.
* The right source is the head unit's own night-mode sensor, which arrives on the AA sensor
* channel once that exists; feed it in through [setSensorNight] and it takes over.
*/
object CarEnvironment {
data class Battery(val percent: Int, val charging: Boolean)
private val phoneDarkFlow = MutableStateFlow(false)
private val darkOutsideFlow = MutableStateFlow(false)
private val batteryFlow = MutableStateFlow(Battery(-1, false))
private val displayFlow = MutableStateFlow("")
/** The projected frame's real geometry, for anything that has to lay out against it. */
data class Frame(val width: Int, val height: Int, val densityDpi: Int)
private val frameFlow = MutableStateFlow(Frame(800, 480, 160))
val frame: StateFlow<Frame> = frameFlow
val phoneDark: StateFlow<Boolean> = phoneDarkFlow
val darkOutside: StateFlow<Boolean> = darkOutsideFlow
val battery: StateFlow<Battery> = batteryFlow
/** Human-readable projected resolution, for the connection info in car Settings. */
val display: StateFlow<String> = displayFlow
private val vehicleFlow = MutableStateFlow("")
private val insetsFlow = MutableStateFlow(app.seb3thehacker.gearslip.Insets.NONE)
/** Name of the matched vehicle profile, for the connection info in car Settings. */
val vehicle: StateFlow<String> = vehicleFlow
/** How much of the video frame the car UI keeps clear, from the matched vehicle profile. */
val insets: StateFlow<app.seb3thehacker.gearslip.Insets> = insetsFlow
/** Live adjustment from car Settings; not persisted until saved to the vehicle profile. */
fun setInsets(insets: app.seb3thehacker.gearslip.Insets) {
insetsFlow.value = insets
}
fun setVehicle(name: String, insets: app.seb3thehacker.gearslip.Insets) {
vehicleFlow.value = name
insetsFlow.value = insets
}
@Volatile private var sensorNight: Boolean? = null
private var appContext: Context? = null
private var running = false
private val handler = Handler(Looper.getMainLooper())
private val tick = object : Runnable {
override fun run() {
refresh()
handler.postDelayed(this, 60_000)
}
}
private val batteryReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
intent?.let(::readBattery)
}
}
fun setPhoneTheme(configuration: Configuration) {
phoneDarkFlow.value =
(configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
}
/** For the AA sensor channel: null goes back to the time-of-day guess. */
fun setSensorNight(night: Boolean?) {
sensorNight = night
refresh()
}
fun setDisplay(width: Int, height: Int, densityDpi: Int) {
displayFlow.value = "${width}x$height @ ${densityDpi}dpi"
frameFlow.value = Frame(width, height, densityDpi)
}
fun start(context: Context) {
if (running) return
running = true
appContext = context.applicationContext
setPhoneTheme(context.resources.configuration)
val sticky = context.applicationContext.registerReceiver(
batteryReceiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED), Context.RECEIVER_NOT_EXPORTED,
)
sticky?.let(::readBattery)
handler.post(tick)
}
fun stop() {
if (!running) return
running = false
handler.removeCallbacks(tick)
runCatching { appContext?.unregisterReceiver(batteryReceiver) }
}
fun refresh() {
darkOutsideFlow.value = when (CarSettings.nightMode.value) {
NightMode.DAY -> false
NightMode.NIGHT -> true
NightMode.AUTO -> sensorNight ?: guessNightFromClock()
}
}
private fun guessNightFromClock(): Boolean {
val hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY)
return hour >= 20 || hour < 6
}
private fun readBattery(intent: Intent) {
val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, 100)
val status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1)
val charging = status == BatteryManager.BATTERY_STATUS_CHARGING ||
status == BatteryManager.BATTERY_STATUS_FULL
batteryFlow.value = Battery(if (level >= 0 && scale > 0) level * 100 / scale else -1, charging)
}
}
@@ -1,51 +0,0 @@
package app.seb3thehacker.gearslip.car
import android.os.SystemClock
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
/**
* Full screen for the screen that asks for it (the browser, or a video in it): no header and no
* nav bar. A swipe in from the top or bottom edge brings the nav bar back for a few seconds,
* with a button to leave full screen; [revealed] says when.
*/
object CarFullscreen {
var active by mutableStateOf(false)
private set
var revealed by mutableStateOf(false)
private set
private var lastTouch = 0L
fun enter() {
active = true
revealed = false
}
fun exit() {
active = false
revealed = false
}
/** A swipe in from an edge: show the nav bar and the way out. */
fun reveal() {
if (!active) return
lastTouch = SystemClock.uptimeMillis()
revealed = true
}
/** Any touch keeps a revealed bar up, so it doesn't slide away under a finger reaching for it. */
fun touch() {
if (revealed) lastTouch = SystemClock.uptimeMillis()
}
/** Called on a timer; hides the bar again after [REVEAL_MS] without a touch. */
fun tick() {
if (revealed && SystemClock.uptimeMillis() - lastTouch >= REVEAL_MS) revealed = false
}
private const val REVEAL_MS = 4_000L
}
@@ -1,258 +0,0 @@
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
val playerLeft by CarSettings.playerLeft.collectAsStateWithLifecycle()
// No padding anywhere, split screen or not: the player sits flush against the map.
Row(Modifier.fillMaxSize()) {
val side = Modifier.weight(1f).fillMaxHeight().padding(bottom = LocalNavBarInset.current)
if (splitScreen && playerLeft) {
if (panel == SidePanel.LYRICS) SideLyrics(now, side) else DockedPlayer(now, side)
}
Box(
Modifier
.weight(if (splitScreen) 2.1f else 1f)
.fillMaxHeight(),
) {
MapPane(navStatus, frame)
}
if (splitScreen && !playerLeft) {
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)
}
}
}
@@ -1,247 +0,0 @@
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
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.coerceIn
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.LaunchedEffect
/**
* 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)
}
/**
* True for the car UI the phone's own car preview draws. The phone is in hand there, so a
* [CarKeyboard] draws nothing and the phone's keyboard does the typing instead (see [PhoneTyping]).
*/
val LocalCarPreview = compositionLocalOf { 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) }
val preview = LocalCarPreview.current
DisposableEffect(preview) {
if (!preview) CarKeyboardVisibility.state.value = true
onDispose { if (!preview) CarKeyboardVisibility.state.value = false }
}
// The same field on the phone, for typing on its own keyboard: see [PhoneTyping].
val latestChange by rememberUpdatedState(onTextChange)
val latestSubmit by rememberUpdatedState(onSubmit)
val latestDismiss by rememberUpdatedState(onDismiss)
val phoneField = remember {
PhoneTyping.open(text, { latestChange(it) }, { latestSubmit() }, onDismiss?.let { { latestDismiss?.invoke() } })
}
DisposableEffect(phoneField) { onDispose { PhoneTyping.close(phoneField) } }
LaunchedEffect(text) { PhoneTyping.carText(phoneField, text) }
if (preview) return
val rows = if (symbols) SYMBOL_ROWS else LETTER_ROWS
val keyHeight = keyHeight(LocalConfiguration.current.screenHeightDp)
CompositionLocalProvider(LocalKeyHeight provides keyHeight) {
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. Search/enter takes the
// bottom-right corner, where a thumb expects it; dismiss (when offered) sits left of it.
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 ") }
if (onDismiss != null) {
DismissKey(onDismiss, Modifier.weight(1.4f))
}
SubmitKey(Modifier.weight(1.6f), submitIcon, submitImage, onSubmit)
}
}
}
}
}
@Composable
private fun Key(label: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
GsIconBox(
onClick = onClick,
modifier = modifier.height(LocalKeyHeight.current),
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(LocalKeyHeight.current),
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(LocalKeyHeight.current),
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
* still fit. A 480-tall screen sets the floor; a taller one (LIVI's 1280x720) gets bigger keys.
*/
private fun keyHeight(screenHeightDp: Int): Dp = (screenHeightDp * 0.09f).dp.coerceIn(44.dp, 64.dp)
private val LocalKeyHeight = compositionLocalOf { 44.dp }
private val LETTER_ROWS = listOf(
"qwertyuiop".map { it.toString() },
"asdfghjkl".map { it.toString() },
"zxcvbnm".map { it.toString() },
)
private val SYMBOL_ROWS = listOf(
"1234567890".map { it.toString() },
listOf("-", "/", ":", ";", "(", ")", "$", "&", "@", "\""),
listOf(".", ",", "?", "!", "'", "#", "%", "*", "+", "="),
)
@@ -1,139 +0,0 @@
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
private val _focusMoves = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
/** A knob turn or D-pad press, so controls that hide when left alone can come back. */
val focusMoves: SharedFlow<Unit> = _focusMoves
/** 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) {
_focusMoves.tryEmit(Unit)
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
_focusMoves.tryEmit(Unit)
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.
*/
private fun voice() = main.post {
if (!VoiceReply.onVoiceKey()) CarAssistant.start()
}
/** 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,
)
}
@@ -1,371 +0,0 @@
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.material.icons.filled.KeyboardArrowDown
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.layout.offset
import androidx.compose.foundation.border
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,
/** Known not to work; drawn with a red X instead of being left off the launcher. */
val broken: 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 carApps = CarAppCatalog.installed(context).associateBy { it.component.packageName }
val mediaApps = MediaCatalog.installed(context).associateBy { it.component.packageName }
val messagingApps = MessagingCatalog.installed(context).associateBy { it.packageName }
// One decision per app over everything it offers a car - a car screen, a player, car
// notifications - rather than a filter per kind that has to know about the others.
val tiles = (carApps.keys + mediaApps.keys + messagingApps.keys).flatMap { pkg ->
val icon by lazy { iconOf(pkg) }
tilesFor(carApps[pkg], mediaApps[pkg], messagingApps[pkg]).map { tile ->
when (tile) {
is AppTile.Screen -> Entry(tile.label, icon, template = tile.app)
is AppTile.Player -> Entry(tile.app.label, icon, media = tile.app)
is AppTile.Messages -> Entry(tile.app.label, icon, messaging = tile.app)
}
}
}
return tiles.sortedBy { it.label.lowercase() }.also { flow.value = it }
}
}
/** What one app gets on the launcher. */
private sealed interface AppTile {
class Screen(val app: TemplateApp, val label: String) : AppTile
class Player(val app: app.seb3thehacker.gearslip.media.MediaApp) : AppTile
class Messages(val app: app.seb3thehacker.gearslip.notify.MessagingApp) : AppTile
}
/**
* The tiles for one app, from what it offers.
*
* - A car screen that is something other than media or messaging (RF Sentinel's POI screen) is
* the app: its media service only makes sound, and its notifications are alerts, so it gets the
* one tile under its own name.
* - A media app with both a car screen and a player (Spotify) gets both, the screen labelled
* "· Browse", since they really are different screens - unless the screen is known to refuse
* Gearslip while the player works, when the Browse tile would only report that.
* - A messenger's own car screen (Google Messages) won't start under Gearslip, and Gearslip's
* Messages screen does the job, so it gets only that - and a messenger that is also a media
* app keeps only its player.
*/
private fun tilesFor(
car: TemplateApp?,
media: app.seb3thehacker.gearslip.media.MediaApp?,
messaging: app.seb3thehacker.gearslip.notify.MessagingApp?,
): List<AppTile> {
if (car != null && !car.isMediaScreen && car.kind != "messaging") return listOf(AppTile.Screen(car, car.label))
val pkg = car?.component?.packageName ?: media?.component?.packageName ?: messaging?.packageName ?: return emptyList()
return buildList {
val screenRefused = KnownApps.screenBroken(pkg) && KnownApps.playerWorks(pkg)
val messengerScreen = messaging != null && (car?.kind == "messaging" || car?.kind == "app")
if (car != null && !messengerScreen && !(media != null && screenRefused)) {
add(AppTile.Screen(car, if (media != null) "${car.label} · Browse" else car.label))
}
if (media != null) add(AppTile.Player(media))
else if (messaging != null) add(AppTile.Messages(messaging))
}
}
/** 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, EXIT }
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
BuiltInApps.Exit -> AppGroup.EXIT
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
// Template-only media apps may have no legacy browser/player tile to identify them.
"media" -> AppGroup.MUSIC
"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 context = LocalContext.current
val navigator = LocalCarNavigator.current
val frame by CarEnvironment.frame.collectAsState()
// Painted from the cache at once; scanned only if [Prefetch] has not got there first.
val all = rememberAllEntries()
val running = rememberRunningApps()
val showVehicleData = rememberVehicleDataShown()
fun tileOf(entry: Entry): Tile {
// Only apps that don't work get a mark. The X rates an app's own car screens; a messenger
// shown on Gearslip's own Messages screen has none, so it never gets one.
val pkg = entry.packageName?.takeIf { entry.messaging == null }
val id = entry.componentId
return Tile(
entry.label, entry.icon, entry.builtIn?.glyph,
broken = pkg != null && (KnownApps.isBroken(context, pkg) || (entry.template != null && KnownApps.screenBroken(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) }
}
}
/** A red disc with a white X: marks a tile known not to work with Gearslip yet. */
@Composable
private fun BrokenBadge(size: androidx.compose.ui.unit.Dp, modifier: Modifier = Modifier) {
Box(
modifier.size(size).background(Color(0xFFD32F2F), CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Filled.Close, contentDescription = "Doesn't work with Gearslip", tint = Color.White, modifier = Modifier.size(size * 0.66f))
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun AppTile(tile: Tile) {
val shape = MaterialTheme.shapes.extraLarge
// A long press opens the app's menu (pin, close) - the same gesture everywhere apps are
// shown, here and on the nav bar. GsIconBox marks it in the theme's style.
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,
) {
Box {
TileIcon(tile)
if (tile.broken) {
// Pinned to the icon's lower corner, ringed in the tile's colour so it
// reads as sitting on top of the icon.
BrokenBadge(
22.dp,
Modifier.align(Alignment.BottomEnd).offset(x = 6.dp, y = 4.dp)
.border(2.dp, MaterialTheme.colorScheme.surfaceVariant, CircleShape),
)
}
}
Spacer(Modifier.height(8.dp))
TileLabel(tile)
}
}
}
}
@Composable
private fun TileIcon(tile: Tile) {
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))
}
}
@Composable
private fun TileLabel(tile: Tile, maxLines: Int = 2, modifier: Modifier = Modifier) {
Text(
tile.label,
modifier = modifier,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
maxLines = maxLines,
overflow = TextOverflow.Ellipsis,
)
}
@@ -1,664 +0,0 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.material.icons.filled.Call
import app.seb3thehacker.gearslip.call.CarCalls
import app.seb3thehacker.gearslip.call.CallUiState
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.LocalIndication
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.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalView
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.withTintedSurfaces
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 nav bar along the bottom of the car screen: home, apps, the assistant, running apps,
// the next turn or a call, the player, and the clock. Split out of CarUi.kt.
/**
* "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
internal 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(NAV_BAR_HEIGHT).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() }
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.
// A call takes the player's place until it ends.
val turn = rememberNextTurn().takeIf { navigator.current != CarScreen.Home }
val call = CarCalls.state.collectAsState().value as? CallUiState.Active
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)) {
if (call == null || roomForBoth) turn?.let { NavTurnPill(it) { navigator.home() } }
if (call != null) {
NavCallPill(call, Modifier.weight(1f, fill = false))
} else 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,
)
}
}
}
}
/** The call under way: who, how long, and Mute and End right here. Ringing has its own screen. */
@Composable
private fun NavCallPill(call: CallUiState.Active, modifier: Modifier = Modifier) {
val context = LocalContext.current.applicationContext
val muted by CarCalls.muted.collectAsState()
val elapsed by produceState(0L, call.since) {
while (true) {
value = (android.os.SystemClock.uptimeMillis() - call.since) / 1000
delay(1000 - (android.os.SystemClock.uptimeMillis() - call.since) % 1000)
}
}
val scheme = MaterialTheme.colorScheme
Surface(
modifier = modifier.height(44.dp),
shape = RoundedCornerShape(14.dp),
color = scheme.secondaryContainer,
contentColor = scheme.onSecondaryContainer,
) {
Row(
Modifier.fillMaxHeight().padding(start = 12.dp, end = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(Icons.Filled.Call, contentDescription = null, modifier = Modifier.size(20.dp))
Text(
call.caller?.label ?: "On a call",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
Text(
"%d:%02d".format(elapsed / 60, elapsed % 60),
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
)
Spacer(Modifier.width(2.dp))
GsIconButton(
if (muted) MediaIcons.MicOff else MediaIcons.Mic,
if (muted) "Unmute" else "Mute",
CarCalls::toggleMute,
tone = if (muted) GsTone.Primary else GsTone.Neutral, size = 36.dp, iconSize = 22.dp,
)
GsIconButton(
MediaIcons.CallEnd, "End call", { CarCalls.end(context) },
colors = GsColors(scheme.error, scheme.onError), size = 36.dp, iconSize = 22.dp,
)
}
}
}
/** "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)
}
}
@@ -1,156 +0,0 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
sealed interface CarScreen {
data object Home : CarScreen
data object Settings : CarScreen
data object Apps : CarScreen
/** Opened from the clock: the day's calendar and the weather, nothing that needs a keyboard. */
data object Dashboard : CarScreen
/** Opened from the weather chip or the dashboard: now, the next hours and the week. */
data object Weather : CarScreen
/** 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 }
@@ -1,81 +0,0 @@
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
/** Raw sensor readings, keyed by SensorBatch field number, which matches the sensor type id. */
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)
}
}
/** Each SensorBatch field contains repeated readings of one sensor type. */
fun onEvent(body: ByteArray) {
val now = System.currentTimeMillis()
val fields = Wire.fields(body).filter { it.wireType == 2 }
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 values shared by the discovery service and SensorBatch. */
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 "GPS satellites",
22 to "Toll card",
23 to "Vehicle energy model",
24 to "Trailer",
25 to "Raw vehicle energy model",
26 to "Raw EV trip settings",
)
fun name(id: Int): String = NAMES[id]?.let { "$it (#$id)" } ?: "Sensor #$id"
}
@@ -1,290 +0,0 @@
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)) }
/** [nav], or null before the car screen has set things up. */
fun navOrNull(): CarAppConnection? = if (context != null) nav else null
/** [media], or null before the car screen has set things up: for code outside the car UI. */
fun mediaOrNull(): CarMedia? = if (context != null) media else null
/**
* 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
/**
* The app that was playing when the driver opened another one. Opening an app is only a look
* at its library: until something plays there, the music carries on in this one, so leaving
* the new app's screen goes back to it, as Android Auto does.
*/
private var stillPlaying: MediaApp? = null
fun init(appContext: Context) {
context = appContext.applicationContext
app.seb3thehacker.gearslip.notify.CarMotion.start(appContext)
app.seb3thehacker.gearslip.call.CarCalls.start(appContext)
CarAssistant.init(appContext)
DirectionsVoice.init(appContext)
CarBluetooth.init(appContext)
BluetoothAudioCheck.init(appContext)
LocationCheck.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 {
// Another map app taking over ends the old one's route, or it keeps guiding unseen.
if (nav.connectedComponent != app.component) endRoute()
nav.connect(app, frame.width, frame.height, frame.densityDpi, destination)
}
}
fun stopNav() {
navStopped = true
endRoute()
nav.disconnect()
}
private fun endRoute() {
if (nav.navigating) nav.stopNavigating()
}
/** 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
val previous = _mediaApp.value
stillPlaying = previous?.takeIf { it.component != app.component && media.now.value.playing && !autoplay }
_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()
}
/**
* Reopens the media app used last, for the car's media button when nothing is open. False
* when there's none on record or it's been uninstalled since.
*/
suspend fun reopenLastMedia(): Boolean {
val ctx = context ?: return false
val last = CarSettings.lastMedia.value?.let(ComponentName::unflattenFromString) ?: return false
val app = withContext(Dispatchers.IO) { MediaCatalog.installed(ctx) }
.firstOrNull { it.component == last } ?: return false
openMedia(app)
return true
}
/**
* The media screen closed. If nothing started in the app it showed, go back to the one that
* kept playing meanwhile, so the player and its buttons follow the music you can hear.
*/
fun mediaScreenClosed() {
val back = stillPlaying ?: return
stillPlaying = null
// A track loaded here, even one paused since, means the driver used this app.
if (media.now.value.playing || media.now.value.title.isNotEmpty()) return
app.seb3thehacker.gearslip.GearslipLog.i("media: nothing played in ${_mediaApp.value?.label}, back to ${back.label}")
openMedia(back)
}
/** 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
stillPlaying = null
}
/** 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(ctx, 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 mid-song: pause it, as Android Auto does, so it doesn't carry on out of
* the phone's speaker. Unplugging a USB accessory isn't "audio becoming noisy" the way
* headphones are, so the apps won't pause themselves. Every playing session is paused, not
* just the one Gearslip opened: anything the car was playing was going through the car.
*/
fun pausePhoneMedia(why: String = "the car left") {
val ctx = context ?: return
val manager = ctx.getSystemService(MediaSessionManager::class.java)
val listener = ComponentName(ctx, GearslipNotificationListener::class.java)
val playing = try {
manager.getActiveSessions(listener).filter { it.playbackState?.state == PlaybackState.STATE_PLAYING }
} catch (e: SecurityException) {
null
}
if (playing == null) {
// No notification access: the app Gearslip itself is driving is all it can reach.
mediaOrNull()?.let { if (it.now.value.playing) it.togglePlay() }
return
}
playing.forEach { runCatching { it.transportControls.pause() } }
if (playing.isNotEmpty()) {
app.seb3thehacker.gearslip.GearslipLog.i("media: paused ${playing.joinToString { it.packageName }}: $why")
}
}
/** 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()
// About 900 MB that nothing needs once the car's gone.
app.seb3thehacker.gearslip.speech.SpeechEngine.unload()
CarAudio.release()
_mediaApp.value = null
navStopped = false
}
}
@@ -1,472 +0,0 @@
package app.seb3thehacker.gearslip.car
import android.content.Context
import app.seb3thehacker.gearslip.AppSettings
import app.seb3thehacker.gearslip.audio.CarAudio
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 }
/** A screen's width and height in pixels, as someone typed it: "1280x480". */
data class ScreenSize(val width: Int, val height: Int) {
override fun toString() = "${width}x$height"
companion object {
fun parse(text: String?): ScreenSize? {
val parts = text?.lowercase()?.split('x', '×', ' ')?.filter { it.isNotBlank() } ?: return null
if (parts.size != 2) return null
val w = parts[0].trim().toIntOrNull() ?: return null
val h = parts[1].trim().toIntOrNull() ?: return null
return if (w in 100..8000 && h in 100..8000) ScreenSize(w, h) else null
}
}
}
/**
* How a head unit puts our frame on a screen of another shape. [CROP] cuts the edges off, as
* a car's own margins do; [SQUASH] shrinks the whole frame to fit, stretching everything.
*/
enum class ScreenFit { CROP, SQUASH }
/** What size the phone's car preview draws: the last car's, this device's whole screen, or one typed in. */
enum class PreviewSize { LAST_CAR, FILL, CUSTOM }
/** When the nav bar gets out of the way: never, during a route, or whenever the map is up. */
enum class NavBarMode { SHOW, HIDE_NAVIGATING, HIDE }
/**
* 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_SPEAK_DIRECTIONS = "car_speak_directions"
private const val KEY_BLUETOOTH_WARNING = "car_bluetooth_warning"
private const val KEY_LOCATION_WARNING = "car_location_warning"
private const val KEY_AUDIO_CHOICE_ASKED = "car_audio_choice_asked"
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_PLAYER_LEFT = "car_player_left"
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_SYSTEM_SPEECH = "car_system_speech"
private const val KEY_CAR_SENSORS = "car_sensors_enabled"
private const val KEY_ADAPTIVE_VIDEO = "adaptive_video_enabled"
private const val KEY_DEBUG_BADGE = "debug_badge"
private const val KEY_SEEN_SAFETY_WARNING = "car_seen_safety_warning"
private const val KEY_UNITS = "car_units"
private const val KEY_NAV_BAR = "car_nav_bar"
private const val KEY_SCREEN_SIZE = "car_screen_size"
private const val KEY_SCREEN_FIT = "car_screen_fit"
private const val KEY_PREVIEW_SIZE = "preview_size"
private const val KEY_PREVIEW_CUSTOM = "preview_custom_size"
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 speakDirectionsFlow = MutableStateFlow(true)
private val bluetoothWarningFlow = MutableStateFlow(true)
private val locationWarningFlow = 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 playerLeftFlow = 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 systemSpeechFlow = MutableStateFlow(false)
private val carSensorsFlow = MutableStateFlow(false)
private val adaptiveVideoFlow = MutableStateFlow(false)
private val debugBadgeFlow = MutableStateFlow(true)
private val seenSafetyWarningFlow = MutableStateFlow(false)
private val unitsFlow = MutableStateFlow(Units.AUTO)
val units: StateFlow<Units> = unitsFlow
private val screenSizeFlow = MutableStateFlow<ScreenSize?>(null)
private val screenFitFlow = MutableStateFlow(ScreenFit.CROP)
private val previewSizeFlow = MutableStateFlow(PreviewSize.LAST_CAR)
private val previewCustomFlow = MutableStateFlow<ScreenSize?>(null)
/** The car screen's real size, when the head unit gets it wrong; null trusts the head unit. */
val screenSize: StateFlow<ScreenSize?> = screenSizeFlow
val screenFit: StateFlow<ScreenFit> = screenFitFlow
val previewSize: StateFlow<PreviewSize> = previewSizeFlow
val previewCustom: StateFlow<ScreenSize?> = previewCustomFlow
/** Takes effect the next time the car connects: the video size is agreed at the start. */
fun setScreenSize(size: ScreenSize?) {
screenSizeFlow.value = size
AppSettings.putString(app, KEY_SCREEN_SIZE, size?.toString().orEmpty())
}
fun setScreenFit(fit: ScreenFit) {
screenFitFlow.value = fit
AppSettings.putString(app, KEY_SCREEN_FIT, fit.name)
}
fun setPreviewSize(size: PreviewSize, custom: ScreenSize? = previewCustomFlow.value) {
previewSizeFlow.value = size
previewCustomFlow.value = custom
AppSettings.putString(app, KEY_PREVIEW_SIZE, size.name)
AppSettings.putString(app, KEY_PREVIEW_CUSTOM, custom?.toString().orEmpty())
}
private val navBarFlow = MutableStateFlow(NavBarMode.SHOW)
val navBarMode: StateFlow<NavBarMode> = navBarFlow
fun setNavBarMode(mode: NavBarMode) {
navBarFlow.value = mode
AppSettings.putString(app, KEY_NAV_BAR, mode.name)
}
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
/** The home screen's player sits left of the map instead of right of it. */
val playerLeft: StateFlow<Boolean> = playerLeftFlow
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 ("Bluetooth" in the settings), the app plays through the
* phone's normal output instead, and Android never asks.
*/
val pipeAudio: StateFlow<Boolean> = pipeAudioFlow
/** Gearslip reads the map app's turns aloud on the car's directions channel; see DirectionsVoice. */
val speakDirections: StateFlow<Boolean> = speakDirectionsFlow
/** With Bluetooth audio, warn when the phone isn't connected to the car; see BluetoothAudioCheck. */
val bluetoothWarning: StateFlow<Boolean> = bluetoothWarningFlow
/** Warn on the car when the phone's location is off; see LocationCheck. */
val locationWarning: StateFlow<Boolean> = locationWarningFlow
/**
* Whether the driver has already been offered Bluetooth after turning down the capture
* dialog. Offered once only: someone who chose to keep the USB route shouldn't be nagged.
*/
/** False until the driver picks USB or Bluetooth, in setup or in settings. */
val pipeAudioChosen: Boolean
get() = AppSettings.getString(app, KEY_PIPE_AUDIO, "") != ""
val audioChoiceAsked: Boolean
get() = AppSettings.getString(app, KEY_AUDIO_CHOICE_ASKED, "false") == "true"
fun markAudioChoiceAsked() = AppSettings.putString(app, KEY_AUDIO_CHOICE_ASKED, "true")
/**
* 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
/** Listen with the phone's own speech service instead of Gearslip's on-device model. */
val systemSpeech: StateFlow<Boolean> = systemSpeechFlow
/**
* 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
/**
* Video bitrate that backs off when the car, or the Wi-Fi to it, falls behind. Takes effect
* only while [experimentalFeaturesEnabled] is on too; untested in a car so far.
*/
val adaptiveVideoEnabled: StateFlow<Boolean> = adaptiveVideoFlow
/** Debug builds only: the car screen's DEBUG BUILD badge, which screenshots for the README leave out. */
val debugBadge: StateFlow<Boolean> = debugBadgeFlow
fun setDebugBadge(on: Boolean) {
debugBadgeFlow.value = on
AppSettings.putString(app, KEY_DEBUG_BADGE, on.toString())
}
/**
* 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"
playerLeftFlow.value = AppSettings.getString(app, KEY_PLAYER_LEFT, "false") == "true"
pipeAudioFlow.value = AppSettings.getString(app, KEY_PIPE_AUDIO, "true") == "true"
// Until the driver sets it, on for USB audio and off for Bluetooth, where the map app's
// own voice already reaches the car.
speakDirectionsFlow.value = AppSettings.getString(app, KEY_SPEAK_DIRECTIONS, pipeAudioFlow.value.toString()) == "true"
bluetoothWarningFlow.value = AppSettings.getString(app, KEY_BLUETOOTH_WARNING, "true") == "true"
locationWarningFlow.value = AppSettings.getString(app, KEY_LOCATION_WARNING, "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"
systemSpeechFlow.value = AppSettings.getString(app, KEY_SYSTEM_SPEECH, "false") == "true"
carSensorsFlow.value = AppSettings.getString(app, KEY_CAR_SENSORS, "false") == "true"
adaptiveVideoFlow.value = AppSettings.getString(app, KEY_ADAPTIVE_VIDEO, "false") == "true"
debugBadgeFlow.value = AppSettings.getString(app, KEY_DEBUG_BADGE, "true") == "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)
screenSizeFlow.value = ScreenSize.parse(AppSettings.getString(app, KEY_SCREEN_SIZE, ""))
screenFitFlow.value = runCatching {
ScreenFit.valueOf(AppSettings.getString(app, KEY_SCREEN_FIT, ScreenFit.CROP.name))
}.getOrDefault(ScreenFit.CROP)
previewSizeFlow.value = runCatching {
PreviewSize.valueOf(AppSettings.getString(app, KEY_PREVIEW_SIZE, PreviewSize.LAST_CAR.name))
}.getOrDefault(PreviewSize.LAST_CAR)
previewCustomFlow.value = ScreenSize.parse(AppSettings.getString(app, KEY_PREVIEW_CUSTOM, ""))
navBarFlow.value = runCatching {
NavBarMode.valueOf(AppSettings.getString(app, KEY_NAV_BAR, NavBarMode.SHOW.name))
}.getOrDefault(NavBarMode.SHOW)
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 setPlayerLeft(on: Boolean) {
playerLeftFlow.value = on
AppSettings.putString(app, KEY_PLAYER_LEFT, on.toString())
}
fun setAutoplay(on: Boolean) {
autoplayFlow.value = on
AppSettings.putString(app, KEY_AUTOPLAY, on.toString())
}
/**
* Takes effect at once: switching to Bluetooth mid-drive stops the capture, and switching
* back asks for it again (or waits for the car, if none is connected).
*/
fun setPipeAudio(on: Boolean) {
// Saved even when unchanged, so picking the default still counts as a choice.
AppSettings.putString(app, KEY_PIPE_AUDIO, on.toString())
if (pipeAudioFlow.value == on) return
pipeAudioFlow.value = on
if (AppSettings.getString(app, KEY_SPEAK_DIRECTIONS, "") == "") speakDirectionsFlow.value = on
if (on) CarAudio.request() else CarAudio.release()
}
fun setSpeakDirections(on: Boolean) {
speakDirectionsFlow.value = on
AppSettings.putString(app, KEY_SPEAK_DIRECTIONS, on.toString())
}
fun setBluetoothWarning(on: Boolean) {
bluetoothWarningFlow.value = on
AppSettings.putString(app, KEY_BLUETOOTH_WARNING, on.toString())
}
fun setLocationWarning(on: Boolean) {
locationWarningFlow.value = on
AppSettings.putString(app, KEY_LOCATION_WARNING, on.toString())
}
fun setExperimentalFeaturesEnabled(on: Boolean) {
experimentalFeaturesFlow.value = on
AppSettings.putString(app, KEY_EXPERIMENTAL_FEATURES, on.toString())
}
fun setSystemSpeech(on: Boolean) {
systemSpeechFlow.value = on
AppSettings.putString(app, KEY_SYSTEM_SPEECH, on.toString())
}
fun setAdaptiveVideoEnabled(on: Boolean) {
adaptiveVideoFlow.value = on
AppSettings.putString(app, KEY_ADAPTIVE_VIDEO, 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(","))
}
}
@@ -1,549 +0,0 @@
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.PaddingValues
import androidx.compose.foundation.lazy.LazyColumn
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.Box
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 navBar by CarSettings.navBarMode.collectAsState()
val screenSize by CarSettings.screenSize.collectAsState()
val screenFit by CarSettings.screenFit.collectAsState()
val whatsNew by CarSettings.whatsNewAfterUpdates.collectAsState()
val pipeAudio by CarSettings.pipeAudio.collectAsState()
val speakDirections by CarSettings.speakDirections.collectAsState()
val bluetoothWarning by CarSettings.bluetoothWarning.collectAsState()
val locationWarning by CarSettings.locationWarning.collectAsState()
val autoplay by CarSettings.autoplay.collectAsState()
val playerTitle by CarSettings.playerTitle.collectAsState()
val playerLeft by CarSettings.playerLeft.collectAsState()
val theme by CarSettings.appTheme.collectAsState()
val uiTheme by CarSettings.uiTheme.collectAsState()
val experimentalFeatures by CarSettings.experimentalFeaturesEnabled.collectAsState()
val carSensors by CarSettings.carSensorsEnabled.collectAsState()
val adaptiveVideo by CarSettings.adaptiveVideoEnabled.collectAsState()
val systemSpeech by CarSettings.systemSpeech.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) }
}
LazyColumn(
Modifier.fillMaxSize(),
// Built as it scrolls into view rather than all at once: every row here is a set of
// buttons, and building the whole screen made opening Settings visibly slow.
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
item {
ScreenHeader("Settings")
}
item { Group("Screen") }
item {
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) }
}
item {
ChoiceRow(
"Size",
listOf(1f to "Normal", 1.25f to "Large", 1.5f to "Extra large"),
scale,
) { CarSettings.setScale(it) }
}
item {
ChoiceRow(
"App theme",
listOf(AppTheme.PHONE to "Follow phone", AppTheme.LIGHT to "Light", AppTheme.DARK to "Dark"),
theme,
) { CarSettings.setAppTheme(it) }
}
item {
ChoiceRow(
"Theme",
GsThemes.builtIn.map { it.id to it.name },
uiTheme,
) { CarSettings.setUiTheme(it) }
}
item {
ChoiceRow(
"Map day/night",
listOf(NightMode.AUTO to "Auto", NightMode.DAY to "Day", NightMode.NIGHT to "Night"),
night,
) { CarSettings.setNightMode(it) }
}
item {
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) }
}
// For a head unit that reports the wrong size and stretches the picture (issue #12).
item {
ChoiceRow(
"Screen size (next connection)",
listOf<ScreenSize?>(null).plus(SCREEN_PRESETS).plus(screenSize).distinct()
.map { it to (it?.toString() ?: "From the car") },
screenSize,
) { CarSettings.setScreenSize(it) }
}
if (screenSize != null) item {
ChoiceRow(
"Fit to the screen",
listOf(ScreenFit.CROP to "Crop the edges", ScreenFit.SQUASH to "Squash to fit"),
screenFit,
) { CarSettings.setScreenFit(it) }
}
item { Group("Nav bar and player") }
item {
ChoiceRow(
"Nav bar",
listOf(
NavBarMode.SHOW to "Always show",
NavBarMode.HIDE_NAVIGATING to "Hide while navigating",
NavBarMode.HIDE to "Hide until tapped",
),
navBar,
) { CarSettings.setNavBarMode(it) }
}
item {
ChoiceRow(
"Player in the nav bar",
listOf(false to "Skip buttons", true to "Song title"),
playerTitle,
) { CarSettings.setPlayerTitle(it) }
}
item {
ChoiceRow(
"Player beside the map",
listOf(false to "Right", true to "Left"),
playerLeft,
) { CarSettings.setPlayerLeft(it) }
}
item {
ChoiceRow(
"Autoplay on connect",
listOf(false to "Off", true to "On"),
autoplay,
) { CarSettings.setAutoplay(it) }
}
item { Group("Sound and voice") }
item {
ChoiceRow(
"Car audio",
listOf(false to "Bluetooth", true to "USB"),
pipeAudio,
) { CarSettings.setPipeAudio(it) }
}
item {
Text(
"USB sends the phone's sound through Gearslip. Android asks to share your screen each " +
"drive, and a few apps block it. Bluetooth plays as the phone normally would, and " +
"Android never asks.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
item { Group("Warnings") }
if (!pipeAudio) item {
ChoiceRow(
"Warn when Bluetooth isn't connected",
listOf(false to "Off", true to "On"),
bluetoothWarning,
) { CarSettings.setBluetoothWarning(it) }
}
item {
ChoiceRow(
"Warn when location is off",
listOf(false to "Off", true to "On"),
locationWarning,
) { CarSettings.setLocationWarning(it) }
}
item {
ChoiceRow(
"Spoken directions",
listOf(false to "Off", true to "On"),
speakDirections,
) { CarSettings.setSpeakDirections(it) }
}
item {
Text(
"Gearslip reads each turn aloud through the car's speakers, and the music dips while it " +
"speaks. Turn it off if your map app's own voice already plays in the car.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
item {
ExperimentalFeatureRow(
title = "Use the phone's speech recognition",
description = "Listens through the phone's own speech service instead of Gearslip's. On " +
"most phones that's Google's, which may send your voice to Google. GrapheneOS has " +
"none, so Gearslip keeps using its own.",
checked = systemSpeech,
) { CarSettings.setSystemSpeech(it) }
}
item { Group("More") }
item {
WebPageSetting()
}
item {
ScreenFit(vehicle)
}
item {
ChoiceRow(
"Experimental features",
listOf(false to "Hide", true to "Show"),
experimentalFeatures,
) { CarSettings.setExperimentalFeaturesEnabled(it) }
}
if (experimentalFeatures) item {
ExperimentalFeaturesList(carSensors, adaptiveVideo)
}
item {
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…")
}
}
item {
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) {
// On a wide screen the name sits beside its choices, which halves how far there is to scroll.
androidx.compose.foundation.layout.BoxWithConstraints(Modifier.fillMaxWidth()) {
if (maxWidth >= 720.dp && options.size <= 4) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Text(title, style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(0.3f))
Box(Modifier.weight(0.7f)) { ChoiceButtons(options, selected, onSelect) }
}
} else {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(title, style = MaterialTheme.typography.titleMedium)
ChoiceButtons(options, selected, onSelect)
}
}
}
}
/** A group of settings: a heading, so a long list reads as a few short ones. */
@Composable
private fun Group(title: String) {
Text(
title,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 12.dp),
)
}
@Composable
private fun <T> ChoiceButtons(options: List<Pair<T, String>>, selected: T, onSelect: (T) -> Unit) {
run {
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(carSensors: Boolean, adaptiveVideo: Boolean) {
Column(
Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
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) }
ExperimentalFeatureRow(
title = "Adaptive video quality",
description = "Lowers the picture's quality when the car, or its Wi-Fi, can't keep " +
"up, and raises it again once it can. Takes effect on the next connection.",
checked = adaptiveVideo,
) { CarSettings.setAdaptiveVideoEnabled(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 Browser 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 Browser 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)
}
}
/** Common car screen sizes; Settings on the phone takes any other. */
private val SCREEN_PRESETS = listOf(
ScreenSize(800, 480), ScreenSize(1024, 600), ScreenSize(1280, 480), ScreenSize(1280, 720),
ScreenSize(1440, 540), ScreenSize(1920, 720), ScreenSize(1920, 1080),
)
@@ -1,62 +0,0 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
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.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalAccessibilityManager
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
/** Draw inside the encoded car display; a normal Android Toast would appear on the phone. */
@Composable
internal fun CarToastOverlay(modifier: Modifier = Modifier) {
val current by CarToasts.message.collectAsState()
val message = current ?: return
val accessibility = LocalAccessibilityManager.current
LaunchedEffect(message, accessibility) {
val timeout = accessibility?.calculateRecommendedTimeoutMillis(
message.durationMs, containsIcons = false, containsText = true, containsControls = false,
) ?: message.durationMs
// The full time, unless another toast is waiting: then it gives way after the minimum.
delay(minOf(timeout, CarToasts.MIN_SHOWN_MS))
withTimeoutOrNull((timeout - CarToasts.MIN_SHOWN_MS).coerceAtLeast(0)) {
CarToasts.waiting.first { it > 0 }
}
CarToasts.dismiss(message)
}
// A non-interactive overlay leaves the map's touch handling and focus intact.
Box(
modifier
.padding(horizontal = 24.dp, vertical = 16.dp)
.widthIn(max = 560.dp)
.clip(RoundedCornerShape(20.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
.semantics { liveRegion = LiveRegionMode.Polite },
) {
Text(
message.text,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.titleMedium,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
}
@@ -1,65 +0,0 @@
package app.seb3thehacker.gearslip.car
import androidx.annotation.MainThread
import androidx.car.app.CarToast
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* One toast at a time across navigation and browse apps. A newer one waits its turn instead of
* replacing the one on screen, which gets at least [MIN_SHOWN_MS] before it gives way.
*/
@MainThread
internal object CarToasts {
// Identity matters: repeating identical text must restart its display timer.
class Message(val owner: Any, val text: String, val durationMs: Long)
/** Long enough to read a short toast at a glance; a waiting toast cuts the rest short. */
const val MIN_SHOWN_MS = 2_000L
/** A burst beyond this many is stale by the time it would show; the oldest waiting go first. */
private const val MAX_WAITING = 3
private val current = MutableStateFlow<Message?>(null)
val message: StateFlow<Message?> = current.asStateFlow()
private val queue = ArrayDeque<Message>()
private val _waiting = MutableStateFlow(0)
val waiting: StateFlow<Int> = _waiting.asStateFlow()
fun show(owner: Any, text: String, duration: Int) {
if (text.isBlank()) return
// Longer than a phone toast: the driver only glances at the screen now and then.
val durationMs = if (duration == CarToast.LENGTH_LONG) 7_000L else 4_000L
val next = Message(owner, text, durationMs)
val shown = current.value
when {
shown == null -> current.value = next
// The same words again restart the one on screen rather than queueing a copy.
shown.text == text -> current.value = next
queue.lastOrNull()?.text == text -> Unit
else -> {
queue.addLast(next)
while (queue.size > MAX_WAITING) queue.removeFirst()
}
}
_waiting.value = queue.size
}
fun dismiss(message: Message) {
// An old timer must not dismiss a newer toast, even if its text is identical.
if (current.value === message) advance()
}
fun clear(owner: Any) {
// Disconnecting the map must not clear a toast belonging to the browse app.
queue.removeAll { it.owner === owner }
if (current.value?.owner === owner) advance() else _waiting.value = queue.size
}
private fun advance() {
current.value = queue.removeFirstOrNull()
_waiting.value = queue.size
}
}
@@ -1,487 +0,0 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.material.icons.filled.Call
import app.seb3thehacker.gearslip.call.CarCalls
import app.seb3thehacker.gearslip.call.CallUiState
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.LocalIndication
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.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalView
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.withTintedSurfaces
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)).withTintedSurfaces(dark)
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()
CarFullscreen.tick()
}
}
// The knob counts as a touch: the map's buttons can't be found while they're faded out.
LaunchedEffect(controlsWake) { CarKeys.focusMoves.collect { controlsWake.touch() } }
remember(appContext) { Prefetch.warm(appContext, force = true); CarServices.init(appContext) }
DisposableEffect(Unit) { onDispose { CarServices.shutdown() } }
// Ask for the phone's sound as soon as the car connects, not only when a media app opens:
// a video in the web browser or a map's voice needs it too. Asks once, after the car offers
// somewhere to play it.
LaunchedEffect(Unit) { if (CarSettings.pipeAudio.value) app.seb3thehacker.gearslip.audio.CarAudio.request() }
// A new version gets one card in the top right, a few seconds in and only while parked.
LaunchedEffect(Unit) { app.seb3thehacker.gearslip.update.UpdateChecker.onCarConnected(appContext) }
// 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()
}
// Home is the dashboard, over the map, so Back from it lands on the map.
CarKeys.ScreenKey.HOME -> { navigator.home(); navigator.dashboard() }
CarKeys.ScreenKey.MAP -> navigator.home()
CarKeys.ScreenKey.MEDIA ->
if (CarServices.mediaApp.value != null || CarServices.reopenLastMedia()) navigator.media()
else navigator.apps()
CarKeys.ScreenKey.PHONE -> BuiltInApps.Phone.open(navigator)
}
}
}
// The car screen is drawn on the phone, so every long press or text-handle drag would buzz
// the phone in its mount. Nobody feels that while driving; it's only noise.
val view = LocalView.current
DisposableEffect(view) {
view.isHapticFeedbackEnabled = false
onDispose { }
}
CompositionLocalProvider(
LocalHapticFeedback provides NoHaptics,
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) {
CompositionLocalProvider(LocalIndication provides FocusRingIndication(MaterialTheme.colorScheme.primary)) {
Surface(
Modifier
.fillMaxSize()
// Watches every touch without taking any: first in line, consumes nothing.
.pointerInput(controlsWake) {
// In full screen, a swipe that starts at the top or bottom edge brings the
// nav bar back. Only from the very edge, so scrolling a page still scrolls.
val edge = 32.dp.toPx()
val slop = 24.dp.toPx()
var startY = -1f
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
controlsWake.touch()
CarFullscreen.touch()
val change = event.changes.firstOrNull() ?: continue
if (!CarFullscreen.active) continue
if (change.pressed && !change.previousPressed) {
val y = change.position.y
startY = if (y < edge || y > size.height - edge) y else -1f
} else if (startY >= 0f && change.pressed && kotlin.math.abs(change.position.y - startY) > slop) {
CarFullscreen.reveal()
startY = -1f
}
}
}
},
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()
val keyboardVisible by CarKeyboardVisibility.state
// Off "Always show", the bar floats over the map rather than taking a row of its
// own: hiding it then leaves the map's surface the size it was. Resizing it would
// hand the map app a new surface each time, and some go black on that.
val navBarMode by CarSettings.navBarMode.collectAsState()
val appTemplate by CarServices.nav.template.collectAsState()
val floatingBar = navBarMode != NavBarMode.SHOW
val barMayHide = screen == CarScreen.Home && when (navBarMode) {
NavBarMode.SHOW -> false
NavBarMode.HIDE_NAVIGATING -> appTemplate is androidx.car.app.navigation.model.NavigationTemplate
NavBarMode.HIDE -> true
}
// A full wait from when hiding becomes possible, not from whenever the last touch was.
LaunchedEffect(barMayHide) { if (barMayHide) controlsWake.touch() }
// A call keeps the bar up whatever the setting: End and Mute live on it.
val onCall = CarCalls.state.collectAsState().value is CallUiState.Active
val barShown = !keyboardVisible && (!barMayHide || controlsWake.navBarAwake || onCall)
val homeInset = if (floatingBar && barShown) NAV_BAR_HEIGHT else 0.dp
// Only the screen that asked: going Home or anywhere else ends it.
val fullscreen = CarFullscreen.active && screen != CarScreen.Home
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.
CompositionLocalProvider(LocalNavBarInset provides homeInset) { CarHome() }
}
// 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.
if (!floatingBar && !keyboardVisible) CarNavBar(navigator)
}
// Every other screen, and everything that pops up, in a layer over Home rather
// than inside its area: full screen grows this layer over the nav bar's row while
// Home, and the map's surface in it, keeps its size.
if (safetyWarningSeen) {
Box(
Modifier
.fillMaxSize()
.padding(bottom = if (!floatingBar && !keyboardVisible && !fullscreen) NAV_BAR_HEIGHT else 0.dp),
) {
if (screen != CarScreen.Home) {
Box(
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
// The floating bar never hides here, so keep the screen clear of it.
.padding(bottom = if (floatingBar && !keyboardVisible && !fullscreen) NAV_BAR_HEIGHT else 0.dp)
// 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()
}
}
}
CarToastOverlay(Modifier.align(Alignment.BottomCenter).padding(bottom = homeInset))
CallPromptOverlay()
BluetoothPairOverlay()
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.
VoiceHelpOverlay(Modifier.align(Alignment.Center))
CallOverlay()
AssistantOverlay(Modifier.align(Alignment.BottomCenter).padding(bottom = homeInset))
val badge by CarSettings.debugBadge.collectAsState()
if (app.seb3thehacker.gearslip.BuildConfig.DEBUG && badge) {
// 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.
DebugBuildBadge(Modifier.align(Alignment.BottomEnd).padding(bottom = homeInset))
}
if (floatingBar || fullscreen) {
androidx.compose.animation.AnimatedVisibility(
if (fullscreen) CarFullscreen.revealed || onCall else barShown,
Modifier.align(Alignment.BottomCenter),
enter = androidx.compose.animation.slideInVertically { it },
exit = androidx.compose.animation.slideOutVertically { it },
) { CarNavBar(navigator) }
}
if (fullscreen) {
androidx.compose.animation.AnimatedVisibility(
CarFullscreen.revealed,
Modifier.align(Alignment.TopCenter).padding(top = 12.dp),
enter = androidx.compose.animation.slideInVertically { -it },
exit = androidx.compose.animation.slideOutVertically { -it },
) {
GsButton(onClick = CarFullscreen::exit, modifier = Modifier.height(48.dp)) {
Icon(MediaIcons.FullscreenExit, contentDescription = null, modifier = Modifier.size(22.dp))
Spacer(Modifier.width(8.dp))
Text("Exit full screen", style = MaterialTheme.typography.titleMedium)
}
}
}
}
}
// Over everything, nav bar included: a long press on an app, anywhere.
AppMenu(navigator, rememberRunningApps())
if (safetyWarningSeen) CarWhatsNew()
}
}
}
}
}
}
internal val NAV_BAR_HEIGHT = 56.dp
/**
* How much of the bottom of Home the floating nav bar covers right now: its height while it shows,
* zero while it's hidden or when it has a row of its own.
*/
internal val LocalNavBarInset = androidx.compose.runtime.compositionLocalOf { 0.dp }
/**
* 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) {
// The card and its button share one width, capped so neither sprawls across a wide screen.
Column(
Modifier.widthIn(max = 640.dp).fillMaxWidth().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerLow,
shape = MaterialTheme.shapes.extraLarge,
modifier = Modifier.fillMaxWidth(),
) {
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 DebugBuildBadge(modifier: Modifier = Modifier) {
Surface(
modifier = modifier.padding(6.dp),
color = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
shape = MaterialTheme.shapes.small,
) {
Text(
"DEBUG BUILD",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
)
}
}
/** Stands in for the phone's vibration motor on the car screen: does nothing. */
private object NoHaptics : HapticFeedback {
override fun performHapticFeedback(hapticFeedbackType: HapticFeedbackType) {}
}
@@ -1,113 +0,0 @@
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)
}
}
}
}
}
}
@@ -1,221 +0,0 @@
package app.seb3thehacker.gearslip.car
import androidx.car.app.model.CarText
import androidx.car.app.model.Distance
import androidx.car.app.navigation.model.Maneuver
import androidx.car.app.navigation.model.NavigationTemplate
import androidx.car.app.navigation.model.RoutingInfo
import androidx.car.app.navigation.model.Step
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.Protobuf
/**
* The next turn on the car's instrument cluster, over its NavigationStatusService (Service
* field 8), the way Android Auto feeds it:
*
* -> NavigationStatus { status = 1 (1 active, 2 inactive, 3 rerouting) }
* -> NavigationNextTurnEvent { road = 1; turn_side = 2; event = 3; turn_number = 5; turn_angle = 6 }
* -> NavigationNextTurnDistanceEvent { distance_meters = 1; time_to_turn_seconds = 2;
* display_distance_e3 = 3; display_distance_unit = 4 }
*
* Newer cars also take a NavigationState message; the deprecated pair above is what a 2018
* Uconnect understands. The turn comes from the map app's trip (what the Car App Library hands
* to clusters), or from its routing card when it sends no trip.
*/
class ClusterNav(
val channelId: Int,
minimumIntervalMs: Long = 0,
private val sendOnChannel: (messageId: Int, body: ByteArray) -> Unit,
) {
private val updateIntervalMs = maxOf(TICK_MS, minimumIntervalMs)
@Volatile private var running = false
private var worker: Thread? = null
private var updatesRequested = true
private var sentStatus = -1
private var sentTurn: String? = null
private var sentDistance: String? = null
@Synchronized
fun onMessage(messageId: Int, body: ByteArray) {
when (messageId) {
MSG_CHANNEL_OPEN_RESPONSE -> {
val status = Protobuf.readInt32Field(body, 1)
GearslipLog.i("cluster: <- ChannelOpenResponse: status=$status")
if (status == 0) start()
}
MSG_START -> {
updatesRequested = true
sentStatus = -1
sentTurn = null
sentDistance = null
GearslipLog.i("cluster: <- start")
}
MSG_STOP -> {
updatesRequested = false
GearslipLog.i("cluster: <- stop")
}
else -> GearslipLog.i("cluster: <- unhandled message id=$messageId (${body.size} bytes)")
}
}
private fun start() {
if (running) return
running = true
worker = Thread({
while (running) {
runCatching { tick() }.onFailure { GearslipLog.w("cluster: ${it.message}") }
try {
Thread.sleep(updateIntervalMs)
} catch (_: InterruptedException) {
return@Thread
}
}
}, "gearslip-cluster").also { it.start() }
}
@Synchronized
fun stop() {
running = false
worker?.interrupt()
worker = null
}
@Synchronized
private fun tick() {
if (!running || !updatesRequested) return
val turn = NavTurn.current()
val status = when {
turn == null -> STATUS_INACTIVE
turn.rerouting -> STATUS_REROUTING
else -> STATUS_ACTIVE
}
if (status != sentStatus) {
sendOnChannel(MSG_STATUS, Protobuf.varintField(1, status.toLong()))
GearslipLog.i("cluster: -> ${STATUS_NAMES[status]}")
sentStatus = status
if (status == STATUS_INACTIVE) { sentTurn = null; sentDistance = null }
}
if (turn == null || status == STATUS_REROUTING) return
sendTurn(turn.step)
turn.distance?.let { sendDistance(it, turn.seconds) }
}
private fun sendTurn(step: Step) {
val maneuver = step.maneuver
val (event, side) = maneuver?.let { eventFor(it.type) } ?: (EVENT_UNKNOWN to SIDE_UNSPECIFIED)
val road = step.road.text().ifBlank { step.cue.text() }
val key = "$road|$event|$side|${maneuver?.roundaboutExitNumber}|${maneuver?.roundaboutExitAngle}"
if (key == sentTurn) return
sentTurn = key
var body = Protobuf.stringField(1, road) +
Protobuf.varintField(2, side.toLong()) +
Protobuf.varintField(3, event.toLong())
maneuver?.roundaboutExitNumber?.takeIf { it > 0 }?.let { body += Protobuf.varintField(5, it.toLong()) }
maneuver?.roundaboutExitAngle?.takeIf { it > 0 }?.let { body += Protobuf.varintField(6, it.toLong()) }
sendOnChannel(MSG_TURN, body)
GearslipLog.i("cluster: -> turn event=$event side=$side road=\"$road\"")
}
/**
* The Car App Library's distance units use the same numbers as the protocol's (1 metres ...
* 7 yards), so the app's own display choice passes straight through.
*/
private fun sendDistance(distance: Distance, seconds: Long?) {
val meters = NavTurn.toMeters(distance)
val key = "${distance.displayDistance}|${distance.displayUnit}|${seconds ?: -1}"
if (key == sentDistance) return
sentDistance = key
sendOnChannel(
MSG_DISTANCE,
Protobuf.varintField(1, meters.toLong()) +
Protobuf.varintField(2, (seconds ?: 0L).coerceAtLeast(0)) +
Protobuf.varintField(3, (distance.displayDistance * 1000).toLong()) +
Protobuf.varintField(4, distance.displayUnit.toLong()),
)
}
private fun CarText?.text() = this?.toString().orEmpty()
companion object {
private const val TICK_MS = 500L
private const val MSG_CHANNEL_OPEN_RESPONSE = 8
private const val MSG_START = 32769
private const val MSG_STOP = 32770
private const val MSG_STATUS = 32771
private const val MSG_TURN = 32772
private const val MSG_DISTANCE = 32773
private const val STATUS_ACTIVE = 1
private const val STATUS_INACTIVE = 2
private const val STATUS_REROUTING = 3
private val STATUS_NAMES = mapOf(1 to "navigating", 2 to "not navigating", 3 to "rerouting")
private const val SIDE_LEFT = 1
private const val SIDE_RIGHT = 2
private const val SIDE_UNSPECIFIED = 3
private const val EVENT_UNKNOWN = 0
/** The car's turn icon and side for a Car App Library manoeuvre. */
fun eventFor(type: Int): Pair<Int, Int> = when (type) {
Maneuver.TYPE_DEPART -> 1 to SIDE_UNSPECIFIED
Maneuver.TYPE_NAME_CHANGE -> 2 to SIDE_UNSPECIFIED
Maneuver.TYPE_KEEP_LEFT, Maneuver.TYPE_TURN_SLIGHT_LEFT -> 3 to SIDE_LEFT
Maneuver.TYPE_KEEP_RIGHT, Maneuver.TYPE_TURN_SLIGHT_RIGHT -> 3 to SIDE_RIGHT
Maneuver.TYPE_TURN_NORMAL_LEFT -> 4 to SIDE_LEFT
Maneuver.TYPE_TURN_NORMAL_RIGHT -> 4 to SIDE_RIGHT
Maneuver.TYPE_TURN_SHARP_LEFT -> 5 to SIDE_LEFT
Maneuver.TYPE_TURN_SHARP_RIGHT -> 5 to SIDE_RIGHT
Maneuver.TYPE_U_TURN_LEFT -> 6 to SIDE_LEFT
Maneuver.TYPE_U_TURN_RIGHT -> 6 to SIDE_RIGHT
Maneuver.TYPE_ON_RAMP_SLIGHT_LEFT, Maneuver.TYPE_ON_RAMP_NORMAL_LEFT, Maneuver.TYPE_ON_RAMP_SHARP_LEFT,
Maneuver.TYPE_ON_RAMP_U_TURN_LEFT -> 7 to SIDE_LEFT
Maneuver.TYPE_ON_RAMP_SLIGHT_RIGHT, Maneuver.TYPE_ON_RAMP_NORMAL_RIGHT, Maneuver.TYPE_ON_RAMP_SHARP_RIGHT,
Maneuver.TYPE_ON_RAMP_U_TURN_RIGHT -> 7 to SIDE_RIGHT
Maneuver.TYPE_OFF_RAMP_SLIGHT_LEFT, Maneuver.TYPE_OFF_RAMP_NORMAL_LEFT -> 8 to SIDE_LEFT
Maneuver.TYPE_OFF_RAMP_SLIGHT_RIGHT, Maneuver.TYPE_OFF_RAMP_NORMAL_RIGHT -> 8 to SIDE_RIGHT
Maneuver.TYPE_FORK_LEFT -> 9 to SIDE_LEFT
Maneuver.TYPE_FORK_RIGHT -> 9 to SIDE_RIGHT
Maneuver.TYPE_MERGE_LEFT -> 10 to SIDE_LEFT
Maneuver.TYPE_MERGE_RIGHT -> 10 to SIDE_RIGHT
Maneuver.TYPE_MERGE_SIDE_UNSPECIFIED -> 10 to SIDE_UNSPECIFIED
Maneuver.TYPE_ROUNDABOUT_ENTER_CW, Maneuver.TYPE_ROUNDABOUT_ENTER_CCW -> 11 to SIDE_UNSPECIFIED
Maneuver.TYPE_ROUNDABOUT_EXIT_CW, Maneuver.TYPE_ROUNDABOUT_EXIT_CCW -> 12 to SIDE_UNSPECIFIED
Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CW, Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CW_WITH_ANGLE,
Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW, Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW_WITH_ANGLE -> 13 to SIDE_UNSPECIFIED
Maneuver.TYPE_STRAIGHT -> 14 to SIDE_UNSPECIFIED
Maneuver.TYPE_FERRY_BOAT, Maneuver.TYPE_FERRY_BOAT_LEFT, Maneuver.TYPE_FERRY_BOAT_RIGHT -> 16 to SIDE_UNSPECIFIED
Maneuver.TYPE_FERRY_TRAIN, Maneuver.TYPE_FERRY_TRAIN_LEFT, Maneuver.TYPE_FERRY_TRAIN_RIGHT -> 17 to SIDE_UNSPECIFIED
Maneuver.TYPE_DESTINATION, Maneuver.TYPE_DESTINATION_STRAIGHT -> 19 to SIDE_UNSPECIFIED
Maneuver.TYPE_DESTINATION_LEFT -> 19 to SIDE_LEFT
Maneuver.TYPE_DESTINATION_RIGHT -> 19 to SIDE_RIGHT
else -> EVENT_UNKNOWN to SIDE_UNSPECIFIED
}
}
}
/** One turn as the car wants it, from whichever source the map app fills. */
internal class NavTurn(val step: Step, val distance: Distance?, val seconds: Long?, val rerouting: Boolean) {
companion object {
/** The map app's next turn: from its trip (what clusters get), or its routing card. */
fun current(): NavTurn? {
val nav = CarServices.navOrNull() ?: return null
nav.trip.value?.let { trip ->
val step = trip.steps.firstOrNull() ?: return@let
val estimate = trip.stepTravelEstimates.firstOrNull()
return NavTurn(step, estimate?.remainingDistance, estimate?.remainingTimeSeconds?.takeIf { it >= 0 }, trip.isLoading)
}
val info = (nav.template.value as? NavigationTemplate)?.navigationInfo as? RoutingInfo ?: return null
val step = info.currentStep ?: return null
return NavTurn(step, info.currentDistance, null, info.isLoading)
}
fun toMeters(d: Distance): Int = (d.displayDistance * when (d.displayUnit) {
Distance.UNIT_KILOMETERS, Distance.UNIT_KILOMETERS_P1 -> 1000.0
Distance.UNIT_MILES, Distance.UNIT_MILES_P1 -> 1609.344
Distance.UNIT_FEET -> 0.3048
Distance.UNIT_YARDS -> 0.9144
else -> 1.0
}).toInt()
}
}
Loaded 100 of 239 files, more files were not shown because too many files have changed in this diff. Show more