Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b71f96cc0 | ||
|
|
24cfbee0c4 |
No files matched your search
@@ -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']
|
|
||||||
@@ -1,68 +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: input
|
|
||||||
id: version
|
|
||||||
attributes:
|
|
||||||
label: Gearslip version
|
|
||||||
description: Shown under the Gearslip name on the app's home screen.
|
|
||||||
placeholder: v0.3.40-alpha
|
|
||||||
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
|
|
||||||
@@ -1,74 +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: input
|
|
||||||
id: version
|
|
||||||
attributes:
|
|
||||||
label: Gearslip version
|
|
||||||
description: Shown under the Gearslip name on the app's home screen.
|
|
||||||
placeholder: v0.3.40-alpha
|
|
||||||
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 +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.
|
|
||||||
@@ -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
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
Gearslip 0.3.40 is a test build for a few cars that still don't connect.
|
|
||||||
|
|
||||||
If your car works on 0.3.39, you don't need this one. If you're following #18, #39 or #57, please try it and attach a log to your issue: open Gearslip, tap Logs, then Share log.
|
|
||||||
|
|
||||||
## Fixed
|
|
||||||
- A car that holds back its screen is asked for it again with the detail older cars expect. This is for the 2017 Pilot (#57) and other cars on the oldest Android Auto version.
|
|
||||||
- Music no longer carries on from the phone's speaker after you turn the car off. An app that started playing again by itself is paused a second time (#39).
|
|
||||||
- A liked song lights up the whole like button, as shuffle and repeat do. Before, only the heart filled in for apps such as Metrolist.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
- GM cars open two more channels, Bluetooth and Wi-Fi projection, and log what the car sends on them. This is to find out why the 2021 Bolt hangs up about 20 seconds in (#18). If you drive a GM car, type your phone's Bluetooth address under Settings > Connection > Phone's Bluetooth address. You'll find it in Android's Settings under About phone.
|
|
||||||
|
|
||||||
Help translate Gearslip on [Weblate](https://hosted.weblate.org/engage/gearslip/).
|
|
||||||
@@ -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 }}"
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
name: Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "v*"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
# The build names itself after the newest tag, which a shallow clone doesn't have.
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- 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
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
name: Translations
|
|
||||||
|
|
||||||
# Weblate is the only thing that writes translated strings: it pushes straight to experimental,
|
|
||||||
# never through a pull request. A PR that edits them collides with Weblate's own copy, and
|
|
||||||
# Weblate locks itself until someone resets it. Edit the English strings.xml only.
|
|
||||||
# Weblate's own pull requests are the exception: they are the translations.
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- "gearslip/src/main/res/values-*/strings.xml"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
weblate-only:
|
|
||||||
if: github.event.pull_request.user.login != 'weblate'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Translations go through Weblate
|
|
||||||
run: |
|
|
||||||
echo "::error::This pull request edits a translation. Please make translation changes on Weblate instead: https://hosted.weblate.org/engage/gearslip/ (it can upload a whole strings.xml under Files > Upload translation). See CONTRIBUTING.md."
|
|
||||||
exit 1
|
|
||||||
@@ -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
|
|
||||||
@@ -6,35 +6,13 @@
|
|||||||
/build
|
/build
|
||||||
local.properties
|
local.properties
|
||||||
|
|
||||||
# Keys
|
# Reference checkouts only - never referenced by the build; safe to delete entirely.
|
||||||
*.jks
|
|
||||||
*.keystore
|
|
||||||
keystore.properties
|
|
||||||
|
|
||||||
# Native build cache
|
|
||||||
.cxx/
|
|
||||||
|
|
||||||
# Testing
|
|
||||||
/cloned-repos
|
/cloned-repos
|
||||||
docs-internal/
|
|
||||||
|
|
||||||
# Internal
|
# The head unit certificate. Never committed or shared beyond who Seb sends it to directly.
|
||||||
*.pdf
|
/cert
|
||||||
todo.md
|
|
||||||
logs/
|
|
||||||
|
|
||||||
# For AI code
|
# Local investigation script; not meant for the public repo.
|
||||||
.claude/
|
/docs/cert-sweep.sh
|
||||||
CLAUDE.md
|
|
||||||
|
|
||||||
# Other
|
|
||||||
archive/
|
archive/
|
||||||
.wrangler/
|
CLAUDE.md
|
||||||
stats-worker/
|
|
||||||
|
|
||||||
# Certificates and keys never go in the repo; the app downloads or imports them on the phone.
|
|
||||||
*.p12
|
|
||||||
*.pfx
|
|
||||||
*.pem
|
|
||||||
*.crt
|
|
||||||
*.key
|
|
||||||
@@ -1,50 +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) |
|
|
||||||
| Renault Clio V | 2019-2024 | Easy Link |
|
|
||||||
| Š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.
|
|
||||||
@@ -1,105 +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.
|
|
||||||
|
|
||||||
Translators sign on Weblate: it shows this agreement before your first translation, and
|
|
||||||
accepting it there 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: ______________________________
|
|
||||||
@@ -1,73 +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.
|
|
||||||
|
|
||||||
## Translating
|
|
||||||
|
|
||||||
Translations happen on [Weblate](https://hosted.weblate.org/engage/gearslip/), never in pull
|
|
||||||
requests. Pick your language, or start a new one, and translate in the browser. Weblate
|
|
||||||
sends the finished work to this repository as a pull request.
|
|
||||||
|
|
||||||
Don't edit the translated `values-*/strings.xml` files by hand. Weblate keeps its own copy of
|
|
||||||
every language, so a change made in git collides with Weblate's next update, and the pull
|
|
||||||
request that made it gets closed. If a code change adds or rewords text, change only the
|
|
||||||
English file, `values/strings.xml`; Weblate picks up the new strings for translators.
|
|
||||||
|
|
||||||
Keep the tone of the English: short, plain, and friendly. Leave placeholders such as `%1$s`
|
|
||||||
and `%1$d` in place; Gearslip fills them with names and numbers. Untranslated strings show
|
|
||||||
in English, so a partial translation is still useful.
|
|
||||||
|
|
||||||
Before your first translation, Weblate asks you to accept the
|
|
||||||
[Contributor License Agreement](CLA.md). That counts as signing it.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
Copyright (C) 2026 Twink Software LLC and contributors
|
Copyright (C) 2026 seb
|
||||||
|
|
||||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
Version 3, 19 November 2007
|
Version 3, 19 November 2007
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
# License
|
# License
|
||||||
|
|
||||||
Gearslip is licensed under the GNU Affero General Public License v3.0. The full text is in `LICENSE`.
|
Gearslip is licensed under the **GNU Affero General Public License v3.0** (see `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.
|
In short: you're free to use, run, and modify it. If you modify it — or run a modified
|
||||||
|
version as a service others use over a network — the AGPL requires you to make your source
|
||||||
|
changes available on the same terms. That obligation follows the code even when you don't
|
||||||
|
redistribute a binary, which is what makes the AGPL fit for something that can run as a
|
||||||
|
background service.
|
||||||
|
|
||||||
## Commercial licensing
|
## 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).
|
If the AGPL's terms don't work for you — for example, you want to use Gearslip privately
|
||||||
|
without being required to publish your changes — a separate commercial license is available
|
||||||
|
directly from the author. Terms (including any restrictions on modification) are set on a
|
||||||
|
case-by-case basis. Contact me though Matrix (on my profile) to discuss one.
|
||||||
@@ -2,167 +2,38 @@
|
|||||||
|
|
||||||
# Gearslip
|
# Gearslip
|
||||||
|
|
||||||
An Android Auto alternative built for everyone.
|
Bring any app to your car screen.
|
||||||
|
|
||||||
[<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)
|
## What this is
|
||||||
|
|
||||||

|
Gearslip is a phone-side Android Auto client. It speaks the Android Auto protocol directly
|
||||||
|
to a car head unit over USB and acts as a **Car App Library host**: apps that ship an
|
||||||
|
Android Auto car UI (navigation and media apps) hand Gearslip a template, and Gearslip
|
||||||
|
renders it natively on the car screen, full quality, no phone-screen mirroring. A separate
|
||||||
|
screen-sharing mode is included for apps that don't ship a car UI at all.
|
||||||
|
|
||||||
[](https://forthebadge.com)
|
Built by reverse-engineering the protocol for interoperability - getting a non-Google client
|
||||||
|
talking to a car head unit that only expects Google's own app.
|
||||||
|
|
||||||
Gearslip speaks the Android Auto protocol straight to your car, over USB or wireless, with no
|
## Status
|
||||||
Google software in the path. Map, music, and messaging apps draw their car screens through
|
|
||||||
Gearslip, and it adds a player, calls, voice, and a web browser of its own.
|
|
||||||
|
|
||||||
> [!WARNING]
|
Real progress, with one hard limit that's worth knowing before you build on this:
|
||||||
> Gearslip is **alpha software**. It can drop the connection, freeze the car screen, or fail
|
|
||||||
> to start, and a new release can break a car that worked before. Set it up while parked, and
|
|
||||||
> keep another way to navigate.
|
|
||||||
|
|
||||||
Questions, ideas? [Join the discussion on Matrix](https://matrix.to/#/!mf0_mgBd5KqKpc9FfX_wHp1jFCcwAtPrndIn_oGIyMk?via=matrix.org).
|
- **Templated app hosting works.** All 16 template types the Car App Library defines are
|
||||||
|
rendered, including a keyboard Gearslip draws itself for search and sign-in. Verified
|
||||||
## What works
|
against several real navigation and media apps.
|
||||||
|
- **Media playback works.** Car-enabled media apps are browsable and controllable from the
|
||||||
| | |
|
car UI, with lyrics and playback state, over the same host connection.
|
||||||
|---|---|
|
- **The connection to a real head unit needs a certificate Google controls, and Gearslip
|
||||||
| **Cars** | Most cars, over USB or wireless. Some cars from about 2020 on need a certificate of your own. [CARS.md](CARS.md) lists the cars drivers have confirmed. |
|
doesn't ship one.** A head unit only accepts a phone identity that chains to Google's
|
||||||
| **Maps** | Organic Maps, Vela, OsmAnd, Waze, Flitsmeister, MAPS.ME, and MapQuest on the car screen, alerts included. Turns from Google Maps or Waze on the phone show in the nav bar and on the instrument cluster. |
|
Automotive Link certificate authority - something only Google issues. Without one, Gearslip
|
||||||
| **Music** | Any app built for car screens, with Now Playing, Up Next, search, and lyrics. Spotify plays through Gearslip's own player. |
|
still runs and renders everything correctly against a debug harness, but a real car will
|
||||||
| **Calls** | Dial or search contacts, and answer or decline on the car screen. Calls use the car's Bluetooth. |
|
reject the connection outright. See [SPIKE_FINDINGS.md](SPIKE_FINDINGS.md) for what was
|
||||||
| **Messages** | Read aloud on arrival, with a reply by voice or a quick reply. |
|
tested, including one publicly-known leaked certificate that works on some older head
|
||||||
| **Voice** | "Navigate to the nearest gas station", "play Daft Punk", "call Mom". Speech stays on the phone. Commands are in English for now. |
|
units and is rejected by newer firmware and by Google's own reference tooling.
|
||||||
| **Controls** | Touch, steering wheel buttons, control knobs, and D-pads. |
|
- **No adb, root, or Shizuku is required to run it.** Every feature is gated on an ordinary
|
||||||
| **Languages** | English and 19 more, some only partly translated. |
|
Android permission or an on-device consent dialog - nothing needs privileged access, on
|
||||||
| **Head units with Android** | Gearslip can be the home screen of an Android head unit, or of a spare phone or tablet on the dash. Turn on Use as launcher in Settings > Connection. |
|
the phone or in the car.
|
||||||
| **Doesn't work** | Spotify's and Google Messages' own car screens, which accept only Android Auto. TIDAL lets only approved apps browse its library. |
|
|
||||||
|
|
||||||
## See it
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
|  |  |
|
|
||||||
| Your car apps in one place. | Metrolist in Gearslip's player. |
|
|
||||||
|  |  |
|
|
||||||
| A new message, read aloud at a tap, over the map. | Lyrics light up word by word as the song plays. |
|
|
||||||
|  |  |
|
|
||||||
| Ask for a place, a song, or an app. | Pick a route. Each one shows its time and distance. |
|
|
||||||
|  | |
|
|
||||||
| Browse the web while parked. | |
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- **Navigation.** Gearslip hosts real map apps on the car screen. While you navigate, a big
|
|
||||||
turn card shows the arrow, the distance, and your lane, and the map keeps the rest.
|
|
||||||
- **Music.** Browse, play, and queue from any car-ready media app. Type a search, and apps
|
|
||||||
that accept one play the best match.
|
|
||||||
- **Phone calls.** During a call, the nav bar shows who you're talking to, and you mute or
|
|
||||||
hang up from there.
|
|
||||||
- **Messages and notifications.** Only messages, missed calls, alarms, and reminders
|
|
||||||
interrupt you. The rest wait on the dashboard. Music pauses while Gearslip speaks or
|
|
||||||
listens.
|
|
||||||
- **Voice.** Tap the mic or press the talk button on the wheel. Say "help" for the full list.
|
|
||||||
- **The web.** A browser with an address bar, back and forward, and its own keyboard.
|
|
||||||
- **Weather and the day ahead.** A dashboard shows the week's calendar beside the weather.
|
|
||||||
- **Your apps, your order.** Apps group by kind. Press and hold one to move it, and hide
|
|
||||||
the ones you don't use from the Apps card on the phone.
|
|
||||||
- **Your look.** The car screen takes your phone's Material You colors. Choose flat
|
|
||||||
buttons or ones that look like real keys, and make text bigger on small screens.
|
|
||||||
- **Settings on the phone.** Set things up before you drive. A change on either screen
|
|
||||||
shows on both.
|
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
1. Install Gearslip from [the releases page](https://github.com/twinksoftware/Gearslip/releases)
|
|
||||||
or through Obtainium.
|
|
||||||
2. Open it. Setup asks where your Android Auto certificate comes from, then for one
|
|
||||||
permission at a time, each with a plain reason. A help screen in the car answers the rest.
|
|
||||||
3. Plug the phone into the car's USB port. In a car with wireless Android Auto, pair the
|
|
||||||
phone with the car over Bluetooth, then tap Connect wirelessly and pick the car.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> On GrapheneOS, make sure **USB-C port control** isn't set to **Charging-only**. That
|
|
||||||
> setting blocks USB data, so the car never sees the phone, even with a data cable.
|
|
||||||
|
|
||||||
Gearslip updates itself from this repository. Once a day it checks the latest release,
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
## Good to know
|
|
||||||
|
|
||||||
- **Music reaches the car over Bluetooth or through Gearslip.** Setup asks which.
|
|
||||||
Bluetooth is the default. Android never asks for permission, and your map app's voice
|
|
||||||
reaches the car too. Through Gearslip, the sound travels over the car connection, cable
|
|
||||||
or wireless. It 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 audio through Gearslip, setup
|
|
||||||
offers two optional adb commands. One removes the audio dialog for good, and the other
|
|
||||||
keeps notifications visible while audio plays.
|
|
||||||
- **Apps without a car screen** can still reach the car through a separate screen-sharing
|
|
||||||
mode.
|
|
||||||
- **Gearslip works with [LIVI](https://github.com/f-io/LIVI),** the open-source head unit
|
|
||||||
used in homemade car dashboards.
|
|
||||||
|
|
||||||
## 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 through Gearslip or over 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.
|
|
||||||
|
|
||||||
## Help out
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
To translate Gearslip into your language, join
|
|
||||||
[Gearslip on Weblate](https://hosted.weblate.org/engage/gearslip/). No coding needed.
|
|
||||||
Translations go through Weblate only, since it holds its own copy of every language and
|
|
||||||
changes made in git collide with it.
|
|
||||||
|
|
||||||
[](https://hosted.weblate.org/engage/gearslip/)
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
@@ -172,35 +43,14 @@ and years as possible. If something breaks, that's on me, so please open an issu
|
|||||||
|
|
||||||
The APK lands at `build/Gearslip-debug.apk`.
|
The APK lands at `build/Gearslip-debug.apk`.
|
||||||
|
|
||||||
A release build needs a signing key. Copy `keystore.properties.example` to
|
## Further reading
|
||||||
`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
|
- [SPIKE_FINDINGS.md](SPIKE_FINDINGS.md) - the certificate investigation in full: what was
|
||||||
|
tried, what a real head unit does and doesn't accept, and why.
|
||||||
[docs/BUILDING_APPS.md](docs/BUILDING_APPS.md) specifies how to write apps that Gearslip
|
- [CUSTOM_HEADUNIT_SCOPING.md](CUSTOM_HEADUNIT_SCOPING.md) - the original scoping of what a
|
||||||
can render: templated apps, media apps, and the Gearslip app protocol.
|
custom head unit client would need.
|
||||||
|
|
||||||
## 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
|
## License
|
||||||
|
|
||||||
Gearslip belongs to Twink Software LLC and is licensed under AGPL-3.0; see [LICENSE](LICENSE).
|
AGPL-3.0 - see [LICENSE](LICENSE). A separate commercial license is available for private
|
||||||
The LLC also sells a separate commercial license for private use outside the AGPL's terms; see
|
use outside the AGPL's terms; see [NOTICE.md](NOTICE.md).
|
||||||
[NOTICE.md](NOTICE.md).
|
|
||||||
@@ -1,515 +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.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Works with Gearslip
|
|
||||||
|
|
||||||
Once your app runs well on Gearslip, say so in two places: your manifest and your README.
|
|
||||||
|
|
||||||
### In your manifest
|
|
||||||
|
|
||||||
Add these lines to `<application>`:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<meta-data
|
|
||||||
android:name="app.seb3thehacker.gearslip.works_with"
|
|
||||||
android:value="true" />
|
|
||||||
<meta-data
|
|
||||||
android:name="app.seb3thehacker.gearslip.about"
|
|
||||||
android:resource="@string/gearslip_about" />
|
|
||||||
```
|
|
||||||
|
|
||||||
Gearslip then marks your app "Works with Gearslip" in its Recommended apps list and on your app's
|
|
||||||
page in Gearslip. An installed app with the flag gets its own place at the top of the list.
|
|
||||||
|
|
||||||
The second entry is optional. It gives your page one sentence on what your app does in the car,
|
|
||||||
such as "Spots trackers and cameras nearby." Write it in place with `android:value` if you have no
|
|
||||||
string resource for it.
|
|
||||||
|
|
||||||
The flag is a label. Gearslip finds and runs your app the same way with or without it.
|
|
||||||
|
|
||||||
### In your README
|
|
||||||
|
|
||||||
Add the badge:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
[](https://github.com/twinksoftware/gearslip)
|
|
||||||
```
|
|
||||||
|
|
||||||
It matches the flat badges most READMEs already show.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="147" height="20" role="img" aria-label="Works with Gearslip">
|
|
||||||
<title>Works with Gearslip</title>
|
|
||||||
<clipPath id="r"><rect width="147" height="20" rx="3" fill="#fff"/></clipPath>
|
|
||||||
<g clip-path="url(#r)">
|
|
||||||
<rect width="88" height="20" fill="#555"/>
|
|
||||||
<rect x="88" width="59" height="20" fill="#8C1F1F"/>
|
|
||||||
</g>
|
|
||||||
<g transform="translate(5 3.5) scale(0.228) translate(-29.5 -25.5)" fill="#fff">
|
|
||||||
<path fill-rule="evenodd" d="M54 36.18 58.64 30.7 67.2 34.24 66.6 41.4 71.82 54 77.3 58.64 73.76 67.2 66.6 66.6 67.2 73.76 58.64 77.3 54 71.82 49.36 77.3 40.8 73.76 41.4 66.6 34.24 67.2 30.7 58.64 36.18 54 30.7 49.36 34.24 40.8 41.4 41.4 40.8 34.24 49.36 30.7ZM54 46.08A7.92 7.92 0 1 1 53.99 46.08Z"/>
|
|
||||||
<path d="M78.61 40.7 85.31 43.29 84.74 52.55 77.78 54.31Z"/>
|
|
||||||
</g>
|
|
||||||
<g fill="#fff" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11" text-rendering="geometricPrecision">
|
|
||||||
<text x="22" y="14" textLength="60">works with</text>
|
|
||||||
<text x="94" y="14" textLength="47">Gearslip</text>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,14 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="432" height="432" viewBox="0 0 108 108">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="trail" x1="73.4" y1="46.0" x2="95.8" y2="44.5" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0%" stop-color="#8C1F1F" stop-opacity="0"/>
|
||||||
|
<stop offset="100%" stop-color="#8C1F1F" stop-opacity="0.7"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="108" height="108" fill="#0c0c0d"/>
|
||||||
|
<circle cx="54" cy="54" r="52" fill="#131415"/>
|
||||||
|
<path d="M 73.23,47.15 L 94.77,51.90 L 96.89,37.05 L 73.57,44.78 Z" fill="url(#trail)"/>
|
||||||
|
<path d="M 54.00,27.00 L 59.99,19.91 Q 61.02,18.69 62.50,19.30 L 72.52,23.45 Q 74.00,24.07 73.87,25.66 L 73.23,33.31 Q 73.09,34.91 73.70,36.39 L 80.39,52.52 Q 81.00,54.00 82.22,55.03 L 88.09,59.99 Q 89.31,61.02 88.70,62.50 L 84.55,72.52 Q 83.93,74.00 82.34,73.87 L 73.09,73.09 Q 73.09,73.09 73.09,73.09 L 73.09,73.09 Q 73.09,73.09 73.09,73.09 L 73.87,82.34 Q 74.00,83.93 72.52,84.55 L 62.50,88.70 Q 61.02,89.31 59.99,88.09 L 54.00,81.00 Q 54.00,81.00 54.00,81.00 L 54.00,81.00 Q 54.00,81.00 54.00,81.00 L 48.01,88.09 Q 46.98,89.31 45.50,88.70 L 35.48,84.55 Q 34.00,83.93 34.13,82.34 L 34.91,73.09 Q 34.91,73.09 34.91,73.09 L 34.91,73.09 Q 34.91,73.09 34.91,73.09 L 25.66,73.87 Q 24.07,74.00 23.45,72.52 L 19.30,62.50 Q 18.69,61.02 19.91,59.99 L 27.00,54.00 Q 27.00,54.00 27.00,54.00 L 27.00,54.00 Q 27.00,54.00 27.00,54.00 L 19.91,48.01 Q 18.69,46.98 19.30,45.50 L 23.45,35.48 Q 24.07,34.00 25.66,34.13 L 34.91,34.91 Q 34.91,34.91 34.91,34.91 L 34.91,34.91 Q 34.91,34.91 34.91,34.91 L 34.13,25.66 Q 34.00,24.07 35.48,23.45 L 45.50,19.30 Q 46.98,18.69 48.01,19.91 L 54.00,27.00 Q 54.00,27.00 54.00,27.00 L 54.00,27.00 Q 54.00,27.00 54.00,27.00 Z" fill="#8B9096"/>
|
||||||
|
<path d="M 92.78,34.42 L 99.94,37.20 Q 101.43,37.78 101.34,39.37 L 100.67,50.20 Q 100.58,51.80 99.03,52.19 L 91.58,54.08 Q 90.03,54.47 90.13,52.87 L 91.19,35.44 Q 91.29,33.84 92.78,34.42 Z" fill="#8C1F1F"/>
|
||||||
|
<circle cx="54" cy="54" r="12" fill="#0c0c0d"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 11 KiB |
@@ -5,6 +5,7 @@
|
|||||||
<stop offset="100%" stop-color="#8C1F1F" stop-opacity="0.7"/>
|
<stop offset="100%" stop-color="#8C1F1F" stop-opacity="0.7"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
|
<rect width="108" height="108" fill="#0c0c0d"/>
|
||||||
<circle cx="54" cy="54" r="52" fill="#131415"/>
|
<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 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 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"/>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 402 KiB |
|
Before Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 502 KiB |
|
Before Width: | Height: | Size: 313 KiB |
|
Before Width: | Height: | Size: 449 KiB |
|
Before Width: | Height: | Size: 420 KiB |
|
Before Width: | Height: | Size: 392 KiB |
|
Before Width: | Height: | Size: 107 KiB |
@@ -1,4 +1,3 @@
|
|||||||
import java.util.Properties
|
|
||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
|
import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
|
||||||
|
|
||||||
@@ -14,148 +13,28 @@ plugins {
|
|||||||
alias(libs.plugins.kotlin.compose)
|
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) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// The version comes from the newest v* git tag, so a build can never disagree with its tag. A
|
|
||||||
// build of the tagged commit is named after it ("0.4.0-beta"); anything later adds the commits
|
|
||||||
// since and the commit ("0.4.0-beta+3.ba4374e"), so a test build can't pass for a release.
|
|
||||||
// The version code is worked out from the numbers, ((major*100 + minor)*100 + patch)*10 + stage,
|
|
||||||
// where stage is 1 for alpha, 2 for beta and 9 for a finished release. It only ever goes up as
|
|
||||||
// long as minor and patch stay under 100. Changelog entries use the same number.
|
|
||||||
class GitVersion(val name: String, val code: Int)
|
|
||||||
|
|
||||||
val gitVersion: GitVersion = run {
|
|
||||||
val described = runCatching {
|
|
||||||
providers.exec {
|
|
||||||
commandLine("git", "describe", "--tags", "--match", "v*", "--abbrev=7")
|
|
||||||
isIgnoreExitValue = true
|
|
||||||
}.standardOutput.asText.get().trim()
|
|
||||||
}.getOrNull().orEmpty()
|
|
||||||
val match = Regex("""^v((\d+)\.(\d+)\.(\d+)(?:-([A-Za-z]+))?)(?:-(\d+)-g([0-9a-f]+))?$""").find(described)
|
|
||||||
?: return@run GitVersion("0.0.0-dev", 1) // no git or no tags, e.g. a source download
|
|
||||||
val (base, major, minor, patch, suffix, since, commit) = match.destructured
|
|
||||||
val stage = when (suffix.lowercase()) {
|
|
||||||
"" -> 9
|
|
||||||
"beta" -> 2
|
|
||||||
"alpha" -> 1
|
|
||||||
else -> 0
|
|
||||||
}
|
|
||||||
GitVersion(
|
|
||||||
name = if (since.isEmpty()) base else "$base+$since.$commit",
|
|
||||||
code = ((major.toInt() * 100 + minor.toInt()) * 100 + patch.toInt()) * 10 + stage,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
android {
|
||||||
namespace = "app.seb3thehacker.gearslip"
|
namespace = "app.seb3thehacker.gearslip"
|
||||||
compileSdk = 37
|
compileSdk = 37
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "app.seb3thehacker.gearslip"
|
applicationId = "app.seb3thehacker.gearslip"
|
||||||
minSdk = 31
|
minSdk = 35
|
||||||
targetSdk = 37
|
targetSdk = 37
|
||||||
versionCode = gitVersion.code
|
versionCode = 2
|
||||||
// -PgearslipVersionName=0.1.99-test makes a build that thinks it's older, to try the updater.
|
versionName = "0.0.02-pre"
|
||||||
versionName = project.findProperty("gearslipVersionName")?.toString() ?: gitVersion.name
|
|
||||||
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 {
|
// Keeps build output out of the module folder: <root>/build/gearslip/... instead of
|
||||||
// Declared only when the properties file is present, so a clone without the key still
|
// <root>/gearslip/build/..., so everything this project produces lands under one
|
||||||
// builds debug; assembleRelease then falls back to unsigned rather than failing here.
|
// root-level build/ directory.
|
||||||
if (keystoreProperties.getProperty("storeFile") != null) {
|
layout.buildDirectory.set(rootProject.layout.buildDirectory.dir("gearslip"))
|
||||||
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"
|
|
||||||
// English (XA) and Arabic (XB) under the phone's languages: stretched, accented and
|
|
||||||
// right-to-left text, which shows what's still hardcoded and what doesn't fit.
|
|
||||||
isPseudoLocalesEnabled = true
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lists each language that has a translation in the app's locale config, so Android 13+
|
|
||||||
// offers it under the system's per-app languages. English is the unqualified default
|
|
||||||
// (res/resources.properties).
|
|
||||||
//
|
|
||||||
// Only languages that are mostly translated ship, so the app never comes up half in English.
|
|
||||||
// Danish and Estonian are still early on Weblate; add them here once they're close to done.
|
|
||||||
androidResources {
|
|
||||||
generateLocaleConfig = true
|
|
||||||
localeFilters += listOf(
|
|
||||||
"en", "ar", "cs", "de", "es", "fr", "it", "ja", "ko", "nl", "pl", "pt-rBR", "ro", "ru",
|
|
||||||
"sv", "tr", "uk", "zh-rCN",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
lint {
|
|
||||||
// Weblate fills languages in a string at a time; the rest fall back to English.
|
|
||||||
disable += "MissingTranslation"
|
|
||||||
}
|
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_21
|
sourceCompatibility = JavaVersion.VERSION_21
|
||||||
targetCompatibility = JavaVersion.VERSION_21
|
targetCompatibility = JavaVersion.VERSION_21
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
|
||||||
// Speech-to-text (sherpa-onnx 1.13.8, static-link-onnxruntime build, in jniLibs) is the
|
|
||||||
// only native code. One APK for all three, since the updater takes the release's only APK;
|
|
||||||
// 32-bit x86 is left out, nothing that drives a car runs it.
|
|
||||||
ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compressed, the three copies cost a 33 MB download instead of 75 MB; the phone unpacks
|
|
||||||
// only its own at install.
|
|
||||||
packaging { jniLibs { useLegacyPackaging = true } }
|
|
||||||
|
|
||||||
testOptions {
|
testOptions {
|
||||||
// GearslipLog calls android.util.Log, which is a stub on the local JVM.
|
// GearslipLog calls android.util.Log, which is a stub on the local JVM.
|
||||||
unitTests.isReturnDefaultValues = true
|
unitTests.isReturnDefaultValues = true
|
||||||
@@ -163,10 +42,9 @@ android {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copies the produced APK to the root build/ folder (<root>/build/Gearslip-debug.apk) after the
|
// Renames the produced APK and moves it to the root build/ folder (<root>/build/Gearslip-debug.apk)
|
||||||
// fact rather than fighting AGP's variant-output API across versions - simpler and doesn't break
|
// after the fact rather than fighting AGP's variant-output API across versions - simpler and
|
||||||
// when that API moves again. A copy, not a move: AGP packages incrementally into the APK it left
|
// doesn't break when that API moves again.
|
||||||
// last time, and with that file gone a build came out holding nothing but classes.dex.
|
|
||||||
tasks.register("renameApk") {
|
tasks.register("renameApk") {
|
||||||
doLast {
|
doLast {
|
||||||
val rootBuildDir = rootProject.layout.buildDirectory.get().asFile
|
val rootBuildDir = rootProject.layout.buildDirectory.get().asFile
|
||||||
@@ -176,6 +54,7 @@ tasks.register("renameApk") {
|
|||||||
.forEach { apk ->
|
.forEach { apk ->
|
||||||
val renamed = File(rootBuildDir, "Gearslip-${apk.parentFile.name}.apk")
|
val renamed = File(rootBuildDir, "Gearslip-${apk.parentFile.name}.apk")
|
||||||
apk.copyTo(renamed, overwrite = true)
|
apk.copyTo(renamed, overwrite = true)
|
||||||
|
apk.delete()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -195,11 +74,6 @@ dependencies {
|
|||||||
// their Bundleables deserialize into.
|
// their Bundleables deserialize into.
|
||||||
implementation(libs.androidx.car.app)
|
implementation(libs.androidx.car.app)
|
||||||
implementation(libs.androidx.car.app.projected)
|
implementation(libs.androidx.car.app.projected)
|
||||||
implementation(libs.androidx.media)
|
|
||||||
// Document-start scripts, so the ad blocker hides ad slots before a page first paints.
|
|
||||||
implementation(libs.androidx.webkit)
|
|
||||||
// The map behind a place list (PlaceListMapTemplate), which the host draws, not the app.
|
|
||||||
implementation(libs.maplibre.android)
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,4 +82,3 @@ tasks.withType<KotlinJvmCompile>().configureEach {
|
|||||||
jvmTarget.set(JvmTarget.JVM_21)
|
jvmTarget.set(JvmTarget.JVM_21)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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.** { *; }
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,9 +9,6 @@ import android.os.Handler
|
|||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
import androidx.activity.ComponentActivity
|
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.CarEnvironment
|
||||||
import app.seb3thehacker.gearslip.car.CarSettings
|
import app.seb3thehacker.gearslip.car.CarSettings
|
||||||
import app.seb3thehacker.gearslip.car.CarUi
|
import app.seb3thehacker.gearslip.car.CarUi
|
||||||
@@ -34,28 +31,6 @@ class CarPreviewActivity : ComponentActivity() {
|
|||||||
private var reader: ImageReader? = null
|
private var reader: ImageReader? = null
|
||||||
@Volatile private var latest: Bitmap? = 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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
GearslipLog.init(this)
|
GearslipLog.init(this)
|
||||||
@@ -83,22 +58,6 @@ class CarPreviewActivity : ComponentActivity() {
|
|||||||
val p = ScreenProjector(this).also { projector = it }
|
val p = ScreenProjector(this).also { projector = it }
|
||||||
p.start(imageReader.surface, w, h, dpi) { CarUi() }
|
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}")
|
GearslipLog.i("preview: displayId=${p.displayId}")
|
||||||
if (intent.getBooleanExtra("hide", false)) p.setOverlayVisible(false)
|
if (intent.getBooleanExtra("hide", false)) p.setOverlayVisible(false)
|
||||||
|
|
||||||
@@ -117,27 +76,6 @@ class CarPreviewActivity : ComponentActivity() {
|
|||||||
GearslipLog.i("preview: wrote ${out.name}")
|
GearslipLog.i("preview: wrote ${out.name}")
|
||||||
step++
|
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
|
// 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.
|
// outside (adb `am start --display` / `input -d`) while the harness records it.
|
||||||
val snaps = intent.getIntExtra("snaps", 0)
|
val snaps = intent.getIntExtra("snaps", 0)
|
||||||
@@ -151,9 +89,8 @@ class CarPreviewActivity : ComponentActivity() {
|
|||||||
snap()
|
snap()
|
||||||
taps.forEachIndexed { i, (x, y) ->
|
taps.forEachIndexed { i, (x, y) ->
|
||||||
main.postDelayed({
|
main.postDelayed({
|
||||||
val point = listOf(TouchPoint(0, x, y))
|
p.dispatchTouch(MotionEvent.ACTION_DOWN, x, y)
|
||||||
p.dispatchTouch(MotionEvent.ACTION_DOWN, 0, point)
|
main.postDelayed({ p.dispatchTouch(MotionEvent.ACTION_UP, x, y) }, 80)
|
||||||
main.postDelayed({ p.dispatchTouch(MotionEvent.ACTION_UP, 0, point) }, 80)
|
|
||||||
}, 2500L * (i + 1))
|
}, 2500L * (i + 1))
|
||||||
main.postDelayed({ snap() }, 2500L * (i + 1) + 1500)
|
main.postDelayed({ snap() }, 2500L * (i + 1) + 1500)
|
||||||
}
|
}
|
||||||
@@ -164,8 +101,6 @@ class CarPreviewActivity : ComponentActivity() {
|
|||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
projector?.stop()
|
projector?.stop()
|
||||||
reader?.close()
|
reader?.close()
|
||||||
CarAudio.onConsentRequested = null
|
|
||||||
CarAudio.release()
|
|
||||||
CarEnvironment.stop()
|
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>
|
|
||||||
@@ -1,12 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<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. -->
|
<!-- 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.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
|
||||||
<!-- Lets an update Gearslip installed itself skip Android's prompt. -->
|
|
||||||
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
|
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Phone mirroring. Android refuses to hand over a MediaProjection unless a foreground
|
Phone mirroring. Android refuses to hand over a MediaProjection unless a foreground
|
||||||
@@ -15,33 +11,11 @@
|
|||||||
-->
|
-->
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<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_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.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||||
<!-- Keeps a wireless start going with the phone locked; see WirelessKeepAlive. -->
|
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
|
|
||||||
|
|
||||||
<!--
|
|
||||||
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. -->
|
<!-- The dashboard opened from the car clock: today's agenda and current weather. -->
|
||||||
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
||||||
@@ -49,37 +23,6 @@
|
|||||||
<!-- Capturing a media app's playback (to send to the car) is gated on this by the platform. -->
|
<!-- 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" />
|
<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, and READ_CALL_LOG
|
|
||||||
also lists recent calls on the car's Phone screen, read on the phone only; 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
|
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
|
whether it holds this permission (HostValidator.isValidHost checks it before falling back
|
||||||
@@ -95,14 +38,6 @@
|
|||||||
<uses-feature android:name="android.hardware.usb.accessory" android:required="true" />
|
<uses-feature android:name="android.hardware.usb.accessory" android:required="true" />
|
||||||
|
|
||||||
<queries>
|
<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>
|
<intent>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
@@ -115,36 +50,16 @@
|
|||||||
<intent>
|
<intent>
|
||||||
<action android:name="android.media.browse.MediaBrowserService" />
|
<action android:name="android.media.browse.MediaBrowserService" />
|
||||||
</intent>
|
</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>
|
</queries>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:roundIcon="@mipmap/ic_launcher"
|
android:roundIcon="@mipmap/ic_launcher"
|
||||||
android:label="${appLabel}"
|
android:label="Gearslip"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:manageSpaceActivity="app.seb3thehacker.gearslip.ui.ManageSpaceActivity"
|
|
||||||
android:theme="@style/Theme.Gearslip">
|
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
|
singleTop: the head unit can re-attach while we are already foregrounded, which
|
||||||
arrives via onNewIntent rather than a fresh instance.
|
arrives via onNewIntent rather than a fresh instance.
|
||||||
@@ -153,7 +68,6 @@
|
|||||||
android:name=".GearslipActivity"
|
android:name=".GearslipActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTop"
|
||||||
android:windowSoftInputMode="adjustResize"
|
|
||||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|uiMode|density|locale|layoutDirection|fontScale">
|
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|uiMode|density|locale|layoutDirection|fontScale">
|
||||||
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
@@ -161,65 +75,6 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
</activity>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Launcher mode: the car UI as this device's home screen, for a head unit that runs Android
|
|
||||||
or a tablet on the dash. Off until Settings turns it on (LauncherMode), so phones never
|
|
||||||
list Gearslip as a home app.
|
|
||||||
-->
|
|
||||||
<activity
|
|
||||||
android:name=".LauncherActivity"
|
|
||||||
android:enabled="false"
|
|
||||||
android:exported="true"
|
|
||||||
android:launchMode="singleTask"
|
|
||||||
android:excludeFromRecents="true"
|
|
||||||
android:screenOrientation="sensorLandscape"
|
|
||||||
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.HOME" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
</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.
|
|
||||||
-->
|
|
||||||
<!-- App info › Storage › Manage space: clears lyrics, voice models and logs one at a time. -->
|
|
||||||
<!-- "Share" a page from any browser to add it to the car browser's start page (issue #85). -->
|
|
||||||
<activity
|
|
||||||
android:name=".browser.ShareToBrowserActivity"
|
|
||||||
android:exported="true"
|
|
||||||
android:excludeFromRecents="true"
|
|
||||||
android:label="@string/browser_share_label"
|
|
||||||
android:noHistory="true"
|
|
||||||
android:theme="@android:style/Theme.Translucent.NoTitleBar">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.SEND" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<data android:mimeType="text/plain" />
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
|
||||||
|
|
||||||
<activity
|
|
||||||
android:name=".ui.ManageSpaceActivity"
|
|
||||||
android:exported="true"
|
|
||||||
android:label="Gearslip storage" />
|
|
||||||
|
|
||||||
<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>
|
<intent-filter>
|
||||||
<action android:name="android.hardware.usb.action.USB_ACCESSORY_ATTACHED" />
|
<action android:name="android.hardware.usb.action.USB_ACCESSORY_ATTACHED" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
@@ -227,20 +82,7 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="android.hardware.usb.action.USB_ACCESSORY_ATTACHED"
|
android:name="android.hardware.usb.action.USB_ACCESSORY_ATTACHED"
|
||||||
android:resource="@xml/accessory_filter" />
|
android:resource="@xml/accessory_filter" />
|
||||||
</activity-alias>
|
</activity>
|
||||||
|
|
||||||
<!-- Dev builds' test message: its Reply and Mark as read actions land here. -->
|
|
||||||
<receiver
|
|
||||||
android:name=".notify.TestMessageReceiver"
|
|
||||||
android:exported="false" />
|
|
||||||
|
|
||||||
<receiver
|
|
||||||
android:name=".update.UpdateReceiver"
|
|
||||||
android:exported="false">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
|
||||||
</intent-filter>
|
|
||||||
</receiver>
|
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".mirror.MirrorService"
|
android:name=".mirror.MirrorService"
|
||||||
@@ -252,19 +94,11 @@
|
|||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="location" />
|
android:foregroundServiceType="location" />
|
||||||
|
|
||||||
<service
|
<!-- Holds the capture session that carries a media app's audio to the car. -->
|
||||||
android:name=".wireless.WirelessKeepAlive"
|
|
||||||
android:exported="false"
|
|
||||||
android:foregroundServiceType="connectedDevice" />
|
|
||||||
|
|
||||||
<!--
|
|
||||||
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
|
<service
|
||||||
android:name=".audio.AudioCaptureService"
|
android:name=".audio.AudioCaptureService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="mediaProjection|microphone" />
|
android:foregroundServiceType="mediaProjection" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Touch on the car screen is replayed onto the phone through this service.
|
Touch on the car screen is replayed onto the phone through this service.
|
||||||
@@ -297,31 +131,5 @@
|
|||||||
<action android:name="android.service.notification.NotificationListenerService" />
|
<action android:name="android.service.notification.NotificationListenerService" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</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>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -1,17 +1,13 @@
|
|||||||
package app.seb3thehacker.gearslip
|
package app.seb3thehacker.gearslip
|
||||||
|
|
||||||
import android.content.ComponentName
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
|
||||||
|
|
||||||
/** User-facing preferences. Read fresh on every session, so changes apply on the next connect. */
|
/** User-facing preferences. Read fresh on every session, so changes apply on the next connect. */
|
||||||
object AppSettings {
|
object AppSettings {
|
||||||
|
|
||||||
private const val PREFS = "gearslip_settings"
|
private const val PREFS = "gearslip_settings"
|
||||||
private const val KEY_STARTUP_URL = "startup_url"
|
private const val KEY_STARTUP_URL = "startup_url"
|
||||||
private const val KEY_SKIPPED_CERT_SETUP = "skipped_cert_setup"
|
private const val KEY_SEEN_COMPAT_WARNING = "seen_compat_warning"
|
||||||
private const val KEY_SEEN_PERMISSIONS_SETUP = "seen_permissions_setup"
|
|
||||||
private const val KEY_GEARSLIP_ENABLED = "gearslip_enabled"
|
|
||||||
|
|
||||||
private fun prefs(context: Context) =
|
private fun prefs(context: Context) =
|
||||||
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
@@ -31,58 +27,11 @@ object AppSettings {
|
|||||||
prefs(context).edit().putString(KEY_STARTUP_URL, value.trim()).apply()
|
prefs(context).edit().putString(KEY_STARTUP_URL, value.trim()).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether the certificate setup screen has been deliberately skipped. */
|
/** Whether the newer-car compatibility warning has already been shown and dismissed once. */
|
||||||
fun hasSkippedCertSetup(context: Context): Boolean =
|
fun hasSeenCompatWarning(context: Context): Boolean =
|
||||||
prefs(context).getBoolean(KEY_SKIPPED_CERT_SETUP, false)
|
prefs(context).getBoolean(KEY_SEEN_COMPAT_WARNING, false)
|
||||||
|
|
||||||
fun setSkippedCertSetup(context: Context) {
|
fun setSeenCompatWarning(context: Context) {
|
||||||
prefs(context).edit().putBoolean(KEY_SKIPPED_CERT_SETUP, true).apply()
|
prefs(context).edit().putBoolean(KEY_SEEN_COMPAT_WARNING, 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ package app.seb3thehacker.gearslip
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import java.io.ByteArrayOutputStream
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.security.KeyStore
|
import java.security.KeyStore
|
||||||
import java.security.cert.X509Certificate
|
import java.security.cert.X509Certificate
|
||||||
@@ -15,9 +14,7 @@ import java.security.cert.X509Certificate
|
|||||||
*
|
*
|
||||||
* 1. **Imported** in the app (Settings > Certificate): a PKCS#12 file plus its password,
|
* 1. **Imported** in the app (Settings > Certificate): a PKCS#12 file plus its password,
|
||||||
* copied into the app's private storage.
|
* copied into the app's private storage.
|
||||||
* 2. **Downloaded** from the public opencardev/aasdk repository. Fetched only when the
|
* 2. **Staged over adb** into the app's external files directory, the way the spike has
|
||||||
* 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:
|
* always done it. Still honoured, so existing setups keep working untouched:
|
||||||
*
|
*
|
||||||
* openssl pkcs12 -export -in headunit.crt -inkey headunit.key \
|
* openssl pkcs12 -export -in headunit.crt -inkey headunit.key \
|
||||||
@@ -26,18 +23,17 @@ import java.security.cert.X509Certificate
|
|||||||
*
|
*
|
||||||
* Default password is "aaspike" (kept as-is so an already-staged phone.p12 doesn't need
|
* 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.
|
* regenerating); a `phone.pass` file next to it overrides it.
|
||||||
* 4. A freshly generated self-signed cert, which a real head unit rejects (see
|
* 3. A freshly generated self-signed cert, which a real head unit rejects (see
|
||||||
* SPIKE_FINDINGS.md).
|
* SPIKE_FINDINGS.md).
|
||||||
*/
|
*/
|
||||||
object CertProvider {
|
object CertProvider {
|
||||||
private val log = GearslipLog.tagged("CERT")
|
|
||||||
|
|
||||||
private const val P12_NAME = "phone.p12"
|
private const val P12_NAME = "phone.p12"
|
||||||
private const val PASS_NAME = "phone.pass"
|
private const val PASS_NAME = "phone.pass"
|
||||||
private const val DEFAULT_PASSWORD = "aaspike"
|
private const val DEFAULT_PASSWORD = "aaspike"
|
||||||
private const val IMPORT_DIR = "identity"
|
private const val IMPORT_DIR = "identity"
|
||||||
|
|
||||||
enum class Kind { IMPORTED, DOWNLOADED, ADB_STAGED, SELF_SIGNED }
|
enum class Kind { IMPORTED, ADB_STAGED, SELF_SIGNED }
|
||||||
|
|
||||||
class Identity(
|
class Identity(
|
||||||
val keyStore: KeyStore,
|
val keyStore: KeyStore,
|
||||||
@@ -47,10 +43,7 @@ object CertProvider {
|
|||||||
val kind: Kind = Kind.SELF_SIGNED,
|
val kind: Kind = Kind.SELF_SIGNED,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/** Loaded fresh each call, so a newly imported or pushed cert takes effect on the next connect. */
|
||||||
* 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 {
|
fun load(context: Context): Identity = loadFromFiles(context) ?: run {
|
||||||
val generated = SelfSignedCert.generate()
|
val generated = SelfSignedCert.generate()
|
||||||
Identity(
|
Identity(
|
||||||
@@ -68,50 +61,12 @@ object CertProvider {
|
|||||||
*/
|
*/
|
||||||
fun loadSupplied(context: Context): Identity? = loadFromFiles(context)
|
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? {
|
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)
|
val imported = importedFile(context, P12_NAME)
|
||||||
if (imported.isFile) {
|
if (imported.isFile) {
|
||||||
runCatching {
|
runCatching {
|
||||||
return loadP12(imported, importedPassword(context), "imported certificate", Kind.IMPORTED)
|
return loadP12(imported, importedPassword(context), "imported certificate", Kind.IMPORTED)
|
||||||
}.onFailure { log.e("could not load the imported certificate - trying the next source", it) }
|
}.onFailure { GearslipLog.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 dir = context.getExternalFilesDir(null)
|
||||||
@@ -125,7 +80,7 @@ object CertProvider {
|
|||||||
DEFAULT_PASSWORD
|
DEFAULT_PASSWORD
|
||||||
}
|
}
|
||||||
return loadP12(staged, password, "$P12_NAME staged over adb", Kind.ADB_STAGED)
|
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) }
|
}.onFailure { GearslipLog.e("could not load $P12_NAME - falling back to self-signed", it) }
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -148,7 +103,7 @@ object CertProvider {
|
|||||||
target.parentFile?.mkdirs()
|
target.parentFile?.mkdirs()
|
||||||
staging.copyTo(target, overwrite = true)
|
staging.copyTo(target, overwrite = true)
|
||||||
importedFile(context, PASS_NAME).writeText(password)
|
importedFile(context, PASS_NAME).writeText(password)
|
||||||
log.i("imported a certificate: ${identity.certificate.subjectX500Principal}")
|
GearslipLog.i("imported a certificate: ${identity.certificate.subjectX500Principal}")
|
||||||
return identity
|
return identity
|
||||||
} finally {
|
} finally {
|
||||||
staging.delete()
|
staging.delete()
|
||||||
@@ -157,84 +112,17 @@ object CertProvider {
|
|||||||
|
|
||||||
fun hasImported(context: Context): Boolean = importedFile(context, P12_NAME).isFile
|
fun hasImported(context: Context): Boolean = importedFile(context, P12_NAME).isFile
|
||||||
|
|
||||||
/** Whether a downloaded certificate is stored (may or may not also be imported). */
|
/** Drops the imported identity; the next source in line (adb-staged, then self-signed) takes over. */
|
||||||
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) {
|
fun removeImported(context: Context) {
|
||||||
importedFile(context, P12_NAME).delete()
|
importedFile(context, P12_NAME).delete()
|
||||||
importedFile(context, PASS_NAME).delete()
|
importedFile(context, PASS_NAME).delete()
|
||||||
log.i("removed the imported certificate")
|
GearslipLog.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
|
// 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.
|
// 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 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 =
|
private fun importedPassword(context: Context): String =
|
||||||
importedFile(context, PASS_NAME).takeIf { it.isFile }?.readText().orEmpty()
|
importedFile(context, PASS_NAME).takeIf { it.isFile }?.readText().orEmpty()
|
||||||
|
|
||||||
|
|||||||
@@ -1,120 +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.
|
|
||||||
*
|
|
||||||
* After 0.3.40, the version code is the one the build works out from the tag (see gitVersion in
|
|
||||||
* build.gradle.kts): ((major*100 + minor)*100 + patch)*10 + stage, so 0.4.0-beta is 4002. Older
|
|
||||||
* entries keep the hand-set codes they shipped with, which are all lower.
|
|
||||||
*/
|
|
||||||
object Changelog {
|
|
||||||
|
|
||||||
class Release(val versionCode: Int, val versionName: String, val changes: List<String>)
|
|
||||||
|
|
||||||
val releases = listOf(
|
|
||||||
Release(
|
|
||||||
15, "0.3.40-alpha",
|
|
||||||
listOf(
|
|
||||||
"A liked song lights up the whole like button, as shuffle and repeat do.",
|
|
||||||
"Music no longer carries on from the phone after you turn the car off.",
|
|
||||||
"Cars that hold back their screen are asked for it again in a way older cars understand.",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Release(
|
|
||||||
14, "0.3.39-alpha",
|
|
||||||
listOf(
|
|
||||||
"Turns from Google Maps or Waze on the phone show in the nav bar and on the car's cluster.",
|
|
||||||
"Alerts from car apps show here, such as Waze asking whether a hazard is still there.",
|
|
||||||
"Hide apps you don't use in the car from the Apps card on the phone.",
|
|
||||||
"Make text in car apps bigger with Car display density, under Screen.",
|
|
||||||
"Music playing through Gearslip comes back after a call.",
|
|
||||||
"Maps keep your position while the phone is locked.",
|
|
||||||
"Wireless keeps trying until the car answers.",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
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,226 +0,0 @@
|
|||||||
package app.seb3thehacker.gearslip
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import app.seb3thehacker.gearslip.car.ClusterScreen
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gearslip's picture on the car's instrument-cluster screen: a second video sink, for a car that
|
|
||||||
* offers one, showing the turn card ([ClusterScreen]). The same exchange as the main screen's,
|
|
||||||
* on its own channel, with its own encoder and its own private display:
|
|
||||||
*
|
|
||||||
* -> CHANNEL_OPEN_REQUEST <- CHANNEL_OPEN_RESPONSE
|
|
||||||
* -> Setup { H.264 } <- Config { status, max_unacked, configuration_indices }
|
|
||||||
* -> VideoFocusRequest <- VideoFocusNotification { projected }
|
|
||||||
* -> Start, CodecConfig, Data... <- Ack
|
|
||||||
*
|
|
||||||
* No touch: a cluster has none. Experimental, behind [car.CarSettings.clusterVideoEnabled].
|
|
||||||
*
|
|
||||||
* Gearslip holds the cluster only while there's a route, as the card has nothing else to show.
|
|
||||||
* Otherwise the car keeps its own picture there: an Audi's virtual cockpit went black instead of
|
|
||||||
* showing its map (issue #56). [watchRoute] asks for the screen when a route starts and hands it
|
|
||||||
* back once there has been none for [NO_ROUTE_MS].
|
|
||||||
*/
|
|
||||||
internal class ClusterVideo(
|
|
||||||
private val context: Context,
|
|
||||||
private val service: ServiceDiscovery.VideoService,
|
|
||||||
private val send: (messageId: Int, body: ByteArray) -> Unit,
|
|
||||||
) {
|
|
||||||
private val log = GearslipLog.tagged("CLUSTER")
|
|
||||||
private val main = Handler(Looper.getMainLooper())
|
|
||||||
|
|
||||||
val channelId get() = service.serviceId
|
|
||||||
|
|
||||||
private var configIndex = 0
|
|
||||||
@Volatile private var started = false
|
|
||||||
/** Configured and ready for focus requests. */
|
|
||||||
@Volatile private var ready = false
|
|
||||||
/** The car shows Gearslip's picture now; frames aren't sent while it shows its own. */
|
|
||||||
@Volatile private var projected = false
|
|
||||||
/** Gearslip has asked for the screen and not handed it back since. */
|
|
||||||
private var wanted = false
|
|
||||||
private var noRouteSince = 0L
|
|
||||||
@Volatile private var running = true
|
|
||||||
@Volatile private var source: VideoSource? = null
|
|
||||||
private var projector: ScreenProjector? = null
|
|
||||||
|
|
||||||
private val ackLock = Object()
|
|
||||||
@Volatile private var inFlight = 0
|
|
||||||
@Volatile private var maxUnacked = 1
|
|
||||||
@Volatile private var lastAckAt = 0L
|
|
||||||
private var frames = 0
|
|
||||||
/** The part of the picture the car's own gauges and bars leave clear, as the car last said. */
|
|
||||||
private val clearArea = MutableStateFlow<Insets?>(null)
|
|
||||||
|
|
||||||
fun onMessage(messageId: Int, body: ByteArray) {
|
|
||||||
when (messageId) {
|
|
||||||
GearslipRunner.MSG_CHANNEL_OPEN_RESPONSE -> {
|
|
||||||
val status = Protobuf.readInt32Field(body, 1) ?: 0
|
|
||||||
log.i("<- ChannelOpenResponse: status=$status")
|
|
||||||
if (status != 0) return
|
|
||||||
send(GearslipRunner.MSG_MEDIA_SETUP, Protobuf.varintField(1, GearslipRunner.CODEC_H264_BP.toLong()))
|
|
||||||
log.i("-> Setup(codec=VIDEO_H264_BP)")
|
|
||||||
}
|
|
||||||
|
|
||||||
GearslipRunner.MSG_MEDIA_CONFIG -> {
|
|
||||||
val fields = Wire.fields(body)
|
|
||||||
Wire.varint(fields, 2)?.toInt()?.let { maxUnacked = maxOf(1, it) }
|
|
||||||
val offered = fields.filter { it.number == 3 && it.wireType == 0 }.map { it.varint.toInt() }
|
|
||||||
// The cluster gets the smallest picture it offers: a turn card needs no more.
|
|
||||||
configIndex = offered.minByOrNull { service.configs.getOrNull(it)?.pixelCount() ?: Int.MAX_VALUE } ?: 0
|
|
||||||
log.i("<- Config status=${Wire.varint(fields, 1)} max_unacked=$maxUnacked offered=$offered - using $configIndex")
|
|
||||||
ready = true
|
|
||||||
main.post(watchRoute)
|
|
||||||
}
|
|
||||||
|
|
||||||
GearslipRunner.MSG_VIDEO_FOCUS_NOTIFICATION -> {
|
|
||||||
val mode = Wire.varint(Wire.fields(body), 1)?.toInt() ?: GearslipRunner.VIDEO_FOCUS_PROJECTED
|
|
||||||
log.i("<- VideoFocusNotification: mode=$mode")
|
|
||||||
val now = mode == GearslipRunner.VIDEO_FOCUS_PROJECTED || mode == GearslipRunner.VIDEO_FOCUS_PROJECTED_NO_INPUT
|
|
||||||
val back = now && !projected
|
|
||||||
projected = now
|
|
||||||
if (now && !started) startStream()
|
|
||||||
// Back from the car's own picture: a fresh key frame, so it doesn't start on a partial one.
|
|
||||||
else if (back) source?.requestSyncFrame()
|
|
||||||
}
|
|
||||||
|
|
||||||
GearslipRunner.MSG_MEDIA_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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateUiConfigRequest { AdditionalVideoConfig { display_insets = 1 } }: an Audi's
|
|
||||||
// virtual cockpit sends one as the picture starts, and draws its dials over the frame.
|
|
||||||
GearslipRunner.MSG_VIDEO_UI_CONFIG -> {
|
|
||||||
log.i("<- UpdateUiConfigRequest (${body.size} bytes)\n${Protobuf.describe(body)}")
|
|
||||||
clearArea.value = insetsFrom(body)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> log.i("<- unhandled message id=$messageId (${body.size} bytes)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Once a second: the cluster follows whether there's a route. */
|
|
||||||
private val watchRoute = object : Runnable {
|
|
||||||
override fun run() {
|
|
||||||
if (!running || !ready) return
|
|
||||||
val route = app.seb3thehacker.gearslip.car.NavTurn.current() != null
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
if (route) noRouteSince = 0L else if (noRouteSince == 0L) noRouteSince = now
|
|
||||||
when {
|
|
||||||
route && !wanted -> requestFocus(GearslipRunner.VIDEO_FOCUS_PROJECTED, "a route started")
|
|
||||||
!route && wanted && now - noRouteSince >= NO_ROUTE_MS -> requestFocus(GearslipRunner.VIDEO_FOCUS_NATIVE, "no route")
|
|
||||||
}
|
|
||||||
main.postDelayed(this, WATCH_MS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun requestFocus(mode: Int, why: String) {
|
|
||||||
wanted = mode == GearslipRunner.VIDEO_FOCUS_PROJECTED
|
|
||||||
// VideoFocusRequestNotification { disp_channel_id = 1; mode = 2; reason = 3 }, as the main
|
|
||||||
// screen asks for its own picture back.
|
|
||||||
val reason = if (wanted) 1L else GearslipRunner.VIDEO_FOCUS_REASON_LAUNCH_NATIVE.toLong()
|
|
||||||
val body = Protobuf.varintField(1, channelId.toLong()) + Protobuf.varintField(2, mode.toLong()) + Protobuf.varintField(3, reason)
|
|
||||||
runCatching { send(GearslipRunner.MSG_VIDEO_FOCUS_REQUEST, body) }
|
|
||||||
log.i("-> VideoFocusRequest(mode=${if (wanted) "PROJECTED" else "NATIVE"}): $why")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startStream() {
|
|
||||||
val config = service.configs.getOrNull(configIndex) ?: service.configs.firstOrNull() ?: return
|
|
||||||
val (width, height) = config.pixelSize() ?: run {
|
|
||||||
log.w("unsupported cluster resolution ${config.resolutionName}")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
started = true
|
|
||||||
send(
|
|
||||||
GearslipRunner.MSG_MEDIA_START,
|
|
||||||
Protobuf.varintField(1, GearslipRunner.SESSION_ID.toLong()) + Protobuf.varintField(2, configIndex.toLong()),
|
|
||||||
)
|
|
||||||
log.i("-> Start(config=$configIndex -> $config)")
|
|
||||||
lastAckAt = System.currentTimeMillis()
|
|
||||||
val video = VideoSource(
|
|
||||||
width = width,
|
|
||||||
height = height,
|
|
||||||
frameRate = FRAME_RATE,
|
|
||||||
mode = VideoSource.Mode.SURFACE,
|
|
||||||
wireless = true, // a small share of the link: the main screen comes first
|
|
||||||
onCodecConfig = { csd ->
|
|
||||||
send(GearslipRunner.MSG_MEDIA_CODEC_CONFIG, csd)
|
|
||||||
log.i("-> CodecConfig (${csd.size} bytes)")
|
|
||||||
},
|
|
||||||
onFrame = { data, pts, _ -> sendFrame(data, pts) },
|
|
||||||
)
|
|
||||||
source = video
|
|
||||||
runCatching { video.start() }.onFailure {
|
|
||||||
log.e("cluster encoder failed to start", it)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val surface = video.inputSurface ?: return
|
|
||||||
val density = config.density.takeIf { it > 0 } ?: 160
|
|
||||||
main.post {
|
|
||||||
if (!running) return@post
|
|
||||||
projector = ScreenProjector(context).also {
|
|
||||||
it.start(surface, width, height, density, name = "gearslip-cluster") {
|
|
||||||
// The margins the car crops off are kept clear, as on the main screen.
|
|
||||||
ClusterScreen(config.widthMargin, config.heightMargin, density, clearArea)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** As on the main screen: wait for the car's acks rather than drop a frame. */
|
|
||||||
private fun sendFrame(data: ByteArray, presentationTimeUs: Long) {
|
|
||||||
synchronized(ackLock) {
|
|
||||||
while (running && inFlight >= maxUnacked) {
|
|
||||||
val since = System.currentTimeMillis() - lastAckAt
|
|
||||||
if (since > GearslipRunner.ACK_STALL_MS) {
|
|
||||||
inFlight = 0
|
|
||||||
lastAckAt = System.currentTimeMillis()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
ackLock.wait(GearslipRunner.ACK_STALL_MS - since + 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!running || !projected) return
|
|
||||||
val timestamp = ByteArray(8)
|
|
||||||
for (i in 0 until 8) timestamp[i] = ((presentationTimeUs shr ((7 - i) * 8)) and 0xFF).toByte()
|
|
||||||
synchronized(ackLock) { inFlight++ }
|
|
||||||
runCatching { send(GearslipRunner.MSG_MEDIA_DATA, timestamp + data) }.onFailure { running = false }
|
|
||||||
if (++frames <= 2) log.i("-> Data frame #$frames (${data.size} bytes)")
|
|
||||||
}
|
|
||||||
|
|
||||||
/** VideoInsets { top = 1; bottom = 2; left = 3; right = 4 }, or null when the car sent none. */
|
|
||||||
private fun insetsFrom(body: ByteArray): Insets? {
|
|
||||||
val config = Wire.bytes(Wire.fields(body), 1) ?: return null
|
|
||||||
val insets = Wire.fields(Wire.bytes(Wire.fields(config), 1) ?: return null)
|
|
||||||
fun side(n: Int) = Wire.varint(insets, n)?.toInt() ?: 0
|
|
||||||
return Insets(top = side(1), bottom = side(2), left = side(3), right = side(4))
|
|
||||||
.also { log.i("the car keeps clear: $it") }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stop() {
|
|
||||||
running = false
|
|
||||||
main.removeCallbacks(watchRoute)
|
|
||||||
synchronized(ackLock) { ackLock.notifyAll() }
|
|
||||||
main.post {
|
|
||||||
projector?.stop()
|
|
||||||
projector = null
|
|
||||||
}
|
|
||||||
source?.stop()
|
|
||||||
source = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
// A turn card changes a few times a minute; the encoder repeats the last frame between.
|
|
||||||
const val FRAME_RATE = 15
|
|
||||||
const val WATCH_MS = 1_000L
|
|
||||||
/** A gap in the turns this long is the route over, not a reroute or a moment between steps. */
|
|
||||||
const val NO_ROUTE_MS = 10_000L
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,10 +12,9 @@ import java.util.concurrent.TimeUnit
|
|||||||
* the TLS self-test: make our own failures loud on the bench.
|
* the TLS self-test: make our own failures loud on the bench.
|
||||||
*/
|
*/
|
||||||
object EncoderSelfTest {
|
object EncoderSelfTest {
|
||||||
private val log = GearslipLog.tagged("VIDEO")
|
|
||||||
|
|
||||||
fun run(width: Int = 800, height: Int = 480, frameRate: Int = 30): Boolean {
|
fun run(width: Int = 800, height: Int = 480, frameRate: Int = 30): Boolean {
|
||||||
log.i("=== encoder self-test starting (${width}x$height @ ${frameRate}fps) ===")
|
GearslipLog.i("=== encoder self-test starting (${width}x$height @ ${frameRate}fps) ===")
|
||||||
|
|
||||||
var codecConfig: ByteArray? = null
|
var codecConfig: ByteArray? = null
|
||||||
var frames = 0
|
var frames = 0
|
||||||
@@ -60,37 +59,37 @@ object EncoderSelfTest {
|
|||||||
val csd = codecConfig
|
val csd = codecConfig
|
||||||
when {
|
when {
|
||||||
csd == null -> {
|
csd == null -> {
|
||||||
log.e("encoder self-test FAILED: no CODEC_CONFIG (SPS/PPS) produced")
|
GearslipLog.e("encoder self-test FAILED: no CODEC_CONFIG (SPS/PPS) produced")
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
frames == 0 -> {
|
frames == 0 -> {
|
||||||
log.e("encoder self-test FAILED: no frames produced")
|
GearslipLog.e("encoder self-test FAILED: no frames produced")
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
!sawKeyFrame -> {
|
!sawKeyFrame -> {
|
||||||
log.e("encoder self-test FAILED: no keyframe - the car needs one first")
|
GearslipLog.e("encoder self-test FAILED: no keyframe - the car needs one first")
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
!sawResyncKeyFrame -> {
|
!sawResyncKeyFrame -> {
|
||||||
log.e(
|
GearslipLog.e(
|
||||||
"encoder self-test FAILED: requestSyncFrame() produced no keyframe - " +
|
"encoder self-test FAILED: requestSyncFrame() produced no keyframe - " +
|
||||||
"the resync-after-drop fix would silently do nothing on this device",
|
"the resync-after-drop fix would silently do nothing on this device",
|
||||||
)
|
)
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
log.i(
|
GearslipLog.i(
|
||||||
"SPS/PPS = ${csd.size} bytes, $frames frames, keyframe present, " +
|
"SPS/PPS = ${csd.size} bytes, $frames frames, keyframe present, " +
|
||||||
"requestSyncFrame() confirmed working",
|
"requestSyncFrame() confirmed working",
|
||||||
)
|
)
|
||||||
log.hex(" csd", csd, limit = 48)
|
GearslipLog.hex(" csd", csd, limit = 48)
|
||||||
log.i("=== encoder self-test PASSED ===")
|
GearslipLog.i("=== encoder self-test PASSED ===")
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
runCatching { source.stop() }
|
runCatching { source.stop() }
|
||||||
log.e("encoder self-test FAILED with an exception", t)
|
GearslipLog.e("encoder self-test FAILED with an exception", t)
|
||||||
false
|
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,10 +1,20 @@
|
|||||||
package app.seb3thehacker.gearslip
|
package app.seb3thehacker.gearslip
|
||||||
|
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.IOException
|
|
||||||
import java.io.OutputStream
|
|
||||||
|
|
||||||
/** Android Auto framing with plaintext fragmentation before per-frame encryption. */
|
/**
|
||||||
|
* Android Auto frame layout, transcribed from aasdk.
|
||||||
|
*
|
||||||
|
* FrameHeader::getData (src/Messenger/FrameHeader.cpp:47-55),
|
||||||
|
* FrameSize::getData (src/Messenger/FrameSize.cpp:49-62) and
|
||||||
|
* MessageOutStream::compoundFrame (src/Messenger/MessageOutStream.cpp:100-120):
|
||||||
|
*
|
||||||
|
* byte 0 channel id
|
||||||
|
* byte 1 frameType | encryption | messageType
|
||||||
|
* bytes 2-3 uint16 BE payload length of THIS frame
|
||||||
|
* bytes 4-7 uint32 BE total message length - present ONLY when frameType == FIRST
|
||||||
|
* payload uint16 BE message id, then the body
|
||||||
|
*/
|
||||||
object Frames {
|
object Frames {
|
||||||
|
|
||||||
const val CHANNEL_CONTROL = 0
|
const val CHANNEL_CONTROL = 0
|
||||||
@@ -26,11 +36,8 @@ object Frames {
|
|||||||
const val MESSAGE_SPECIFIC = 0
|
const val MESSAGE_SPECIFIC = 0
|
||||||
const val MESSAGE_CONTROL = 1 shl 2
|
const val MESSAGE_CONTROL = 1 shl 2
|
||||||
|
|
||||||
// Android Auto's default fragment budget includes the framing header. TLS adds overhead.
|
/** aasdk splits at this size (MessageOutStream.hpp:62). Nothing the spike sends is close. */
|
||||||
private const val FRAGMENT_SIZE = 0x3f00
|
const val MAX_FRAME_PAYLOAD = 0x4000
|
||||||
private const val HEADER_SIZE = 4
|
|
||||||
private const val FIRST_HEADER_SIZE = 8
|
|
||||||
const val MAX_FRAME_PAYLOAD = FRAGMENT_SIZE - HEADER_SIZE
|
|
||||||
|
|
||||||
class Frame(
|
class Frame(
|
||||||
val channel: Int,
|
val channel: Int,
|
||||||
@@ -38,7 +45,6 @@ object Frames {
|
|||||||
val encrypted: Boolean,
|
val encrypted: Boolean,
|
||||||
val messageType: Int,
|
val messageType: Int,
|
||||||
val payload: ByteArray,
|
val payload: ByteArray,
|
||||||
val totalSize: Int? = null,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,11 +60,6 @@ object Frames {
|
|||||||
frameType: Int = TYPE_BULK,
|
frameType: Int = TYPE_BULK,
|
||||||
totalSize: Int? = null,
|
totalSize: Int? = null,
|
||||||
): ByteArray {
|
): 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 flags = frameType or messageType or (if (encrypted) ENCRYPTED else 0)
|
||||||
val header = byteArrayOf(
|
val header = byteArrayOf(
|
||||||
channel.toByte(),
|
channel.toByte(),
|
||||||
@@ -66,7 +67,7 @@ object Frames {
|
|||||||
((payload.size shr 8) and 0xFF).toByte(),
|
((payload.size shr 8) and 0xFF).toByte(),
|
||||||
(payload.size and 0xFF).toByte(),
|
(payload.size and 0xFF).toByte(),
|
||||||
)
|
)
|
||||||
if (totalSize == null) return header + payload
|
if (frameType != TYPE_FIRST || totalSize == null) return header + payload
|
||||||
val extended = byteArrayOf(
|
val extended = byteArrayOf(
|
||||||
((totalSize shr 24) and 0xFF).toByte(),
|
((totalSize shr 24) and 0xFF).toByte(),
|
||||||
((totalSize shr 16) and 0xFF).toByte(),
|
((totalSize shr 16) and 0xFF).toByte(),
|
||||||
@@ -76,43 +77,6 @@ object Frames {
|
|||||||
return header + extended + payload
|
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.
|
* Pulls whole frames out of an accumulating buffer.
|
||||||
*
|
*
|
||||||
@@ -123,8 +87,6 @@ object Frames {
|
|||||||
private var pending = ByteArray(0)
|
private var pending = ByteArray(0)
|
||||||
|
|
||||||
fun append(data: ByteArray, length: Int) {
|
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)
|
val grown = ByteArray(pending.size + length)
|
||||||
System.arraycopy(pending, 0, grown, 0, pending.size)
|
System.arraycopy(pending, 0, grown, 0, pending.size)
|
||||||
System.arraycopy(data, 0, grown, pending.size, length)
|
System.arraycopy(data, 0, grown, pending.size, length)
|
||||||
@@ -144,53 +106,30 @@ object Frames {
|
|||||||
val total = headerLength + frameSize
|
val total = headerLength + frameSize
|
||||||
if (pending.size < total) return null
|
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)
|
val payload = pending.copyOfRange(headerLength, total)
|
||||||
pending = pending.copyOfRange(total, pending.size)
|
pending = pending.copyOfRange(total, pending.size)
|
||||||
return Frame(
|
return Frame(
|
||||||
channel, frameType, (flags and ENCRYPTED) != 0, flags and MESSAGE_CONTROL, payload, messageSize,
|
channel, frameType, (flags and ENCRYPTED) != 0, flags and MESSAGE_CONTROL, payload,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rejoins messages on independent channels, validating their plaintext length and flags. */
|
/** Rejoins FIRST/MIDDLE/LAST runs. Payloads arrive already decrypted. */
|
||||||
class Assembler {
|
class Assembler {
|
||||||
private class Partial(val first: Frame, val data: ByteArrayOutputStream = ByteArrayOutputStream())
|
private val partial = HashMap<Int, ByteArrayOutputStream>()
|
||||||
private val partial = HashMap<Int, Partial>()
|
|
||||||
private var buffered = 0
|
|
||||||
|
|
||||||
fun offer(frame: Frame, payload: ByteArray): ByteArray? {
|
fun offer(frame: Frame, payload: ByteArray): ByteArray? = when (frame.frameType) {
|
||||||
if (frame.frameType == TYPE_BULK) {
|
TYPE_BULK -> payload
|
||||||
if (partial.containsKey(frame.channel)) fail("Complete message interrupted fragments")
|
TYPE_FIRST -> {
|
||||||
return payload
|
partial[frame.channel] = ByteArrayOutputStream().apply { write(payload) }
|
||||||
|
null
|
||||||
}
|
}
|
||||||
val message = if (frame.frameType == TYPE_FIRST) {
|
TYPE_MIDDLE -> {
|
||||||
if (frame.totalSize == null || partial.containsKey(frame.channel)) fail("Invalid first fragment")
|
partial[frame.channel]?.write(payload)
|
||||||
Partial(frame).also { partial[frame.channel] = it }
|
null
|
||||||
} 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()
|
|
||||||
}
|
}
|
||||||
|
TYPE_LAST -> partial.remove(frame.channel)?.apply { write(payload) }?.toByteArray()
|
||||||
private fun fail(message: String): Nothing {
|
else -> null
|
||||||
partial.clear()
|
|
||||||
buffered = 0
|
|
||||||
throw IOException(message)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,16 +8,13 @@ import android.hardware.usb.UsbAccessory
|
|||||||
import android.hardware.usb.UsbManager
|
import android.hardware.usb.UsbManager
|
||||||
import android.media.projection.MediaProjection
|
import android.media.projection.MediaProjection
|
||||||
import android.media.projection.MediaProjectionManager
|
import android.media.projection.MediaProjectionManager
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.ParcelFileDescriptor
|
import android.os.ParcelFileDescriptor
|
||||||
import android.provider.Settings
|
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
|
import android.view.WindowManager
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
|
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
@@ -29,8 +26,6 @@ import app.seb3thehacker.gearslip.mirror.TouchRelayService
|
|||||||
import app.seb3thehacker.gearslip.car.CarEnvironment
|
import app.seb3thehacker.gearslip.car.CarEnvironment
|
||||||
import app.seb3thehacker.gearslip.car.CarSettings
|
import app.seb3thehacker.gearslip.car.CarSettings
|
||||||
import app.seb3thehacker.gearslip.car.CarUi
|
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.GearslipApp
|
||||||
import app.seb3thehacker.gearslip.ui.GearslipTheme
|
import app.seb3thehacker.gearslip.ui.GearslipTheme
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
@@ -44,12 +39,8 @@ import java.io.FileOutputStream
|
|||||||
* rotation or theme change from recreating it and dropping the connection.
|
* rotation or theme change from recreating it and dropping the connection.
|
||||||
*/
|
*/
|
||||||
class GearslipActivity : ComponentActivity(), Projection {
|
class GearslipActivity : ComponentActivity(), Projection {
|
||||||
private val mainHandler = android.os.Handler(android.os.Looper.getMainLooper())
|
|
||||||
|
|
||||||
|
|
||||||
private var descriptor: ParcelFileDescriptor? = null
|
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 runner: GearslipRunner? = null
|
||||||
private var screenProjector: ScreenProjector? = null
|
private var screenProjector: ScreenProjector? = null
|
||||||
private var worker: Thread? = null
|
private var worker: Thread? = null
|
||||||
@@ -77,13 +68,7 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
private val audioPermission = registerForActivityResult(
|
private val audioPermission = registerForActivityResult(
|
||||||
androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
|
androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
|
||||||
) { granted ->
|
) { granted ->
|
||||||
if (granted) askAudioCapture()
|
if (granted) askAudioCapture() else {
|
||||||
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")
|
GearslipLog.w("audio: record permission declined")
|
||||||
CarAudio.denied()
|
CarAudio.denied()
|
||||||
}
|
}
|
||||||
@@ -96,111 +81,47 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
} else {
|
} else {
|
||||||
GearslipLog.w("audio: capture consent declined")
|
GearslipLog.w("audio: capture consent declined")
|
||||||
CarAudio.denied()
|
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() {
|
private val detachReceiver = object : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
if (intent?.action == UsbManager.ACTION_USB_ACCESSORY_DETACHED) {
|
if (intent?.action == UsbManager.ACTION_USB_ACCESSORY_DETACHED) {
|
||||||
GearslipLog.w("accessory detached")
|
GearslipLog.w("accessory detached")
|
||||||
// A wireless session has no accessory to lose; leave it running.
|
closeAccessory()
|
||||||
if (wirelessSocket == null) closeAccessory()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Android's one-time "Allow" for a car to connect by itself; see [WirelessAuto]. */
|
/** Hosted map apps need Gearslip to hold location so they can keep it in the background. */
|
||||||
private var approvingCar: String? = null
|
private val locationPermission = registerForActivityResult(
|
||||||
|
androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
|
||||||
|
) { }
|
||||||
|
|
||||||
/** Whether the phone showed Gearslip when the car's intent arrived; see [stepAside]. */
|
/** The car dashboard's agenda card; declined just leaves it empty rather than asking again. */
|
||||||
private var shownOnPhone = false
|
private val calendarPermission = registerForActivityResult(
|
||||||
private var stepAsideOnVideo = false
|
androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
|
||||||
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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
// Location, calendar, the phone/contacts group, and the microphone are asked for one at
|
if (checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION) !=
|
||||||
// a time by PermissionsSetupScreen instead of here, each only after the driver taps
|
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||||
// Allow on a screen explaining what it is for. Firing them all from onCreate, unprompted,
|
) locationPermission.launch(android.Manifest.permission.ACCESS_FINE_LOCATION)
|
||||||
// is both a bad first impression and unreliable: Android does not consistently show more
|
if (checkSelfPermission(android.Manifest.permission.READ_CALENDAR) !=
|
||||||
// than one system permission dialog fired back-to-back, which was silently dropping some.
|
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||||
|
) calendarPermission.launch(android.Manifest.permission.READ_CALENDAR)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
|
|
||||||
GearslipLog.init(this)
|
GearslipLog.init(this)
|
||||||
GearslipLog.installCrashHandler()
|
|
||||||
GearslipLog.i("Gearslip ready")
|
GearslipLog.i("Gearslip ready")
|
||||||
app.seb3thehacker.gearslip.wireless.WirelessLink.onConnected = { socket ->
|
|
||||||
runOnUiThread { startWireless(socket) }
|
|
||||||
}
|
|
||||||
app.seb3thehacker.gearslip.wireless.WirelessLink.sessionWaiting = { runner?.waitingForVersion == true }
|
|
||||||
app.seb3thehacker.gearslip.wireless.WirelessAuto.showApproval = { chooser, address ->
|
|
||||||
runOnUiThread {
|
|
||||||
approvingCar = address
|
|
||||||
carApproval.launch(androidx.activity.result.IntentSenderRequest.Builder(chooser).build())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
CarSettings.init(this)
|
CarSettings.init(this)
|
||||||
// Before any car connects: CarServices only starts once the car shows Gearslip's screen,
|
|
||||||
// and a car that waits to know the phone before it shows it would never hear (issue #43).
|
|
||||||
app.seb3thehacker.gearslip.car.CarBluetooth.init(this)
|
|
||||||
app.seb3thehacker.gearslip.browser.BrowserSettings.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)
|
CarEnvironment.setPhoneTheme(resources.configuration)
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
GearslipTheme {
|
GearslipTheme {
|
||||||
GearslipApp(onDisconnect = { closeAccessory() }, onRequestCallScreening = ::requestCallScreeningRole)
|
GearslipApp(onDisconnect = ::closeAccessory)
|
||||||
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 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,28 +141,6 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
CarEnvironment.setPhoneTheme(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 onResume() {
|
|
||||||
super.onResume()
|
|
||||||
shownOnPhone = true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStop() {
|
|
||||||
super.onStop()
|
|
||||||
shownOnPhone = false
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
handleIntent(intent)
|
handleIntent(intent)
|
||||||
@@ -249,9 +148,6 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
app.seb3thehacker.gearslip.wireless.WirelessLink.onConnected = null
|
|
||||||
app.seb3thehacker.gearslip.wireless.WirelessLink.sessionWaiting = null
|
|
||||||
app.seb3thehacker.gearslip.wireless.WirelessAuto.showApproval = null
|
|
||||||
runCatching { unregisterReceiver(detachReceiver) }
|
runCatching { unregisterReceiver(detachReceiver) }
|
||||||
PhoneMirror.onStartRequested = null
|
PhoneMirror.onStartRequested = null
|
||||||
PhoneMirror.onTokenReady = null
|
PhoneMirror.onTokenReady = null
|
||||||
@@ -263,19 +159,6 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
GearslipLog.flush()
|
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 --------------------------------------------------------------------
|
// --- Media audio --------------------------------------------------------------------
|
||||||
|
|
||||||
private fun requestAudioCapture() {
|
private fun requestAudioCapture() {
|
||||||
@@ -316,8 +199,7 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
val target = surface ?: return
|
val target = surface ?: return
|
||||||
if (screenProjector != null) return
|
if (screenProjector != null) return
|
||||||
val projector = ScreenProjector(this).also { screenProjector = it }
|
val projector = ScreenProjector(this).also { screenProjector = it }
|
||||||
// The frame's density, not frameDensity: that stays the car's own, for the phone mirror.
|
projector.start(target, frameWidth, frameHeight, frameDensity) { CarUi() }
|
||||||
projector.start(target, frameWidth, frameHeight, CarEnvironment.frame.value.densityDpi) { CarUi() }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -341,20 +223,7 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun handleIntent(intent: Intent?) {
|
private fun handleIntent(intent: Intent?) {
|
||||||
// Debug builds only: opens the audio choice for a look, without turning down a real prompt.
|
|
||||||
if (BuildConfig.DEBUG && intent?.getBooleanExtra("show_audio_choice", false) == true) offerBluetooth.value = true
|
|
||||||
if (intent?.action == app.seb3thehacker.gearslip.wireless.WirelessAuto.ACTION_CAR_NEARBY) {
|
|
||||||
stepAsideOnVideo = !shownOnPhone
|
|
||||||
connectToNearbyCar(intent.getStringExtra(app.seb3thehacker.gearslip.wireless.WirelessAuto.EXTRA_ADDRESS))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (intent?.action != UsbManager.ACTION_USB_ACCESSORY_ATTACHED) 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)
|
val accessory = intent.getParcelableExtra(UsbManager.EXTRA_ACCESSORY, UsbAccessory::class.java)
|
||||||
if (accessory == null) {
|
if (accessory == null) {
|
||||||
GearslipLog.e("attach intent carried no accessory")
|
GearslipLog.e("attach intent carried no accessory")
|
||||||
@@ -362,51 +231,14 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
GearslipLog.i("accessory attached by the system - permission is implicit on this path")
|
GearslipLog.i("accessory attached by the system - permission is implicit on this path")
|
||||||
stepAsideOnVideo = !shownOnPhone
|
|
||||||
start(accessory)
|
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) {
|
private fun start(accessory: UsbAccessory) {
|
||||||
// A fresh ATTACHED intent means the system wants a new session, whatever state the old
|
if (worker?.isAlive == true) {
|
||||||
// one is in - closing first guarantees openAccessory() below never fights a descriptor
|
GearslipLog.w("a run is already in progress")
|
||||||
// this same process forgot to release. The old worker thread often isn't alive by this
|
return
|
||||||
// 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)
|
describe(accessory)
|
||||||
|
|
||||||
val manager = getSystemService(UsbManager::class.java)
|
val manager = getSystemService(UsbManager::class.java)
|
||||||
@@ -424,49 +256,9 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
identityProvider = { CertProvider.load(this) },
|
identityProvider = { CertProvider.load(this) },
|
||||||
projection = this,
|
projection = this,
|
||||||
vehicleProfileFor = { info -> VehicleProfiles.find(VehicleProfiles.load(this), info) },
|
vehicleProfileFor = { info -> VehicleProfiles.find(VehicleProfiles.load(this), info) },
|
||||||
phoneIcons = { PhoneIcons.pngs(this) },
|
|
||||||
)
|
)
|
||||||
this.runner = runner
|
this.runner = runner
|
||||||
worker = Thread { runner.run() }.apply { name = "gearslip"; start() }
|
worker = Thread { runner.run() }.apply { name = "gearslip"; start() }
|
||||||
holdLocation()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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,
|
|
||||||
phoneIcons = { PhoneIcons.pngs(this) },
|
|
||||||
)
|
|
||||||
this.runner = runner
|
|
||||||
worker = Thread { runner.run() }.apply { name = "gearslip"; start() }
|
|
||||||
holdLocation()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether this session holds [app.seb3thehacker.gearslip.host.LocationKeepAlive]. */
|
|
||||||
private var holdingLocation = false
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Android lets a foreground service started from the background use location only in the few
|
|
||||||
* seconds after one of the app's screens opens. The car just opened this one, so take the
|
|
||||||
* location service now: a map bound later, with the phone already locked, got no position.
|
|
||||||
*/
|
|
||||||
private fun holdLocation() {
|
|
||||||
if (holdingLocation) return
|
|
||||||
holdingLocation = true
|
|
||||||
app.seb3thehacker.gearslip.host.LocationKeepAlive.acquire(this) {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun describe(accessory: UsbAccessory) {
|
private fun describe(accessory: UsbAccessory) {
|
||||||
@@ -474,39 +266,15 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
GearslipLog.i(" version=${accessory.version} description=${accessory.description}")
|
GearslipLog.i(" version=${accessory.version} description=${accessory.description}")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun closeAccessory() = closeAccessory(stopWireless = true)
|
private fun closeAccessory() {
|
||||||
|
|
||||||
/** Ends the session, wired or wireless. [stopWireless] also drops the car's Bluetooth and Wi-Fi. */
|
|
||||||
private fun closeAccessory(stopWireless: Boolean) {
|
|
||||||
runner?.stop()
|
runner?.stop()
|
||||||
SessionReport.print()
|
|
||||||
runCatching { descriptor?.close() }
|
runCatching { descriptor?.close() }
|
||||||
descriptor = null
|
descriptor = null
|
||||||
runCatching { wirelessSocket?.close() }
|
|
||||||
wirelessSocket = null
|
|
||||||
if (stopWireless) app.seb3thehacker.gearslip.wireless.WirelessLink.stop(this)
|
|
||||||
runner = null
|
runner = null
|
||||||
if (holdingLocation) {
|
|
||||||
holdingLocation = false
|
|
||||||
app.seb3thehacker.gearslip.host.LocationKeepAlive.release(this)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Projection ---------------------------------------------------------------------
|
// --- Projection ---------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
|
||||||
* The car opens Gearslip on the phone, since Android hands a USB car only to an activity and
|
|
||||||
* auto-connect has to start one too. Once the car's screen is up, go back to whatever the
|
|
||||||
* phone showed, as Home would; the session carries on as it does with the phone locked. A
|
|
||||||
* question for the driver keeps Gearslip in front, and so does the phone already showing it.
|
|
||||||
*/
|
|
||||||
private fun stepAside() {
|
|
||||||
stepAsideOnVideo = false
|
|
||||||
if (offerBluetooth.value || micBlockedNotice.value || approvingCar != null) return
|
|
||||||
GearslipLog.i("the car's screen is up - moving Gearslip off the phone's screen")
|
|
||||||
moveTaskToBack(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSurfaceReady(surface: Surface, width: Int, height: Int, densityDpi: Int) {
|
override fun onSurfaceReady(surface: Surface, width: Int, height: Int, densityDpi: Int) {
|
||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
this.surface = surface
|
this.surface = surface
|
||||||
@@ -516,33 +284,19 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
CarEnvironment.setDisplay(width, height, densityDpi)
|
CarEnvironment.setDisplay(width, height, densityDpi)
|
||||||
CarEnvironment.start(this)
|
CarEnvironment.start(this)
|
||||||
showCarUi()
|
showCarUi()
|
||||||
if (stepAsideOnVideo) stepAside()
|
|
||||||
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 onStill() {
|
override fun onTouch(action: Int, x: Float, y: Float) {
|
||||||
screenProjector?.redraw()
|
runOnUiThread {
|
||||||
|
if (PhoneMirror.active.value) relayTouch(action, x, y)
|
||||||
|
else screenProjector?.dispatchTouch(action, x, y)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onProjectionStopped() {
|
override fun onProjectionStopped() {
|
||||||
// Before the car UI goes, while the media connection is still up.
|
|
||||||
app.seb3thehacker.gearslip.car.CarServices.pausePhoneMedia()
|
|
||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
PhoneMirror.requestStop()
|
PhoneMirror.requestStop()
|
||||||
app.seb3thehacker.gearslip.car.CarKeys.focusSink = null
|
|
||||||
screenProjector?.stop()
|
screenProjector?.stop()
|
||||||
screenProjector = null
|
screenProjector = null
|
||||||
surface = null
|
surface = null
|
||||||
|
|||||||
@@ -31,13 +31,6 @@ object GearslipLog {
|
|||||||
|
|
||||||
private var file: File? = null
|
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
|
// 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
|
// 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.
|
// snapshot per frame rather than one list copy per line.
|
||||||
@@ -47,109 +40,10 @@ object GearslipLog {
|
|||||||
val version: StateFlow<Int> = liveVersion
|
val version: StateFlow<Int> = liveVersion
|
||||||
|
|
||||||
fun init(context: Context) {
|
fun init(context: Context) {
|
||||||
// The phone app and the launcher both start here; one file per process.
|
|
||||||
if (file != null) return
|
|
||||||
val dir = context.getExternalFilesDir(null) ?: return
|
val dir = context.getExternalFilesDir(null) ?: return
|
||||||
val name = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
|
val name = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
|
||||||
file = File(dir, "gearslip-$name.log")
|
file = File(dir, "gearslip-$name.log")
|
||||||
i("log file: ${file?.absolutePath}")
|
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() } }
|
|
||||||
lastExit(context, dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
private const val KEEP_FILES = 30
|
|
||||||
private const val KEY_EXIT_SEEN = "log_exit_seen"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Why Android ended the last run, when the crash handler can't have seen it: a native crash,
|
|
||||||
* a freeze (ANR), or the memory killer. Written here and onto the end of that run's own log,
|
|
||||||
* which is the one a driver shares after a drive that went wrong.
|
|
||||||
*/
|
|
||||||
private fun lastExit(context: Context, dir: File) {
|
|
||||||
val am = context.getSystemService(android.app.ActivityManager::class.java) ?: return
|
|
||||||
val seen = AppSettings.getString(context, KEY_EXIT_SEEN, "0").toLongOrNull() ?: 0L
|
|
||||||
val exits = runCatching { am.getHistoricalProcessExitReasons(null, 0, 5) }.getOrDefault(emptyList())
|
|
||||||
.filter { it.timestamp > seen }
|
|
||||||
exits.firstOrNull()?.let { AppSettings.putString(context, KEY_EXIT_SEEN, it.timestamp.toString()) }
|
|
||||||
val parse = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US)
|
|
||||||
for (exit in exits.reversed()) {
|
|
||||||
val why = EXIT_REASONS[exit.reason] ?: continue
|
|
||||||
val at = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date(exit.timestamp))
|
|
||||||
val detail = buildString {
|
|
||||||
append("$at, $why")
|
|
||||||
exit.description?.takeIf { it.isNotBlank() }?.let { append(" ($it)") }
|
|
||||||
append(", memory ${exit.pss / 1024} MB")
|
|
||||||
if (exit.reason == android.app.ApplicationExitInfo.REASON_ANR) {
|
|
||||||
anrMainThread(exit)?.let { append("\n").append(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
w("Android ended an earlier run at $detail")
|
|
||||||
// The run that died: the newest log that started before it ended.
|
|
||||||
val run = logFiles(dir).filter { it != file }.firstOrNull { f ->
|
|
||||||
val started = runCatching { parse.parse(f.name.removePrefix("gearslip-").removeSuffix(".log")) }.getOrNull()
|
|
||||||
started != null && started.time <= exit.timestamp
|
|
||||||
} ?: continue
|
|
||||||
io.execute { runCatching { run.appendText("--- $EXIT_MARK $detail\n") } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The main thread's stack from an ANR trace: what Gearslip was stuck doing. */
|
|
||||||
private fun anrMainThread(exit: android.app.ApplicationExitInfo): String? = runCatching {
|
|
||||||
exit.traceInputStream?.bufferedReader()?.useLines { lines ->
|
|
||||||
lines.dropWhile { !it.startsWith("\"main\"") }.take(ANR_LINES).joinToString("\n")
|
|
||||||
}?.takeIf { it.isNotBlank() }
|
|
||||||
}.getOrNull()
|
|
||||||
|
|
||||||
private const val ANR_LINES = 25
|
|
||||||
private const val EXIT_MARK = "Android ended this run:"
|
|
||||||
|
|
||||||
/** Exit reasons worth a line; leaving, updating and Java crashes (already logged) aren't. */
|
|
||||||
private val EXIT_REASONS = mapOf(
|
|
||||||
android.app.ApplicationExitInfo.REASON_CRASH_NATIVE to "a native crash",
|
|
||||||
android.app.ApplicationExitInfo.REASON_ANR to "the app stopped responding (ANR)",
|
|
||||||
android.app.ApplicationExitInfo.REASON_LOW_MEMORY to "the phone ran low on memory",
|
|
||||||
android.app.ApplicationExitInfo.REASON_EXCESSIVE_RESOURCE_USAGE to "it used too much of the phone's resources",
|
|
||||||
android.app.ApplicationExitInfo.REASON_INITIALIZATION_FAILURE to "it failed to start",
|
|
||||||
android.app.ApplicationExitInfo.REASON_SIGNALED to "the system killed it",
|
|
||||||
android.app.ApplicationExitInfo.REASON_DEPENDENCY_DIED to "something it depends on died",
|
|
||||||
)
|
|
||||||
|
|
||||||
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 || EXIT_MARK 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() }
|
fun snapshot(): List<String> = synchronized(live) { live.toList() }
|
||||||
@@ -167,85 +61,13 @@ object GearslipLog {
|
|||||||
return if (f != null && f.isFile) f.readText() else snapshot().joinToString("\n")
|
return if (f != null && f.isFile) f.readText() else snapshot().joinToString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
fun i(message: String) = write("I", message)
|
||||||
* 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) = write("W", message)
|
||||||
|
|
||||||
fun w(message: String, tag: String = "APP") = write("W", message, tag)
|
fun e(message: String, t: Throwable? = null) {
|
||||||
|
write("E", if (t == null) message else "$message: ${t.javaClass.simpleName}: ${t.message}")
|
||||||
/** Logs the message, then the exception with its cause chain and top stack frames. */
|
if (t != null) Log.e(TAG, message, t)
|
||||||
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.
|
|
||||||
*/
|
|
||||||
@Volatile private var crashHandlerInstalled = false
|
|
||||||
|
|
||||||
fun installCrashHandler() {
|
|
||||||
if (crashHandlerInstalled) return
|
|
||||||
crashHandlerInstalled = true
|
|
||||||
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. */
|
/** Unmissable in a scrolling logcat - this is what the whole spike exists to print. */
|
||||||
@@ -267,8 +89,7 @@ object GearslipLog {
|
|||||||
runCatching { latch.await(timeoutMs, TimeUnit.MILLISECONDS) }
|
runCatching { latch.await(timeoutMs, TimeUnit.MILLISECONDS) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun hex(label: String, data: ByteArray, limit: Int = 256, tag: String = "APP") {
|
fun hex(label: String, data: ByteArray, limit: Int = 256) {
|
||||||
if (!debug) return
|
|
||||||
val shown = minOf(data.size, limit)
|
val shown = minOf(data.size, limit)
|
||||||
val sb = StringBuilder()
|
val sb = StringBuilder()
|
||||||
for (idx in 0 until shown) {
|
for (idx in 0 until shown) {
|
||||||
@@ -276,16 +97,16 @@ object GearslipLog {
|
|||||||
if (idx % 16 == 15) sb.append('\n') else sb.append(' ')
|
if (idx % 16 == 15) sb.append('\n') else sb.append(' ')
|
||||||
}
|
}
|
||||||
val suffix = if (data.size > limit) "\n... (${data.size - limit} more bytes)" else ""
|
val suffix = if (data.size > limit) "\n... (${data.size - limit} more bytes)" else ""
|
||||||
i("$label (${data.size} bytes)\n$sb$suffix", tag)
|
i("$label (${data.size} bytes)\n$sb$suffix")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun write(level: String, message: String, tag: String = "APP") {
|
private fun write(level: String, message: String) {
|
||||||
when (level) {
|
when (level) {
|
||||||
"W" -> Log.w(TAG, message)
|
"W" -> Log.w(TAG, message)
|
||||||
"E" -> Log.e(TAG, message)
|
"E" -> Log.e(TAG, message)
|
||||||
else -> if (debug) Log.i(TAG, message)
|
else -> Log.i(TAG, message)
|
||||||
}
|
}
|
||||||
val line = "${stamp.format(Date())} $level ${tag.padEnd(5)} $message"
|
val line = "${stamp.format(Date())} $level $message"
|
||||||
synchronized(live) {
|
synchronized(live) {
|
||||||
live.addLast(line)
|
live.addLast(line)
|
||||||
while (live.size > MAX_LIVE_LINES) live.removeFirst()
|
while (live.size > MAX_LIVE_LINES) live.removeFirst()
|
||||||
|
|||||||
@@ -1,152 +0,0 @@
|
|||||||
package app.seb3thehacker.gearslip
|
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.res.Configuration
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.KeyEvent
|
|
||||||
import android.view.WindowManager
|
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.OnBackPressedCallback
|
|
||||||
import androidx.activity.compose.setContent
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
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.material3.Button
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.core.view.WindowCompat
|
|
||||||
import androidx.core.view.WindowInsetsCompat
|
|
||||||
import androidx.core.view.WindowInsetsControllerCompat
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import app.seb3thehacker.gearslip.car.CarEnvironment
|
|
||||||
import app.seb3thehacker.gearslip.car.CarKeys
|
|
||||||
import app.seb3thehacker.gearslip.car.CarSettings
|
|
||||||
import app.seb3thehacker.gearslip.car.CarUi
|
|
||||||
import app.seb3thehacker.gearslip.car.LauncherMode
|
|
||||||
import app.seb3thehacker.gearslip.car.Prefetch
|
|
||||||
import app.seb3thehacker.gearslip.ui.CarFrame
|
|
||||||
import app.seb3thehacker.gearslip.ui.GearslipTheme
|
|
||||||
import app.seb3thehacker.gearslip.ui.rememberCarProjector
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The home screen in launcher mode (see [LauncherMode]): the car UI, filling this device's own
|
|
||||||
* screen. It runs for as long as the activity lives, not only while it's in front, so a map
|
|
||||||
* keeps guiding and music keeps playing while another app is open over it.
|
|
||||||
*/
|
|
||||||
class LauncherActivity : ComponentActivity() {
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
GearslipLog.init(this)
|
|
||||||
GearslipLog.installCrashHandler()
|
|
||||||
CarSettings.init(this)
|
|
||||||
CarKeys.init(this)
|
|
||||||
Prefetch.warm(this)
|
|
||||||
CarEnvironment.setPhoneTheme(resources.configuration)
|
|
||||||
|
|
||||||
// A dash screen is something to glance at: it stays on, with the system bars out of the way.
|
|
||||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
|
||||||
WindowInsetsControllerCompat(window, window.decorView).apply {
|
|
||||||
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
|
||||||
hide(WindowInsetsCompat.Type.systemBars())
|
|
||||||
}
|
|
||||||
// Back moves back in the car UI. A home screen has nowhere else to go back to.
|
|
||||||
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
|
||||||
override fun handleOnBackPressed() {
|
|
||||||
CarKeys.onKey(KeyEvent.KEYCODE_BACK, down = true, longPress = false)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
setContent {
|
|
||||||
GearslipTheme {
|
|
||||||
LauncherScreen(onOpenGearslip = { startActivity(Intent(this, GearslipActivity::class.java)) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Home pressed while Gearslip is already the home screen: back to the car UI's Home. */
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
|
||||||
super.onNewIntent(intent)
|
|
||||||
CarKeys.onKey(KeyEvent.KEYCODE_HOME, down = true, longPress = false)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
|
||||||
super.onConfigurationChanged(newConfig)
|
|
||||||
CarEnvironment.setPhoneTheme(newConfig)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A head unit's knob, D-pad and hard keys work the car UI the way a car's do. */
|
|
||||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
|
||||||
if (event.keyCode != KeyEvent.KEYCODE_BACK && event.repeatCount == 0 &&
|
|
||||||
CarKeys.onKey(event.keyCode, event.action == KeyEvent.ACTION_DOWN, event.isLongPress)
|
|
||||||
) return true
|
|
||||||
return super.dispatchKeyEvent(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun LauncherScreen(onOpenGearslip: () -> Unit) {
|
|
||||||
val status by SessionStatus.state.collectAsStateWithLifecycle()
|
|
||||||
// Plugged into a car, this device is the phone and the car UI belongs on the car's screen.
|
|
||||||
val inCar = status.phase == SessionStatus.Phase.CONNECTING || status.phase == SessionStatus.Phase.PROJECTING
|
|
||||||
if (inCar) {
|
|
||||||
InCarNotice(onOpenGearslip)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
BoxWithConstraints(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
|
||||||
val px = with(LocalDensity.current) { maxWidth.roundToPx() / 2 * 2 to maxHeight.roundToPx() / 2 * 2 }
|
|
||||||
val (width, height) = px
|
|
||||||
if (width <= 0 || height <= 0) return@BoxWithConstraints
|
|
||||||
// Car-like text and buttons: 160dpi for every 480 pixels of height, as the car preview uses.
|
|
||||||
// This screen stands in for the car's, so hosted apps size to it and the density setting applies.
|
|
||||||
val density = remember(width, height) {
|
|
||||||
CarEnvironment.setDisplay(width, height, (160 * height / 480).coerceIn(120, 640))
|
|
||||||
CarEnvironment.frame.value.densityDpi
|
|
||||||
}
|
|
||||||
// Before the projector starts, so the car UI's first screen already knows what to hide.
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
LauncherMode.setActive(true)
|
|
||||||
onDispose { LauncherMode.setActive(false) }
|
|
||||||
}
|
|
||||||
val projector = rememberCarProjector(width, height, density, "gearslip-launcher") { CarUi() }
|
|
||||||
DisposableEffect(projector) {
|
|
||||||
CarKeys.focusSink = { code, down -> projector.dispatchKey(code, down) }
|
|
||||||
onDispose { CarKeys.focusSink = null }
|
|
||||||
}
|
|
||||||
CarFrame(projector, width, height, Modifier.fillMaxSize())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun InCarNotice(onOpenGearslip: () -> Unit) {
|
|
||||||
Column(
|
|
||||||
Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).padding(32.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.launcher_in_car),
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
Button(onClick = onOpenGearslip) { Text(stringResource(R.string.launcher_open_gearslip)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,34 +0,0 @@
|
|||||||
package app.seb3thehacker.gearslip
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.graphics.Canvas
|
|
||||||
import java.io.ByteArrayOutputStream
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gearslip's own icon as the PNGs a phone hands the car in its ServiceDiscoveryRequest, at the
|
|
||||||
* 32, 64 and 128 px a real phone sends. Cars draw the phone's tile on their home screen from
|
|
||||||
* them, and older head units may wait for them before answering at all.
|
|
||||||
*/
|
|
||||||
object PhoneIcons {
|
|
||||||
|
|
||||||
private val SIZES = intArrayOf(32, 64, 128)
|
|
||||||
|
|
||||||
@Volatile private var cached: List<ByteArray>? = null
|
|
||||||
|
|
||||||
/** The three PNGs, smallest first; empty if the icon can't be drawn. */
|
|
||||||
fun pngs(context: Context): List<ByteArray> = cached ?: runCatching {
|
|
||||||
val icon = context.packageManager.getApplicationIcon(context.applicationInfo).mutate()
|
|
||||||
SIZES.map { size ->
|
|
||||||
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
|
||||||
icon.setBounds(0, 0, size, size)
|
|
||||||
icon.draw(Canvas(bitmap))
|
|
||||||
val png = ByteArrayOutputStream()
|
|
||||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, png)
|
|
||||||
bitmap.recycle()
|
|
||||||
png.toByteArray()
|
|
||||||
}
|
|
||||||
}.onFailure { GearslipLog.w("phone icons: couldn't draw them (${it.message})") }
|
|
||||||
.getOrDefault(emptyList())
|
|
||||||
.also { if (it.isNotEmpty()) cached = it }
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
package app.seb3thehacker.gearslip
|
package app.seb3thehacker.gearslip
|
||||||
|
|
||||||
import java.io.ByteArrayOutputStream
|
|
||||||
import java.net.Socket
|
import java.net.Socket
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.security.KeyStore
|
import java.security.KeyStore
|
||||||
@@ -12,19 +11,31 @@ import javax.net.ssl.SSLEngine
|
|||||||
import javax.net.ssl.SSLEngineResult
|
import javax.net.ssl.SSLEngineResult
|
||||||
import javax.net.ssl.SSLEngineResult.HandshakeStatus
|
import javax.net.ssl.SSLEngineResult.HandshakeStatus
|
||||||
import javax.net.ssl.SSLPeerUnverifiedException
|
import javax.net.ssl.SSLPeerUnverifiedException
|
||||||
import javax.net.ssl.SSLException
|
|
||||||
import javax.net.ssl.X509ExtendedTrustManager
|
import javax.net.ssl.X509ExtendedTrustManager
|
||||||
|
|
||||||
/** TLS server for projection. Handshake records travel in control message 3, not a TLS socket. */
|
/**
|
||||||
|
* The phone end of the Android Auto TLS session.
|
||||||
|
*
|
||||||
|
* Two things here are the opposite of what aasdk does, because aasdk implements the head
|
||||||
|
* unit and the head unit is the TLS *client*: it calls TLS_client_method() and
|
||||||
|
* SSL_set_connect_state (src/Transport/SSLWrapper.cpp:97-140, src/Messenger/Cryptor.cpp:87),
|
||||||
|
* and SSL_set_accept_state appears nowhere in that codebase. So the phone is the TLS
|
||||||
|
* **server** and its certificate goes out as the server certificate - sent unconditionally
|
||||||
|
* on every handshake, which is precisely what we are testing.
|
||||||
|
*
|
||||||
|
* TLS here is not layered over the socket: handshake bytes travel inside
|
||||||
|
* MESSAGE_ENCAPSULATED_SSL control messages (id 3). That rules out SSLSocket and makes
|
||||||
|
* SSLEngine - a buffer-to-buffer engine, the JSSE counterpart of aasdk's memory BIO pair -
|
||||||
|
* the right tool.
|
||||||
|
*/
|
||||||
class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
||||||
private val log = GearslipLog.tagged("TLS")
|
|
||||||
|
|
||||||
private val engine: SSLEngine
|
private val engine: SSLEngine
|
||||||
private var netIn = ByteArray(0)
|
private var netIn = ByteArray(0)
|
||||||
private var appBuffer = ByteBuffer.allocate(32 * 1024)
|
private var appBuffer = ByteBuffer.allocate(32 * 1024)
|
||||||
private var netBuffer = ByteBuffer.allocate(32 * 1024)
|
private var netBuffer = ByteBuffer.allocate(32 * 1024)
|
||||||
|
|
||||||
@Volatile var handshakeComplete: Boolean = false
|
var handshakeComplete: Boolean = false
|
||||||
private set
|
private set
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -40,7 +51,6 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
|||||||
useClientMode = false
|
useClientMode = false
|
||||||
// want, not need: we ask the head unit for its certificate so the run also tells
|
// 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.
|
// us what real units present, but we never make the session depend on getting one.
|
||||||
// Homemade head units may have none.
|
|
||||||
wantClientAuth = true
|
wantClientAuth = true
|
||||||
enabledProtocols = arrayOf("TLSv1.2") // aasdk pins TLS 1.2
|
enabledProtocols = arrayOf("TLSv1.2") // aasdk pins TLS 1.2
|
||||||
}
|
}
|
||||||
@@ -77,7 +87,7 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
|||||||
if (netBuffer.hasRemaining()) {
|
if (netBuffer.hasRemaining()) {
|
||||||
outgoing += ByteArray(netBuffer.remaining()).also { netBuffer.get(it) }
|
outgoing += ByteArray(netBuffer.remaining()).also { netBuffer.get(it) }
|
||||||
}
|
}
|
||||||
if (result.status == SSLEngineResult.Status.CLOSED) throw SSLException("TLS closed during handshake")
|
if (result.status == SSLEngineResult.Status.CLOSED) break@loop
|
||||||
}
|
}
|
||||||
|
|
||||||
HandshakeStatus.NEED_UNWRAP -> {
|
HandshakeStatus.NEED_UNWRAP -> {
|
||||||
@@ -89,7 +99,7 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
|||||||
appBuffer = ByteBuffer.allocate(appBuffer.capacity() * 2)
|
appBuffer = ByteBuffer.allocate(appBuffer.capacity() * 2)
|
||||||
continue@loop
|
continue@loop
|
||||||
}
|
}
|
||||||
SSLEngineResult.Status.CLOSED -> throw SSLException("TLS closed during handshake")
|
SSLEngineResult.Status.CLOSED -> break@loop
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,7 +123,7 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
|||||||
/** Wraps a control-channel payload for an ENCRYPTED frame. */
|
/** Wraps a control-channel payload for an ENCRYPTED frame. */
|
||||||
fun encrypt(plain: ByteArray): ByteArray {
|
fun encrypt(plain: ByteArray): ByteArray {
|
||||||
val source = ByteBuffer.wrap(plain)
|
val source = ByteBuffer.wrap(plain)
|
||||||
val out = ByteArrayOutputStream()
|
val out = java.io.ByteArrayOutputStream()
|
||||||
while (source.hasRemaining()) {
|
while (source.hasRemaining()) {
|
||||||
netBuffer.clear()
|
netBuffer.clear()
|
||||||
val result = engine.wrap(source, netBuffer)
|
val result = engine.wrap(source, netBuffer)
|
||||||
@@ -121,9 +131,6 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
|||||||
netBuffer = ByteBuffer.allocate(netBuffer.capacity() * 2)
|
netBuffer = ByteBuffer.allocate(netBuffer.capacity() * 2)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (result.status != SSLEngineResult.Status.OK || result.bytesConsumed() == 0) {
|
|
||||||
throw SSLException("TLS could not encrypt application data: ${result.status}")
|
|
||||||
}
|
|
||||||
netBuffer.flip()
|
netBuffer.flip()
|
||||||
val chunk = ByteArray(netBuffer.remaining())
|
val chunk = ByteArray(netBuffer.remaining())
|
||||||
netBuffer.get(chunk)
|
netBuffer.get(chunk)
|
||||||
@@ -132,10 +139,14 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
|||||||
return out.toByteArray()
|
return out.toByteArray()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Each encrypted projection frame contains complete TLS records. */
|
/**
|
||||||
|
* Unwraps one ENCRYPTED frame's payload. aasdk decrypts per frame
|
||||||
|
* (MessageOutStream::compoundFrame encrypts per frame), so each frame's payload is
|
||||||
|
* self-contained ciphertext.
|
||||||
|
*/
|
||||||
fun decrypt(cipher: ByteArray): ByteArray {
|
fun decrypt(cipher: ByteArray): ByteArray {
|
||||||
val source = ByteBuffer.wrap(cipher)
|
val source = ByteBuffer.wrap(cipher)
|
||||||
val out = ByteArrayOutputStream()
|
val out = java.io.ByteArrayOutputStream()
|
||||||
while (source.hasRemaining()) {
|
while (source.hasRemaining()) {
|
||||||
appBuffer.clear()
|
appBuffer.clear()
|
||||||
val result = engine.unwrap(source, appBuffer)
|
val result = engine.unwrap(source, appBuffer)
|
||||||
@@ -145,9 +156,10 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
SSLEngineResult.Status.BUFFER_UNDERFLOW -> {
|
SSLEngineResult.Status.BUFFER_UNDERFLOW -> {
|
||||||
throw SSLException("Incomplete TLS record in an encrypted frame")
|
GearslipLog.w("decrypt: record split across frames (${source.remaining()} bytes left over)")
|
||||||
|
break
|
||||||
}
|
}
|
||||||
SSLEngineResult.Status.CLOSED -> throw SSLException("TLS peer closed the connection")
|
SSLEngineResult.Status.CLOSED -> break
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
appBuffer.flip()
|
appBuffer.flip()
|
||||||
@@ -161,29 +173,26 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
|||||||
|
|
||||||
private fun describeSession() {
|
private fun describeSession() {
|
||||||
val session = engine.session
|
val session = engine.session
|
||||||
log.i("TLS handshake COMPLETE - protocol=${session.protocol} cipher=${session.cipherSuite}")
|
GearslipLog.i("TLS handshake COMPLETE - protocol=${session.protocol} cipher=${session.cipherSuite}")
|
||||||
SessionReport.tls(session.protocol, session.cipherSuite)
|
|
||||||
try {
|
try {
|
||||||
val peers = session.peerCertificates
|
val peers = session.peerCertificates
|
||||||
log.i("head unit presented ${peers.size} certificate(s):")
|
GearslipLog.i("head unit presented ${peers.size} certificate(s):")
|
||||||
peers.forEachIndexed { index, certificate ->
|
peers.forEachIndexed { index, certificate ->
|
||||||
if (certificate is X509Certificate) {
|
if (certificate is X509Certificate) {
|
||||||
log.i(" [$index] subject=${certificate.subjectX500Principal}")
|
GearslipLog.i(" [$index] subject=${certificate.subjectX500Principal}")
|
||||||
log.i(" issuer =${certificate.issuerX500Principal}")
|
GearslipLog.i(" issuer =${certificate.issuerX500Principal}")
|
||||||
log.i(" serial =${certificate.serialNumber} valid=${certificate.notBefore}..${certificate.notAfter}")
|
GearslipLog.i(" serial =${certificate.serialNumber} valid=${certificate.notBefore}..${certificate.notAfter}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (_: SSLPeerUnverifiedException) {
|
} catch (_: SSLPeerUnverifiedException) {
|
||||||
log.i("head unit presented NO certificate (we asked but did not require one)")
|
GearslipLog.i("head unit presented NO certificate (we asked but did not require one)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Accepts everything and reports what it saw. We want the data, not a verdict. */
|
/** Accepts everything and reports what it saw. We want the data, not a verdict. */
|
||||||
private class LoggingTrustManager : X509ExtendedTrustManager() {
|
private class LoggingTrustManager : X509ExtendedTrustManager() {
|
||||||
private val log = GearslipLog.tagged("TLS")
|
|
||||||
|
|
||||||
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {
|
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {
|
||||||
log.i("peer certificate offered (authType=$authType, chain=${chain?.size ?: 0}) - accepting")
|
GearslipLog.i("peer certificate offered (authType=$authType, chain=${chain?.size ?: 0}) - accepting")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?, socket: Socket?) =
|
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?, socket: Socket?) =
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ object ProjectedContent {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Precedence: the URL saved in Settings, then a `url.txt` in the external files dir (the
|
* 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). Blank opens the
|
* pre-Settings way, still honoured so existing setups keep working), then the built-in
|
||||||
* browser's own start page.
|
* test page.
|
||||||
*/
|
*/
|
||||||
fun resolve(context: Context): String {
|
fun resolve(context: Context): String {
|
||||||
val saved = AppSettings.startupUrl(context)
|
val saved = AppSettings.startupUrl(context)
|
||||||
@@ -26,7 +26,7 @@ object ProjectedContent {
|
|||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ""
|
return TEST_PAGE
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A bare "example.com" is a URL the user meant; raw HTML is passed through untouched. */
|
/** A bare "example.com" is a URL the user meant; raw HTML is passed through untouched. */
|
||||||
|
|||||||
@@ -17,20 +17,9 @@ interface Projection {
|
|||||||
/**
|
/**
|
||||||
* A touch from the head unit, already scaled into projected-video coordinates.
|
* A touch from the head unit, already scaled into projected-video coordinates.
|
||||||
* [action] is an Android MotionEvent action - Android Auto's PointerAction enum uses
|
* [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
|
* identical values (DOWN=0, UP=1, MOVED=2, POINTER_DOWN=5, POINTER_UP=6).
|
||||||
* 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 onTouch(action: Int, x: Float, y: Float)
|
||||||
|
|
||||||
fun onProjectionStopped()
|
fun onProjectionStopped()
|
||||||
|
|
||||||
/**
|
|
||||||
* The screen has been still for a moment and the encoder has sent nothing since. Draw it once
|
|
||||||
* more, so the car gets a fresh frame (see [VideoSource]'s onStill).
|
|
||||||
*/
|
|
||||||
fun onStill() {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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)
|
|
||||||
@@ -2,7 +2,14 @@ package app.seb3thehacker.gearslip
|
|||||||
|
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
|
|
||||||
/** Protobuf field encoding and diagnostic rendering for the projection protocol. */
|
/**
|
||||||
|
* Just enough protobuf to run the spike, hand-rolled so there is no protoc step.
|
||||||
|
*
|
||||||
|
* Only three messages are ever written (AuthResponse is only read):
|
||||||
|
* ServiceDiscoveryRequest { optional string label_text = 4; optional string device_name = 5; }
|
||||||
|
* PingResponse { required int64 timestamp = 1; }
|
||||||
|
* AuthResponse { required int32 status = 1; }
|
||||||
|
*/
|
||||||
object Protobuf {
|
object Protobuf {
|
||||||
|
|
||||||
fun varint(value: Long): ByteArray {
|
fun varint(value: Long): ByteArray {
|
||||||
@@ -24,17 +31,33 @@ object Protobuf {
|
|||||||
return varint(((field shl 3) or 2).toLong()) + varint(bytes.size.toLong()) + bytes
|
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 =
|
fun varintField(field: Int, value: Long): ByteArray =
|
||||||
varint((field shl 3).toLong()) + varint(value)
|
varint((field shl 3).toLong()) + varint(value)
|
||||||
|
|
||||||
/** Reads the single int32 in AuthResponse. Negative values are 10-byte varints. */
|
/** Reads the single int32 in AuthResponse. Negative values are 10-byte varints. */
|
||||||
fun readInt32Field(data: ByteArray, field: Int): Int? = readInt64Field(data, field)?.toInt()
|
fun readInt32Field(data: ByteArray, field: Int): Int? {
|
||||||
|
var pos = 0
|
||||||
/** Reads a varint field whole, for int64s such as a ping's timestamp. */
|
while (pos < data.size) {
|
||||||
fun readInt64Field(data: ByteArray, field: Int): Long? = Wire.varint(Wire.fields(data), field)
|
val (tag, afterTag) = readVarint(data, pos) ?: return null
|
||||||
|
pos = afterTag
|
||||||
|
val number = (tag ushr 3).toInt()
|
||||||
|
when ((tag and 7L).toInt()) {
|
||||||
|
0 -> {
|
||||||
|
val (value, afterValue) = readVarint(data, pos) ?: return null
|
||||||
|
pos = afterValue
|
||||||
|
if (number == field) return value.toInt()
|
||||||
|
}
|
||||||
|
2 -> {
|
||||||
|
val (len, afterLen) = readVarint(data, pos) ?: return null
|
||||||
|
pos = afterLen + len.toInt()
|
||||||
|
}
|
||||||
|
5 -> pos += 4
|
||||||
|
1 -> pos += 8
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shallow field dump. Used on ServiceDiscoveryResponse: its make/model/year strings
|
* Shallow field dump. Used on ServiceDiscoveryResponse: its make/model/year strings
|
||||||
@@ -42,7 +65,6 @@ object Protobuf {
|
|||||||
* proof that the session is live and we are decrypting correctly.
|
* proof that the session is live and we are decrypting correctly.
|
||||||
*/
|
*/
|
||||||
fun describe(data: ByteArray, indent: String = " "): String {
|
fun describe(data: ByteArray, indent: String = " "): String {
|
||||||
if (indent.length > 32) return "$indent(nested message)\n"
|
|
||||||
val sb = StringBuilder()
|
val sb = StringBuilder()
|
||||||
var pos = 0
|
var pos = 0
|
||||||
while (pos < data.size) {
|
while (pos < data.size) {
|
||||||
@@ -61,7 +83,6 @@ object Protobuf {
|
|||||||
}
|
}
|
||||||
2 -> {
|
2 -> {
|
||||||
val (len, afterLen) = readVarint(data, pos) ?: break
|
val (len, afterLen) = readVarint(data, pos) ?: break
|
||||||
if (len < 0 || len > data.size - afterLen) break
|
|
||||||
val end = afterLen + len.toInt()
|
val end = afterLen + len.toInt()
|
||||||
if (end > data.size) break
|
if (end > data.size) break
|
||||||
val body = data.copyOfRange(afterLen, end)
|
val body = data.copyOfRange(afterLen, end)
|
||||||
@@ -90,7 +111,6 @@ object Protobuf {
|
|||||||
var pos = start
|
var pos = start
|
||||||
while (pos < data.size && shift <= 63) {
|
while (pos < data.size && shift <= 63) {
|
||||||
val b = data[pos].toInt() and 0xFF
|
val b = data[pos].toInt() and 0xFF
|
||||||
if (shift == 63 && b > 1) return null
|
|
||||||
result = result or ((b and 0x7F).toLong() shl shift)
|
result = result or ((b and 0x7F).toLong() shl shift)
|
||||||
pos++
|
pos++
|
||||||
if (b and 0x80 == 0) return result to pos
|
if (b and 0x80 == 0) return result to pos
|
||||||
@@ -109,68 +129,45 @@ object Wire {
|
|||||||
val out = mutableListOf<Field>()
|
val out = mutableListOf<Field>()
|
||||||
var pos = 0
|
var pos = 0
|
||||||
while (pos < data.size) {
|
while (pos < data.size) {
|
||||||
val tag = readVarint(data, pos) ?: return emptyList()
|
val tag = readVarint(data, pos) ?: break
|
||||||
pos = tag.second
|
pos = tag.second
|
||||||
val number = (tag.first ushr 3).toInt()
|
val number = (tag.first ushr 3).toInt()
|
||||||
if (tag.first ushr 3 !in 1..0x1fff_ffffL) return emptyList()
|
|
||||||
when ((tag.first and 7L).toInt()) {
|
when ((tag.first and 7L).toInt()) {
|
||||||
0 -> {
|
0 -> {
|
||||||
val v = readVarint(data, pos) ?: return emptyList()
|
val v = readVarint(data, pos) ?: break
|
||||||
pos = v.second
|
pos = v.second
|
||||||
out += Field(number, 0, v.first, null)
|
out += Field(number, 0, v.first, null)
|
||||||
}
|
}
|
||||||
1 -> { if (data.size - pos < 8) return emptyList(); pos += 8 }
|
1 -> { if (pos + 8 > data.size) break; pos += 8 }
|
||||||
2 -> {
|
2 -> {
|
||||||
val len = readVarint(data, pos) ?: return emptyList()
|
val len = readVarint(data, pos) ?: break
|
||||||
if (len.first < 0 || len.first > data.size - len.second) return emptyList()
|
|
||||||
val end = len.second + len.first.toInt()
|
val end = len.second + len.first.toInt()
|
||||||
if (end > data.size || end < len.second) return emptyList()
|
if (end > data.size || end < len.second) break
|
||||||
out += Field(number, 2, 0, data.copyOfRange(len.second, end))
|
out += Field(number, 2, 0, data.copyOfRange(len.second, end))
|
||||||
pos = end
|
pos = end
|
||||||
}
|
}
|
||||||
5 -> { if (data.size - pos < 4) return emptyList(); pos += 4 }
|
5 -> { if (pos + 4 > data.size) break; pos += 4 }
|
||||||
else -> return emptyList()
|
else -> break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
fun varint(fields: List<Field>, number: Int): Long? =
|
fun varint(fields: List<Field>, number: Int): Long? =
|
||||||
fields.lastOrNull { it.number == number && it.wireType == 0 }?.varint
|
fields.firstOrNull { it.number == number && it.wireType == 0 }?.varint
|
||||||
|
|
||||||
fun bytes(fields: List<Field>, number: Int): ByteArray? =
|
fun bytes(fields: List<Field>, number: Int): ByteArray? =
|
||||||
fields.lastOrNull { it.number == number && it.wireType == 2 }?.bytes
|
fields.firstOrNull { it.number == number && it.wireType == 2 }?.bytes
|
||||||
|
|
||||||
fun allBytes(fields: List<Field>, number: Int): List<ByteArray> =
|
fun allBytes(fields: List<Field>, number: Int): List<ByteArray> =
|
||||||
fields.filter { it.number == number && it.wireType == 2 }.mapNotNull { it.bytes }
|
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>? {
|
private fun readVarint(data: ByteArray, start: Int): Pair<Long, Int>? {
|
||||||
var result = 0L
|
var result = 0L
|
||||||
var shift = 0
|
var shift = 0
|
||||||
var pos = start
|
var pos = start
|
||||||
while (pos < data.size && shift <= 63) {
|
while (pos < data.size && shift <= 63) {
|
||||||
val b = data[pos].toInt() and 0xFF
|
val b = data[pos].toInt() and 0xFF
|
||||||
if (shift == 63 && b > 1) return null
|
|
||||||
result = result or ((b and 0x7F).toLong() shl shift)
|
result = result or ((b and 0x7F).toLong() shl shift)
|
||||||
pos++
|
pos++
|
||||||
if (b and 0x80 == 0) return result to pos
|
if (b and 0x80 == 0) return result to pos
|
||||||
|
|||||||
@@ -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,18 +1,15 @@
|
|||||||
package app.seb3thehacker.gearslip
|
package app.seb3thehacker.gearslip
|
||||||
|
|
||||||
import android.app.ActivityOptions
|
import android.app.ActivityOptions
|
||||||
import android.app.LocaleManager
|
|
||||||
import android.app.Presentation
|
import android.app.Presentation
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.res.Configuration
|
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.hardware.display.DisplayManager
|
import android.hardware.display.DisplayManager
|
||||||
import android.hardware.display.VirtualDisplay
|
import android.hardware.display.VirtualDisplay
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
import android.view.ContextThemeWrapper
|
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -48,19 +45,7 @@ class ScreenProjector(private val context: Context) {
|
|||||||
/** Tracks the synthetic gesture so injected MotionEvents have a coherent downTime. */
|
/** Tracks the synthetic gesture so injected MotionEvents have a coherent downTime. */
|
||||||
private var downTime = 0L
|
private var downTime = 0L
|
||||||
|
|
||||||
/**
|
fun start(surface: Surface, width: Int, height: Int, densityDpi: Int, content: @Composable () -> Unit) {
|
||||||
* [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()
|
stop()
|
||||||
|
|
||||||
val displayManager = context.getSystemService(DisplayManager::class.java)
|
val displayManager = context.getSystemService(DisplayManager::class.java)
|
||||||
@@ -72,7 +57,7 @@ class ScreenProjector(private val context: Context) {
|
|||||||
// driver on every connect.
|
// driver on every connect.
|
||||||
val display = try {
|
val display = try {
|
||||||
displayManager.createVirtualDisplay(
|
displayManager.createVirtualDisplay(
|
||||||
name, width, height, densityDpi, surface,
|
"gearslip-projection", width, height, densityDpi, surface,
|
||||||
DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION or
|
DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION or
|
||||||
DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY,
|
DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY,
|
||||||
)
|
)
|
||||||
@@ -99,7 +84,7 @@ class ScreenProjector(private val context: Context) {
|
|||||||
decor.setViewTreeViewModelStoreOwner(treeOwner)
|
decor.setViewTreeViewModelStoreOwner(treeOwner)
|
||||||
decor.setBackgroundColor(Color.BLACK)
|
decor.setBackgroundColor(Color.BLACK)
|
||||||
|
|
||||||
show.setContentView(ComposeView(withAppLanguage(show.context)).apply { setContent(content) })
|
show.setContentView(ComposeView(show.context).apply { setContent(content) })
|
||||||
show.show()
|
show.show()
|
||||||
|
|
||||||
presentation = show
|
presentation = show
|
||||||
@@ -109,18 +94,6 @@ class ScreenProjector(private val context: Context) {
|
|||||||
GearslipLog.i("projecting ${width}x$height @ ${densityDpi}dpi")
|
GearslipLog.i("projecting ${width}x$height @ ${densityDpi}dpi")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* [context] in the language the driver picked for Gearslip, when it differs from the phone's
|
|
||||||
* (issue #80). Android builds a virtual display's context from the system locale and leaves
|
|
||||||
* the per-app one out, so the car screen stayed in the phone's language.
|
|
||||||
*/
|
|
||||||
private fun withAppLanguage(context: Context): Context {
|
|
||||||
val locales = this.context.getSystemService(LocaleManager::class.java)?.applicationLocales
|
|
||||||
if (locales == null || locales.isEmpty) return context
|
|
||||||
val config = Configuration(context.resources.configuration).apply { setLocales(locales) }
|
|
||||||
return ContextThemeWrapper(context, context.theme).apply { applyOverrideConfiguration(config) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The projected display's id, for placing other apps' activities on it. */
|
/** The projected display's id, for placing other apps' activities on it. */
|
||||||
val displayId: Int? get() = virtualDisplay?.display?.displayId
|
val displayId: Int? get() = virtualDisplay?.display?.displayId
|
||||||
|
|
||||||
@@ -163,41 +136,13 @@ class ScreenProjector(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** [action] is already an Android MotionEvent action; x/y are in projected coordinates. */
|
||||||
* [action] is already an Android MotionEvent action; [points] are in projected coordinates and
|
fun dispatchTouch(action: Int, x: Float, y: Float) {
|
||||||
* 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
|
val target = root ?: return
|
||||||
if (points.isEmpty()) return
|
|
||||||
val now = SystemClock.uptimeMillis()
|
val now = SystemClock.uptimeMillis()
|
||||||
if (action == MotionEvent.ACTION_DOWN || downTime == 0L) downTime = now
|
if (action == MotionEvent.ACTION_DOWN || downTime == 0L) downTime = now
|
||||||
|
|
||||||
val properties = Array(points.size) { i ->
|
val event = MotionEvent.obtain(downTime, now, action, x, y, 0)
|
||||||
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 {
|
try {
|
||||||
target.dispatchTouchEvent(event)
|
target.dispatchTouchEvent(event)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -206,47 +151,6 @@ class ScreenProjector(private val context: Context) {
|
|||||||
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) downTime = 0L
|
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.
|
|
||||||
*/
|
|
||||||
/** Draws the screen again as it is, which hands the display's consumer a new frame. */
|
|
||||||
fun redraw() {
|
|
||||||
root?.let { it.post { it.invalidate() } }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setSurface(surface: Surface?) {
|
|
||||||
virtualDisplay?.surface = surface
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
runCatching { presentation?.dismiss() }
|
runCatching { presentation?.dismiss() }
|
||||||
runCatching { owner?.destroy() }
|
runCatching { owner?.destroy() }
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ object ServiceDiscovery {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* [widthMargin]/[heightMargin] are the head unit's own numbers (VideoConfiguration fields 3
|
* [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
|
* and 4): pixels of the frame it does not treat as usable UI. Logged, and available to a
|
||||||
* frame to a screen of a different shape. A matched vehicle profile's insets override them.
|
* vehicle profile, but not applied automatically - what they mean varies by unit.
|
||||||
*/
|
*/
|
||||||
class VideoConfig(
|
class VideoConfig(
|
||||||
val resolution: Int,
|
val resolution: Int,
|
||||||
@@ -38,30 +38,11 @@ object ServiceDiscovery {
|
|||||||
|
|
||||||
fun pixelCount(): Int = pixelSize()?.let { it.first * it.second } ?: Int.MAX_VALUE
|
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() =
|
override fun toString() =
|
||||||
"$resolutionName @ $frameRateName, density=$density, margin=${widthMargin}x$heightMargin"
|
"$resolutionName @ $frameRateName, density=$density, margin=${widthMargin}x$heightMargin"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
class VideoService(val serviceId: Int, val codecType: Int, val configs: List<VideoConfig>) {
|
||||||
* [displayId] is MediaSinkService field 6: 0, the default, is the main screen. [displayType]
|
|
||||||
* is field 7 (0 main, 1 instrument cluster, 2 auxiliary), or null when the car leaves it out.
|
|
||||||
*/
|
|
||||||
class VideoService(
|
|
||||||
val serviceId: Int,
|
|
||||||
val codecType: Int,
|
|
||||||
val configs: List<VideoConfig>,
|
|
||||||
val displayId: Int = 0,
|
|
||||||
val displayType: Int? = null,
|
|
||||||
) {
|
|
||||||
val codecName: String get() = CODECS[codecType] ?: "unknown($codecType)"
|
val codecName: String get() = CODECS[codecType] ?: "unknown($codecType)"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +66,7 @@ object ServiceDiscovery {
|
|||||||
fun findAudioServices(response: ByteArray): List<AudioService> = buildList {
|
fun findAudioServices(response: ByteArray): List<AudioService> = buildList {
|
||||||
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
|
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
|
||||||
val service = Wire.fields(channel)
|
val service = Wire.fields(channel)
|
||||||
val id = serviceId(service) ?: continue
|
val id = Wire.varint(service, 1)?.toInt() ?: continue
|
||||||
val sink = Wire.bytes(service, 3) ?: continue
|
val sink = Wire.bytes(service, 3) ?: continue
|
||||||
val sinkFields = Wire.fields(sink)
|
val sinkFields = Wire.fields(sink)
|
||||||
val audioConfigs = Wire.allBytes(sinkFields, 3)
|
val audioConfigs = Wire.allBytes(sinkFields, 3)
|
||||||
@@ -108,124 +89,6 @@ object ServiceDiscovery {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The channel the car takes call status on (PhoneStatusService, Service field 10), for its
|
|
||||||
* cluster and phone screens. It carries no settings. Null when the car has none.
|
|
||||||
*/
|
|
||||||
fun findPhoneStatusChannel(response: ByteArray): Int? = findChannelWith(response, 10)
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The channel of the car's Wi-Fi projection service (Service field 14), or null. */
|
|
||||||
fun findWifiProjectionService(response: ByteArray): Int? =
|
|
||||||
Wire.allBytes(Wire.fields(response), 1).firstNotNullOfOrNull { channel ->
|
|
||||||
val service = Wire.fields(channel)
|
|
||||||
if (Wire.bytes(service, 14) == null) null else Wire.varint(service, 1)?.toInt()
|
|
||||||
}
|
|
||||||
|
|
||||||
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 STREAMS = mapOf(0 to "NONE", 1 to "GUIDANCE", 2 to "SYSTEM_AUDIO", 3 to "MEDIA", 4 to "TELEPHONY")
|
||||||
|
|
||||||
private val RESOLUTIONS = mapOf(
|
private val RESOLUTIONS = mapOf(
|
||||||
@@ -243,79 +106,33 @@ object ServiceDiscovery {
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The head unit's touchscreen and button set, from InputSourceService (Service field 4):
|
* The head unit's touchscreen, from InputSourceService (Service field 4):
|
||||||
* InputSourceService { repeated int32 keycodes_supported = 1 [packed=true];
|
* InputSourceService { keycodes = 1; repeated TouchScreen touchscreen = 2; ... }
|
||||||
* repeated TouchScreen touchscreen = 2; ... }
|
|
||||||
* TouchScreen { required int32 width = 1; required int32 height = 2; type = 3; }
|
* TouchScreen { required int32 width = 1; required int32 height = 2; type = 3; }
|
||||||
*
|
*
|
||||||
* Touch reports use projected video coordinates; these dimensions describe the input
|
* Touch coordinates arrive in this space (1258x708 on the 2018 Uconnect) and must be
|
||||||
* hardware and help select a video configuration. [keycodesSupported] is what the
|
* scaled to the projected video size before injection.
|
||||||
* 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(
|
class InputService(val serviceId: Int, val touchWidth: Int, val touchHeight: Int)
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
fun findInputService(response: ByteArray): InputService? {
|
||||||
* 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)) {
|
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
|
||||||
val service = Wire.fields(channel)
|
val service = Wire.fields(channel)
|
||||||
val id = serviceId(service) ?: continue
|
val id = Wire.varint(service, 1)?.toInt() ?: continue
|
||||||
val input = Wire.bytes(service, 4) ?: continue
|
val input = Wire.bytes(service, 4) ?: continue
|
||||||
val inputFields = Wire.fields(input)
|
val touch = Wire.bytes(Wire.fields(input), 2) ?: continue
|
||||||
// A unit with only a knob and buttons has no touchscreen entry; it still has keys.
|
val touchFields = Wire.fields(touch)
|
||||||
val touchFields = Wire.bytes(inputFields, 2)?.let { Wire.fields(it) }.orEmpty()
|
val width = Wire.varint(touchFields, 1)?.toInt() ?: continue
|
||||||
val width = Wire.varint(touchFields, 1)?.toInt() ?: 0
|
val height = Wire.varint(touchFields, 2)?.toInt() ?: continue
|
||||||
val height = Wire.varint(touchFields, 2)?.toInt() ?: 0
|
return InputService(id, width, height)
|
||||||
val keycodes = Wire.repeatedInts(inputFields, 1)
|
|
||||||
add(InputService(id, width, height, keycodes, Wire.varint(inputFields, 5)?.toInt() ?: 0))
|
|
||||||
}
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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? {
|
fun findVideoService(response: ByteArray): VideoService? {
|
||||||
val sinks = videoServices(response)
|
|
||||||
return sinks.firstOrNull { it.displayId == 0 } ?: sinks.firstOrNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The instrument cluster's video sink: one marked as a cluster, or, from a car that marks
|
|
||||||
* none, a second sink beside the main screen's. Null when the car has no cluster screen.
|
|
||||||
*/
|
|
||||||
fun findClusterVideoService(response: ByteArray): VideoService? {
|
|
||||||
val sinks = videoServices(response)
|
|
||||||
val main = findVideoService(response) ?: return null
|
|
||||||
return sinks.firstOrNull { it.displayType == DISPLAY_TYPE_CLUSTER && it.serviceId != main.serviceId }
|
|
||||||
?: sinks.firstOrNull { it.displayType == null && it.serviceId != main.serviceId && it.displayId != main.displayId }
|
|
||||||
}
|
|
||||||
|
|
||||||
private const val DISPLAY_TYPE_CLUSTER = 1
|
|
||||||
|
|
||||||
private fun videoServices(response: ByteArray): List<VideoService> = buildList {
|
|
||||||
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
|
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
|
||||||
val service = Wire.fields(channel)
|
val service = Wire.fields(channel)
|
||||||
val id = serviceId(service) ?: continue
|
val id = Wire.varint(service, 1)?.toInt() ?: continue
|
||||||
val sink = Wire.bytes(service, 3) ?: continue
|
val sink = Wire.bytes(service, 3) ?: continue
|
||||||
|
|
||||||
val sinkFields = Wire.fields(sink)
|
val sinkFields = Wire.fields(sink)
|
||||||
@@ -333,30 +150,7 @@ object ServiceDiscovery {
|
|||||||
heightMargin = Wire.varint(f, 4)?.toInt() ?: 0,
|
heightMargin = Wire.varint(f, 4)?.toInt() ?: 0,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
add(VideoService(id, codecType, configs, Wire.varint(sinkFields, 6)?.toInt() ?: 0, Wire.varint(sinkFields, 7)?.toInt()))
|
return VideoService(id, codecType, configs)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,245 +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"),
|
|
||||||
WIFI("Wi-Fi 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.WIFI, 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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,16 +14,13 @@ object SessionStatus {
|
|||||||
|
|
||||||
enum class Phase { IDLE, CONNECTING, PROJECTING, FAILED, DISCONNECTED }
|
enum class Phase { IDLE, CONNECTING, PROJECTING, FAILED, DISCONNECTED }
|
||||||
|
|
||||||
/** [stuckOn] is set when connecting has gone on too long: the cable or Wi-Fi, for what to try. */
|
data class Snapshot(val phase: Phase, val headline: String, val detail: String)
|
||||||
data class Snapshot(val phase: Phase, val headline: String, val detail: String, val stuckOn: Link? = null)
|
|
||||||
|
|
||||||
enum class Link { CABLE, WIRELESS }
|
|
||||||
|
|
||||||
private val flow = MutableStateFlow(
|
private val flow = MutableStateFlow(
|
||||||
Snapshot(
|
Snapshot(
|
||||||
Phase.IDLE,
|
Phase.IDLE,
|
||||||
"Not connected",
|
"Not connected",
|
||||||
"Plug into the car's USB port, or connect wirelessly. Gearslip opens by itself when the car answers.",
|
"Plug the phone into the car's USB port. Gearslip opens by itself when the head unit connects.",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,12 +30,6 @@ object SessionStatus {
|
|||||||
flow.value = Snapshot(Phase.CONNECTING, "Connecting", stage)
|
flow.value = Snapshot(Phase.CONNECTING, "Connecting", stage)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Still connecting after [GearslipRunner]'s patience ran out: Home says what to try. */
|
|
||||||
fun stillConnecting(link: Link) {
|
|
||||||
val now = flow.value
|
|
||||||
if (now.phase == Phase.CONNECTING) flow.value = now.copy(stuckOn = link)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun projecting() {
|
fun projecting() {
|
||||||
flow.value = Snapshot(Phase.PROJECTING, "Projecting", "Streaming to the head unit.")
|
flow.value = Snapshot(Phase.PROJECTING, "Projecting", "Streaming to the head unit.")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import java.net.Socket
|
|||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
import java.security.cert.X509Certificate
|
import java.security.cert.X509Certificate
|
||||||
import javax.net.ssl.KeyManagerFactory
|
|
||||||
import javax.net.ssl.SSLContext
|
import javax.net.ssl.SSLContext
|
||||||
import javax.net.ssl.SSLEngine
|
import javax.net.ssl.SSLEngine
|
||||||
import javax.net.ssl.SSLEngineResult
|
import javax.net.ssl.SSLEngineResult
|
||||||
@@ -12,20 +11,25 @@ import javax.net.ssl.SSLEngineResult.HandshakeStatus
|
|||||||
import javax.net.ssl.X509ExtendedTrustManager
|
import javax.net.ssl.X509ExtendedTrustManager
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks mutual TLS and an encrypted data round trip with an in-process head-unit peer. Test code
|
* Runs [PhoneTls] against an in-process TLS client and checks that a full handshake plus a
|
||||||
* only: the peer trusts any certificate, which is the point here and must never ship in the app.
|
* data round trip works.
|
||||||
|
*
|
||||||
|
* This exists because of one specific hazard: a bug in the certificate, the key manager or
|
||||||
|
* the engine wiring produces a dead connection in the car that is indistinguishable from a
|
||||||
|
* head unit rejecting us - and the whole point of the spike is a trustworthy go/no-go
|
||||||
|
* signal. Run this on the bench first; if it passes, a failure in the car is about the head
|
||||||
|
* unit, not about this code.
|
||||||
*/
|
*/
|
||||||
object TlsSelfTest {
|
object TlsSelfTest {
|
||||||
private val log = GearslipLog.tagged("TLS")
|
|
||||||
|
|
||||||
fun run(identity: CertProvider.Identity, withClientCertificate: Boolean = true): Boolean {
|
fun run(identity: CertProvider.Identity): Boolean {
|
||||||
log.i("=== TLS self-test starting ===")
|
GearslipLog.i("=== TLS self-test starting ===")
|
||||||
log.i("cert source: ${identity.source}")
|
GearslipLog.i("cert source: ${identity.source}")
|
||||||
log.i(" subject = ${identity.certificate.subjectX500Principal}")
|
GearslipLog.i(" subject = ${identity.certificate.subjectX500Principal}")
|
||||||
log.i(" issuer = ${identity.certificate.issuerX500Principal}")
|
GearslipLog.i(" issuer = ${identity.certificate.issuerX500Principal}")
|
||||||
return try {
|
return try {
|
||||||
val server = PhoneTls(identity.keyStore, identity.password)
|
val server = PhoneTls(identity.keyStore, identity.password)
|
||||||
val client = Driver(clientEngine(withClientCertificate))
|
val client = Driver(clientEngine())
|
||||||
|
|
||||||
var outbound = client.pump(null) // ClientHello
|
var outbound = client.pump(null) // ClientHello
|
||||||
var rounds = 0
|
var rounds = 0
|
||||||
@@ -40,35 +44,30 @@ object TlsSelfTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!server.handshakeComplete) {
|
if (!server.handshakeComplete) {
|
||||||
log.e("self-test FAILED: handshake did not complete after $rounds rounds")
|
GearslipLog.e("self-test FAILED: handshake did not complete after $rounds rounds")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exercise the same encrypt path used by ServiceDiscoveryRequest.
|
// Data round trip, exercising the same encrypt path the spike uses for
|
||||||
|
// ServiceDiscoveryRequest.
|
||||||
val plaintext = "gearslip round trip".toByteArray()
|
val plaintext = "gearslip round trip".toByteArray()
|
||||||
val decrypted = client.decrypt(server.encrypt(plaintext))
|
val decrypted = client.decrypt(server.encrypt(plaintext))
|
||||||
if (!decrypted.contentEquals(plaintext)) {
|
if (!decrypted.contentEquals(plaintext)) {
|
||||||
log.e("self-test FAILED: round trip mismatch (${decrypted.size} bytes back)")
|
GearslipLog.e("self-test FAILED: round trip mismatch (${decrypted.size} bytes back)")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
log.i("=== TLS self-test PASSED - certificate, key manager and engine are sound ===")
|
GearslipLog.i("=== TLS self-test PASSED - certificate, key manager and engine are sound ===")
|
||||||
true
|
true
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
log.e("self-test FAILED with an exception", t)
|
GearslipLog.e("self-test FAILED with an exception", t)
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun clientEngine(withCertificate: Boolean): SSLEngine {
|
private fun clientEngine(): SSLEngine {
|
||||||
val context = SSLContext.getInstance("TLSv1.2")
|
val context = SSLContext.getInstance("TLSv1.2")
|
||||||
val keyManagers = if (withCertificate) {
|
context.init(null, arrayOf(TrustAll()), SecureRandom())
|
||||||
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 {
|
return context.createSSLEngine().apply {
|
||||||
useClientMode = true
|
useClientMode = true
|
||||||
enabledProtocols = arrayOf("TLSv1.2")
|
enabledProtocols = arrayOf("TLSv1.2")
|
||||||
@@ -23,18 +23,9 @@ class VideoSource(
|
|||||||
private val height: Int,
|
private val height: Int,
|
||||||
private val frameRate: Int,
|
private val frameRate: Int,
|
||||||
private val mode: Mode = Mode.TEST_CARD,
|
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 onCodecConfig: (ByteArray) -> Unit,
|
||||||
private val onFrame: (data: ByteArray, presentationTimeUs: Long, keyFrame: Boolean) -> Unit,
|
private val onFrame: (data: ByteArray, presentationTimeUs: Long, keyFrame: Boolean) -> Unit,
|
||||||
/**
|
|
||||||
* SURFACE mode, nothing encoded for [STILL_MS]. The repeat setting below should cover a still
|
|
||||||
* screen, but on a Pixel 10 it didn't: a LuChi box got 11 frames, then none, and stayed black
|
|
||||||
* (issue #58). Whoever draws the screen can draw it again, and that is a frame.
|
|
||||||
*/
|
|
||||||
private val onStill: () -> Unit = {},
|
|
||||||
) {
|
) {
|
||||||
private val log = GearslipLog.tagged("VIDEO")
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TEST_CARD feeds generated YUV buffers; SURFACE exposes a MediaCodec input Surface for
|
* TEST_CARD feeds generated YUV buffers; SURFACE exposes a MediaCodec input Surface for
|
||||||
@@ -48,26 +39,12 @@ class VideoSource(
|
|||||||
private set
|
private set
|
||||||
|
|
||||||
private var codec: MediaCodec? = null
|
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
|
private var worker: Thread? = null
|
||||||
@Volatile private var running = false
|
@Volatile private var running = false
|
||||||
private var frameIndex = 0L
|
private var frameIndex = 0L
|
||||||
|
|
||||||
fun start() {
|
fun start() {
|
||||||
val level = levelFor(width, height, frameRate)
|
val format = MediaFormat.createVideoFormat(MIME, width, height).apply {
|
||||||
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(
|
setInteger(
|
||||||
MediaFormat.KEY_COLOR_FORMAT,
|
MediaFormat.KEY_COLOR_FORMAT,
|
||||||
if (mode == Mode.SURFACE) {
|
if (mode == Mode.SURFACE) {
|
||||||
@@ -76,54 +53,25 @@ class VideoSource(
|
|||||||
MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible
|
MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
setInteger(MediaFormat.KEY_BIT_RATE, bitRate)
|
setInteger(MediaFormat.KEY_BIT_RATE, 2_000_000)
|
||||||
setInteger(MediaFormat.KEY_FRAME_RATE, frameRate)
|
setInteger(MediaFormat.KEY_FRAME_RATE, frameRate)
|
||||||
// Drops already ask for a fresh keyframe, so a long interval costs nothing in recovery
|
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1)
|
||||||
// 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_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline)
|
||||||
setInteger(MediaFormat.KEY_LEVEL, level.codecLevel)
|
setInteger(MediaFormat.KEY_LEVEL, MediaCodecInfo.CodecProfileLevel.AVCLevel31)
|
||||||
if (tuned) {
|
|
||||||
// Hints only: a car screen wants each frame now, not the best frame a moment later.
|
|
||||||
setInteger(MediaFormat.KEY_BITRATE_MODE, MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR)
|
|
||||||
setInteger(MediaFormat.KEY_PRIORITY, 0)
|
|
||||||
setInteger(MediaFormat.KEY_LATENCY, 1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var encoder = MediaCodec.createEncoderByType(MIME)
|
val encoder = MediaCodec.createEncoderByType(MIME)
|
||||||
try {
|
encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||||
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()
|
if (mode == Mode.SURFACE) inputSurface = encoder.createInputSurface()
|
||||||
encoder.start()
|
encoder.start()
|
||||||
codec = encoder
|
codec = encoder
|
||||||
running = true
|
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")
|
GearslipLog.i("encoder started: ${width}x$height @ ${frameRate}fps, H.264 Baseline, mode=$mode")
|
||||||
|
|
||||||
worker = Thread {
|
worker = Thread {
|
||||||
runCatching { pump(encoder) }
|
runCatching { pump(encoder) }
|
||||||
.onFailure { if (running) log.e("encoder loop failed", it) }
|
.onFailure { if (running) GearslipLog.e("encoder loop failed", it) }
|
||||||
}.apply { name = "gearslip-encoder"; start() }
|
}.apply { name = "gearslip-encoder"; start() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,31 +89,12 @@ class VideoSource(
|
|||||||
codec?.setParameters(Bundle().apply {
|
codec?.setParameters(Bundle().apply {
|
||||||
putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0)
|
putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0)
|
||||||
})
|
})
|
||||||
}.onFailure { log.w("requestSyncFrame failed: ${it.message}") }
|
}.onFailure { GearslipLog.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() {
|
fun stop() {
|
||||||
running = false
|
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)
|
worker?.join(1_000)
|
||||||
} catch (e: InterruptedException) {
|
|
||||||
Thread.currentThread().interrupt()
|
|
||||||
}
|
|
||||||
runCatching { inputSurface?.release() }
|
runCatching { inputSurface?.release() }
|
||||||
inputSurface = null
|
inputSurface = null
|
||||||
runCatching { codec?.stop() }
|
runCatching { codec?.stop() }
|
||||||
@@ -176,8 +105,6 @@ class VideoSource(
|
|||||||
private fun pump(encoder: MediaCodec) {
|
private fun pump(encoder: MediaCodec) {
|
||||||
val info = MediaCodec.BufferInfo()
|
val info = MediaCodec.BufferInfo()
|
||||||
val frameIntervalUs = 1_000_000L / frameRate
|
val frameIntervalUs = 1_000_000L / frameRate
|
||||||
var lastOutputAt = 0L
|
|
||||||
var lastNudgeAt = 0L
|
|
||||||
|
|
||||||
while (running) {
|
while (running) {
|
||||||
// SURFACE mode has no input buffers to fill - frames arrive from the display.
|
// SURFACE mode has no input buffers to fill - frames arrive from the display.
|
||||||
@@ -195,10 +122,7 @@ class VideoSource(
|
|||||||
frameIndex++
|
frameIndex++
|
||||||
}
|
}
|
||||||
|
|
||||||
// A surface-fed encoder has nothing to do between frames, so block until one is ready
|
var outIndex = encoder.dequeueOutputBuffer(info, TIMEOUT_US)
|
||||||
// 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) {
|
while (outIndex >= 0) {
|
||||||
val buffer = encoder.getOutputBuffer(outIndex)
|
val buffer = encoder.getOutputBuffer(outIndex)
|
||||||
if (buffer != null && info.size > 0) {
|
if (buffer != null && info.size > 0) {
|
||||||
@@ -216,18 +140,10 @@ class VideoSource(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
encoder.releaseOutputBuffer(outIndex, false)
|
encoder.releaseOutputBuffer(outIndex, false)
|
||||||
lastOutputAt = System.currentTimeMillis()
|
|
||||||
outIndex = encoder.dequeueOutputBuffer(info, 0)
|
outIndex = encoder.dequeueOutputBuffer(info, 0)
|
||||||
}
|
}
|
||||||
if (mode == Mode.SURFACE && lastOutputAt > 0) {
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
if (now - lastOutputAt > STILL_MS && now - lastNudgeAt > STILL_MS) {
|
|
||||||
lastNudgeAt = now
|
|
||||||
onStill()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mode != Mode.SURFACE) Thread.sleep(maxOf(1L, frameIntervalUs / 2000))
|
Thread.sleep(maxOf(1L, frameIntervalUs / 2000))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,53 +187,6 @@ class VideoSource(
|
|||||||
private companion object {
|
private companion object {
|
||||||
const val MIME = MediaFormat.MIMETYPE_VIDEO_AVC
|
const val MIME = MediaFormat.MIMETYPE_VIDEO_AVC
|
||||||
const val TIMEOUT_US = 10_000L
|
const val TIMEOUT_US = 10_000L
|
||||||
const val SURFACE_TIMEOUT_US = 100_000L
|
|
||||||
private const val STILL_MS = 1_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. */
|
/** (Y, Cb, Cr) - white, yellow, cyan, green, magenta, red, blue. */
|
||||||
val BAR_COLOURS = listOf(
|
val BAR_COLOURS = listOf(
|
||||||
|
|||||||
@@ -1,103 +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() }
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val INPUT_WAIT_US = 10_000L
|
|
||||||
private val FREQUENCIES = listOf(96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350)
|
|
||||||
|
|
||||||
/** The two-byte AudioSpecificConfig for AAC-LC: object type 2, rate index, channel count. */
|
|
||||||
fun audioSpecificConfig(sampleRate: Int, channels: Int): ByteArray {
|
|
||||||
val freq = FREQUENCIES.indexOf(sampleRate).let { if (it < 0) 3 else it }
|
|
||||||
return byteArrayOf(
|
|
||||||
((2 shl 3) or (freq shr 1)).toByte(),
|
|
||||||
(((freq and 1) shl 7) or (channels shl 3)).toByte(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,6 @@ import android.app.PendingIntent
|
|||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.content.pm.ServiceInfo
|
import android.content.pm.ServiceInfo
|
||||||
import android.media.AudioAttributes
|
import android.media.AudioAttributes
|
||||||
import android.media.AudioFormat
|
import android.media.AudioFormat
|
||||||
@@ -16,7 +15,6 @@ import android.media.AudioRecord
|
|||||||
import android.media.projection.MediaProjection
|
import android.media.projection.MediaProjection
|
||||||
import android.media.projection.MediaProjectionManager
|
import android.media.projection.MediaProjectionManager
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.os.Process
|
|
||||||
import app.seb3thehacker.gearslip.GearslipLog
|
import app.seb3thehacker.gearslip.GearslipLog
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
||||||
@@ -26,51 +24,16 @@ import kotlin.math.abs
|
|||||||
* Playback capture is the one public route to another app's sound, and it hangs off a
|
* 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
|
* 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.
|
* 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.
|
* Capture is scoped to the media app's uid: notification pings and other apps 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() {
|
class AudioCaptureService : Service() {
|
||||||
|
|
||||||
@Volatile private var reading = false
|
@Volatile private var reading = false
|
||||||
private var projection: MediaProjection? = null
|
private var projection: MediaProjection? = null
|
||||||
private var thread: Thread? = 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 onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
running = true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
if (intent?.action == ACTION_STOP) {
|
if (intent?.action == ACTION_STOP) {
|
||||||
stopSelf()
|
stopSelf()
|
||||||
@@ -85,14 +48,7 @@ class AudioCaptureService : Service() {
|
|||||||
return START_NOT_STICKY
|
return START_NOT_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
// Android silences the microphone for apps in the background unless a running service
|
startForeground(NOTIFICATION_ID, notification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION)
|
||||||
// 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 {
|
projection = try {
|
||||||
getSystemService(MediaProjectionManager::class.java).getMediaProjection(resultCode, data)
|
getSystemService(MediaProjectionManager::class.java).getMediaProjection(resultCode, data)
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
@@ -118,14 +74,13 @@ class AudioCaptureService : Service() {
|
|||||||
private fun beginCapture(token: MediaProjection) {
|
private fun beginCapture(token: MediaProjection) {
|
||||||
val rate = CarAudio.sampleRate
|
val rate = CarAudio.sampleRate
|
||||||
val channels = CarAudio.channels
|
val channels = CarAudio.channels
|
||||||
|
val uid = CarAudio.uid
|
||||||
val record = try {
|
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)
|
val config = AudioPlaybackCaptureConfiguration.Builder(token)
|
||||||
.addMatchingUsage(AudioAttributes.USAGE_MEDIA)
|
.addMatchingUsage(AudioAttributes.USAGE_MEDIA)
|
||||||
.addMatchingUsage(AudioAttributes.USAGE_GAME)
|
.addMatchingUsage(AudioAttributes.USAGE_GAME)
|
||||||
.addMatchingUsage(AudioAttributes.USAGE_UNKNOWN)
|
.addMatchingUsage(AudioAttributes.USAGE_UNKNOWN)
|
||||||
|
.apply { if (uid >= 0) addMatchingUid(uid) }
|
||||||
.build()
|
.build()
|
||||||
val mask = if (channels == 1) AudioFormat.CHANNEL_IN_MONO else AudioFormat.CHANNEL_IN_STEREO
|
val mask = if (channels == 1) AudioFormat.CHANNEL_IN_MONO else AudioFormat.CHANNEL_IN_STEREO
|
||||||
val format = AudioFormat.Builder()
|
val format = AudioFormat.Builder()
|
||||||
@@ -137,7 +92,7 @@ class AudioCaptureService : Service() {
|
|||||||
AudioRecord.Builder()
|
AudioRecord.Builder()
|
||||||
.setAudioPlaybackCaptureConfig(config)
|
.setAudioPlaybackCaptureConfig(config)
|
||||||
.setAudioFormat(format)
|
.setAudioFormat(format)
|
||||||
.setBufferSizeInBytes(maxOf(minimum, CHUNK_BYTES * BUFFER_CHUNKS))
|
.setBufferSizeInBytes(maxOf(minimum, CHUNK_BYTES * 4))
|
||||||
.build()
|
.build()
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
GearslipLog.e("audio: could not set up playback capture", t)
|
GearslipLog.e("audio: could not set up playback capture", t)
|
||||||
@@ -148,58 +103,27 @@ class AudioCaptureService : Service() {
|
|||||||
|
|
||||||
reading = true
|
reading = true
|
||||||
thread = Thread({
|
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)
|
val buffer = ByteArray(CHUNK_BYTES)
|
||||||
var peak = 0
|
var peak = 0
|
||||||
var heardAt = System.currentTimeMillis()
|
var heardAt = System.currentTimeMillis()
|
||||||
val startedAt = heardAt
|
|
||||||
var everHeard = false
|
|
||||||
var warnedSilent = false
|
|
||||||
var lastSound = startedAt
|
|
||||||
var silenceReported = false
|
|
||||||
try {
|
try {
|
||||||
record.startRecording()
|
record.startRecording()
|
||||||
GearslipLog.i("audio: capturing media playback at ${rate}Hz x$channels")
|
GearslipLog.i("audio: capturing uid=$uid at ${rate}Hz x$channels")
|
||||||
mutePhone()
|
|
||||||
CarAudio.captureStarted()
|
CarAudio.captureStarted()
|
||||||
while (reading) {
|
while (reading) {
|
||||||
val n = record.read(buffer, 0, buffer.size)
|
val n = record.read(buffer, 0, buffer.size)
|
||||||
if (n <= 0) {
|
if (n <= 0) break
|
||||||
GearslipLog.e("audio: AudioRecord.read returned $n - capture has stopped")
|
|
||||||
break
|
|
||||||
}
|
|
||||||
for (i in 0 until n - 1 step 2) {
|
for (i in 0 until n - 1 step 2) {
|
||||||
val sample = (buffer[i].toInt() and 0xFF) or (buffer[i + 1].toInt() shl 8)
|
val sample = (buffer[i].toInt() and 0xFF) or (buffer[i + 1].toInt() shl 8)
|
||||||
peak = maxOf(peak, abs(sample.toShort().toInt()))
|
peak = maxOf(peak, abs(sample.toShort().toInt()))
|
||||||
}
|
}
|
||||||
if (peak > 0) everHeard = true
|
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
// A stretch of silence: CarAudio says why on the car if a blocking app is playing.
|
|
||||||
if (peak > 0) {
|
|
||||||
lastSound = now
|
|
||||||
silenceReported = false
|
|
||||||
} else if (!silenceReported && now - lastSound >= SILENCE_MS) {
|
|
||||||
silenceReported = true
|
|
||||||
CarAudio.silent(this@AudioCaptureService)
|
|
||||||
}
|
|
||||||
val level = peak / 32768f
|
val level = peak / 32768f
|
||||||
if (now - heardAt >= LEVEL_LOG_MS) {
|
if (now - heardAt >= LEVEL_LOG_MS) {
|
||||||
GearslipLog.i("audio: peak %.3f".format(level))
|
GearslipLog.i("audio: peak %.3f".format(level))
|
||||||
heardAt = now
|
heardAt = now
|
||||||
peak = 0
|
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)
|
CarAudio.deliver(buffer, n, level)
|
||||||
}
|
}
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
@@ -207,7 +131,6 @@ class AudioCaptureService : Service() {
|
|||||||
} finally {
|
} finally {
|
||||||
runCatching { record.stop() }
|
runCatching { record.stop() }
|
||||||
record.release()
|
record.release()
|
||||||
unmutePhone()
|
|
||||||
CarAudio.captureStopped()
|
CarAudio.captureStopped()
|
||||||
}
|
}
|
||||||
}, "gearslip-audio").apply { isDaemon = true; start() }
|
}, "gearslip-audio").apply { isDaemon = true; start() }
|
||||||
@@ -217,13 +140,8 @@ class AudioCaptureService : Service() {
|
|||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
reading = false
|
reading = false
|
||||||
runCatching { thread?.join(500) }
|
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() }
|
runCatching { projection?.stop() }
|
||||||
projection = null
|
projection = null
|
||||||
ScreenShareGuard.restore(this)
|
|
||||||
running = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun notification(): Notification {
|
private fun notification(): Notification {
|
||||||
@@ -254,17 +172,10 @@ class AudioCaptureService : Service() {
|
|||||||
|
|
||||||
/** About 21 ms of 48 kHz stereo: small enough for lip-sync, big enough not to flood the link. */
|
/** About 21 ms of 48 kHz stereo: small enough for lip-sync, big enough not to flood the link. */
|
||||||
private const val CHUNK_BYTES = 4096
|
private const val 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 LEVEL_LOG_MS = 3_000L
|
||||||
private const val SILENCE_MS = 5_000L
|
|
||||||
|
|
||||||
private var appContext: Context? = null
|
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) {
|
fun start(context: Context, resultCode: Int, data: Intent) {
|
||||||
appContext = context.applicationContext
|
appContext = context.applicationContext
|
||||||
context.startForegroundService(
|
context.startForegroundService(
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import app.seb3thehacker.gearslip.GearslipLog
|
|||||||
import app.seb3thehacker.gearslip.Protobuf
|
import app.seb3thehacker.gearslip.Protobuf
|
||||||
import app.seb3thehacker.gearslip.ServiceDiscovery
|
import app.seb3thehacker.gearslip.ServiceDiscovery
|
||||||
import app.seb3thehacker.gearslip.Wire
|
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:
|
* The phone end of the head unit's media audio sink, the audio twin of the video channel:
|
||||||
@@ -24,62 +22,15 @@ class AudioLink(
|
|||||||
private val service: ServiceDiscovery.AudioService,
|
private val service: ServiceDiscovery.AudioService,
|
||||||
private val sendOnChannel: (messageId: Int, body: ByteArray) -> Unit,
|
private val sendOnChannel: (messageId: Int, body: ByteArray) -> Unit,
|
||||||
private val sendControl: (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 = {},
|
|
||||||
/** The car wants the speakers for a moment (a call, its own voice), and then [onGivenBack]. */
|
|
||||||
private val onPausedForNow: () -> Unit = {},
|
|
||||||
private val onGivenBack: () -> Unit = {},
|
|
||||||
) : CarAudio.Link {
|
) : CarAudio.Link {
|
||||||
|
|
||||||
private var configIndex = 0
|
private var configIndex = 0
|
||||||
@Volatile private var ready = false
|
@Volatile private var ready = false
|
||||||
@Volatile private var started = false
|
@Volatile private var started = false
|
||||||
@Volatile private var focusGranted = false
|
|
||||||
@Volatile private var focusRequested = false
|
|
||||||
/** The car took the speakers for a moment, and we paused the phone's music until it's done. */
|
|
||||||
@Volatile private var pausedForNow = false
|
|
||||||
@Volatile private var inFlight = 0
|
@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 = 8
|
||||||
@Volatile private var maxUnacked = 4
|
|
||||||
@Volatile private var lastAckAt = 0L
|
@Volatile private var lastAckAt = 0L
|
||||||
/**
|
|
||||||
* Bytes of PCM per Data message, or 0 to send each capture read as it comes. Raised by
|
|
||||||
* [adaptChunkSize] for a car that acks fewer chunks than it plays.
|
|
||||||
*/
|
|
||||||
@Volatile private var chunkBytes = 0
|
|
||||||
/** Capture reads gathered until they fill [chunkBytes]. Only the capture thread touches it. */
|
|
||||||
private val pending = java.io.ByteArrayOutputStream()
|
|
||||||
/** Set by a new stream, so the capture thread drops what an older one left in [pending]. */
|
|
||||||
@Volatile private var pendingStale = false
|
|
||||||
/** The size of the last chunk sent, for [adaptChunkSize] to know what each one holds. */
|
|
||||||
@Volatile private var lastPayloadBytes = 0
|
|
||||||
/** Acks that came while the window was full, and the time they took: the car's own pace. */
|
|
||||||
private var slowAcks = 0
|
|
||||||
private var slowAckMs = 0L
|
|
||||||
private var lastAckFull = false
|
|
||||||
private var stalls = 0
|
|
||||||
private var chunks = 0
|
private var chunks = 0
|
||||||
private var bytes = 0L
|
|
||||||
private var dropped = 0
|
|
||||||
@Volatile private var configTimer: Thread? = null
|
|
||||||
/** Underflows the car reported this drive, and the silence still to slip in ahead of the next chunk. */
|
|
||||||
private var underflows = 0
|
|
||||||
@Volatile private var padMs = 0
|
|
||||||
@Volatile private var pendingPadMs = 0
|
|
||||||
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()
|
private val config get() = service.configs.getOrNull(configIndex) ?: service.configs.firstOrNull()
|
||||||
override val sampleRate get() = config?.sampleRate ?: 48_000
|
override val sampleRate get() = config?.sampleRate ?: 48_000
|
||||||
@@ -87,114 +38,13 @@ class AudioLink(
|
|||||||
|
|
||||||
val channelId get() = service.serviceId
|
val channelId get() = service.serviceId
|
||||||
|
|
||||||
/** Sending audio now, which for media means the car has handed us the speakers. */
|
|
||||||
val streaming get() = started
|
|
||||||
|
|
||||||
/** The car has set the sink up, so a stream can start. */
|
|
||||||
val isReady get() = ready
|
|
||||||
|
|
||||||
@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)")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A spoken turn on the music channel while no music plays (see DirectionsVoice): the stream
|
|
||||||
* runs for the turn only, under ducking focus like the directions channel's, so the radio
|
|
||||||
* comes back after it rather than giving way to Android Auto for good.
|
|
||||||
*/
|
|
||||||
fun startForTurn() {
|
|
||||||
askForSpeakers()
|
|
||||||
startStream()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stopForTurn() {
|
|
||||||
synchronized(startLock) { stopLocked(release = false) }
|
|
||||||
handBackSpeakers()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun opened(status: Int) {
|
fun opened(status: Int) {
|
||||||
GearslipLog.i("$tag: <- ChannelOpenResponse: status=$status")
|
|
||||||
if (status != 0) {
|
if (status != 0) {
|
||||||
GearslipLog.w("$tag: the head unit refused the $tag channel (status $status)")
|
GearslipLog.w("audio: the head unit refused the media audio channel (status $status)")
|
||||||
return
|
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()))
|
sendOnChannel(MSG_SETUP, Protobuf.varintField(1, service.codecType.toLong()))
|
||||||
GearslipLog.i("$tag: -> Setup(codec=${service.codecName}) on channel ${service.serviceId}")
|
GearslipLog.i("audio: -> Setup(codec=${service.codecName})")
|
||||||
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) {
|
fun onMessage(messageId: Int, body: ByteArray) {
|
||||||
@@ -206,328 +56,77 @@ class AudioLink(
|
|||||||
val status = Wire.varint(fields, 1)?.toInt()
|
val status = Wire.varint(fields, 1)?.toInt()
|
||||||
val window = Wire.varint(fields, 2)?.toInt()
|
val window = Wire.varint(fields, 2)?.toInt()
|
||||||
val indices = fields.filter { it.number == 3 && it.wireType == 0 }.map { it.varint.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")
|
GearslipLog.i("audio: <- Config status=$status max_unacked=$window indices=$indices")
|
||||||
if (window != null) maxUnacked = maxOf(1, window)
|
if (window != null) maxUnacked = maxOf(1, window)
|
||||||
configIndex = indices.firstOrNull() ?: 0
|
configIndex = indices.firstOrNull() ?: 0
|
||||||
if (status == STATUS_READY) {
|
if (status == STATUS_READY) {
|
||||||
configTimer?.interrupt()
|
|
||||||
ready = true
|
ready = true
|
||||||
GearslipLog.i("$tag: sink ready - ${sampleRate}Hz x$channels ${config?.bits}bit")
|
GearslipLog.i("audio: ready - ${sampleRate}Hz x$channels ${config?.bits}bit")
|
||||||
onReady(this)
|
CarAudio.attach(this)
|
||||||
} else {
|
|
||||||
GearslipLog.w(
|
|
||||||
"$tag: the sink is not ready (status=$status, 1=WAIT 2=READY) - " +
|
|
||||||
"nothing will be sent until it is",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MSG_UNDERFLOW -> onUnderflow()
|
|
||||||
|
|
||||||
MSG_ACK -> {
|
MSG_ACK -> {
|
||||||
val acked = Wire.varint(Wire.fields(body), 2)?.toInt() ?: 1
|
val acked = Wire.varint(Wire.fields(body), 2)?.toInt() ?: 1
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
synchronized(ackLock) {
|
|
||||||
// A full window means the sender is waiting on this ack. When it was waiting on
|
|
||||||
// the one before too, the gap between them is the car's own pace.
|
|
||||||
val full = inFlight >= maxUnacked
|
|
||||||
if (full && lastAckFull) {
|
|
||||||
slowAcks++
|
|
||||||
slowAckMs += now - lastAckAt
|
|
||||||
}
|
|
||||||
lastAckFull = full
|
|
||||||
inFlight = maxOf(0, inFlight - maxOf(1, acked))
|
inFlight = maxOf(0, inFlight - maxOf(1, acked))
|
||||||
lastAckAt = now
|
lastAckAt = System.currentTimeMillis()
|
||||||
ackLock.notifyAll()
|
|
||||||
}
|
|
||||||
adaptChunkSize()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whatever else the head unit answers Setup with, we need its bytes to identify it.
|
else -> GearslipLog.i("audio: <- unhandled message id=$messageId (${body.size} bytes)")
|
||||||
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) {
|
fun onFocus(state: Int) {
|
||||||
val name = FOCUS_STATES[state] ?: "unknown($state)"
|
GearslipLog.i("audio: <- AudioFocus state=$state (1=GAIN, 2=GAIN_TRANSIENT, 3=LOSS, 4=LOSS_TRANSIENT)")
|
||||||
GearslipLog.i("$tag: <- AudioFocusResponse state=$name")
|
|
||||||
if (!asksFocus || expectsFocusAnswer()) {
|
|
||||||
// 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
|
|
||||||
when {
|
|
||||||
focusGranted -> {
|
|
||||||
startStream()
|
|
||||||
if (pausedForNow) {
|
|
||||||
pausedForNow = false
|
|
||||||
onGivenBack()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// A call or the car's own voice. Releasing focus here meant the car never handed it
|
|
||||||
// back, so the music stayed paused after the call: keep the claim and wait for GAIN.
|
|
||||||
state == FOCUS_STATE_LOSS_TRANSIENT -> {
|
|
||||||
synchronized(startLock) { stopLocked(release = false) }
|
|
||||||
if (had) {
|
|
||||||
pausedForNow = true
|
|
||||||
onPausedForNow()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
stop()
|
|
||||||
// A ducking request leaves the music playing; a loss means another source has the speakers.
|
|
||||||
if (state == FOCUS_STATE_LOSS && (had || pausedForNow)) {
|
|
||||||
pausedForNow = false
|
|
||||||
onTakenBack()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The car ran out of sound to play (AudioUnderflowNotification, no body): its buffer emptied
|
|
||||||
* before the next chunk arrived, which the driver hears as a gap. Capture runs in real time, so
|
|
||||||
* the car's buffer only ever holds what the window lets through. A little silence slipped in
|
|
||||||
* ahead of the next chunk puts it that far ahead, so the next slow patch is soaked up rather
|
|
||||||
* than heard. Capped, as each step adds a little delay to the music.
|
|
||||||
*/
|
|
||||||
private fun onUnderflow() {
|
|
||||||
underflows++
|
|
||||||
val step = if (started && padMs < MAX_PAD_MS) PAD_STEP_MS else 0
|
|
||||||
padMs += step
|
|
||||||
pendingPadMs += step
|
|
||||||
if (underflows <= 3 || underflows % 20 == 0) {
|
|
||||||
GearslipLog.w("$tag: <- AudioUnderflow ($underflows this drive) - running ${padMs}ms ahead now")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A 2020 Civic acks a chunk about every 83 ms whatever the chunk holds, so 21 ms chunks
|
|
||||||
* reached it at a quarter of real time: a second of music, then silence, over and over
|
|
||||||
* (issue #43). When the car keeps the sender waiting and its acks come much further apart
|
|
||||||
* than the audio in each chunk, gather the capture into chunks that last at least that gap,
|
|
||||||
* rounded up to a power of two in frames: 83 ms is about 4096 frames at 48 kHz, which looks
|
|
||||||
* like the car's own buffer. A car that keeps up never fills the window, so it never gets here.
|
|
||||||
*/
|
|
||||||
private fun adaptChunkSize() {
|
|
||||||
if (aac != null) return
|
|
||||||
val interval = synchronized(ackLock) {
|
|
||||||
if (slowAcks < ADAPT_ACKS) return
|
|
||||||
(slowAckMs.toDouble() / slowAcks).also {
|
|
||||||
slowAcks = 0
|
|
||||||
slowAckMs = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val frame = channels * 2
|
|
||||||
val bytesPerMs = sampleRate * frame / 1000.0
|
|
||||||
val current = if (chunkBytes > 0) chunkBytes else lastPayloadBytes
|
|
||||||
if (current <= 0 || current >= MAX_CHUNK_BYTES) return
|
|
||||||
val heldMs = current / bytesPerMs
|
|
||||||
if (interval < heldMs * SLOW_ACK_RATIO) return
|
|
||||||
val gapFrames = Math.ceil(interval * sampleRate / 1000.0).toInt().coerceAtLeast(1)
|
|
||||||
val frames = Integer.highestOneBit(gapFrames).let { if (it < gapFrames) it shl 1 else it }
|
|
||||||
val target = minOf(MAX_CHUNK_BYTES / frame * frame, frames * frame)
|
|
||||||
if (target <= current) return
|
|
||||||
chunkBytes = target
|
|
||||||
GearslipLog.i(
|
|
||||||
"$tag: the car acks a chunk every ${interval.toInt()}ms, but each holds ${heldMs.toInt()}ms - " +
|
|
||||||
"sending $target-byte chunks (${(target / bytesPerMs).toInt()}ms) from now on",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Silence for [ms], whole frames of this sink's format. */
|
|
||||||
private fun silence(ms: Int): ByteArray = ByteArray(sampleRate * ms / 1000 * channels * 2)
|
|
||||||
|
|
||||||
override fun start() {
|
override fun start() {
|
||||||
if (!ready || started) return
|
if (!ready || started) return
|
||||||
if (asksFocus && !focusRequested) {
|
|
||||||
focusRequested = true
|
|
||||||
sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_GAIN.toLong()))
|
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(
|
sendOnChannel(
|
||||||
MSG_START,
|
MSG_START,
|
||||||
Protobuf.varintField(1, sessionId.toLong()) + Protobuf.varintField(2, configIndex.toLong()),
|
Protobuf.varintField(1, SESSION_ID.toLong()) + Protobuf.varintField(2, configIndex.toLong()),
|
||||||
)
|
)
|
||||||
// Raw AAC-LC has no header of its own: the car needs the codec config before the first frame.
|
|
||||||
if (service.codecType == ServiceDiscovery.CODEC_AAC_LC) {
|
|
||||||
val asc = AacEncoder.audioSpecificConfig(sampleRate, channels)
|
|
||||||
sendOnChannel(MSG_CODEC_CONFIG, asc)
|
|
||||||
GearslipLog.hex("$tag: -> CodecConfig (AudioSpecificConfig)", asc)
|
|
||||||
}
|
|
||||||
inFlight = 0
|
inFlight = 0
|
||||||
lastAckAt = System.currentTimeMillis()
|
lastAckAt = System.currentTimeMillis()
|
||||||
pendingStale = true
|
|
||||||
synchronized(ackLock) {
|
|
||||||
slowAcks = 0
|
|
||||||
slowAckMs = 0
|
|
||||||
lastAckFull = false
|
|
||||||
}
|
|
||||||
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
|
started = true
|
||||||
GearslipLog.i("$tag: -> Start(session=$sessionId, config=$configIndex) - streaming ${sampleRate}Hz x$channels")
|
GearslipLog.i("audio: -> AudioFocusRequest(GAIN), Start(config=$configIndex)")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun stop() {
|
override fun stop() {
|
||||||
synchronized(startLock) { stopLocked() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** [release] false keeps our claim on the speakers, so the car hands them back on its own. */
|
|
||||||
private fun stopLocked(release: Boolean = true) {
|
|
||||||
if (!started) return
|
if (!started) return
|
||||||
started = false
|
started = false
|
||||||
synchronized(ackLock) { ackLock.notifyAll() }
|
|
||||||
runCatching {
|
runCatching {
|
||||||
sendOnChannel(MSG_STOP, Protobuf.varintField(1, sessionId.toLong()))
|
sendOnChannel(MSG_STOP, Protobuf.varintField(1, SESSION_ID.toLong()))
|
||||||
if (release && focusRequested) sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_RELEASE.toLong()))
|
sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_RELEASE.toLong()))
|
||||||
}
|
}
|
||||||
if (release) focusRequested = false
|
GearslipLog.i("audio: -> Stop, AudioFocusRequest(RELEASE) after $chunks chunks")
|
||||||
focusGranted = false
|
|
||||||
aac?.release()
|
|
||||||
aac = null
|
|
||||||
GearslipLog.i("$tag: -> Stop${if (release) ", AudioFocusRequest(RELEASE)" else ""} after $chunks chunks / $bytes bytes")
|
|
||||||
chunks = 0
|
chunks = 0
|
||||||
bytes = 0
|
|
||||||
// A new stream starts with the car's buffer empty again: earn the lead back if it's needed.
|
|
||||||
padMs = 0
|
|
||||||
pendingPadMs = 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun write(pcm: ByteArray, size: Int) {
|
override fun write(pcm: ByteArray, size: Int) {
|
||||||
if (!started) return
|
if (!started) return
|
||||||
val pad = pendingPadMs
|
if (inFlight >= maxUnacked) {
|
||||||
if (pad > 0) {
|
|
||||||
pendingPadMs = 0
|
|
||||||
val quiet = silence(pad)
|
|
||||||
writeNow(quiet, quiet.size)
|
|
||||||
}
|
|
||||||
writeNow(pcm, size)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun writeNow(pcm: ByteArray, size: Int) {
|
|
||||||
val encoder = aac
|
|
||||||
if (encoder == null) {
|
|
||||||
if (pendingStale) {
|
|
||||||
pendingStale = false
|
|
||||||
pending.reset()
|
|
||||||
}
|
|
||||||
val want = chunkBytes
|
|
||||||
if (want <= 0 && pending.size() == 0) {
|
|
||||||
send(pcm.copyOf(size))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pending.write(pcm, 0, size)
|
|
||||||
if (pending.size() >= want) {
|
|
||||||
send(pending.toByteArray())
|
|
||||||
pending.reset()
|
|
||||||
}
|
|
||||||
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 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
|
if (System.currentTimeMillis() - lastAckAt > STALL_MS) inFlight = 0 else return
|
||||||
// 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 timestamp = ByteArray(8)
|
||||||
val micros = System.nanoTime() / 1_000
|
val micros = System.nanoTime() / 1_000
|
||||||
for (i in 0 until 8) timestamp[i] = ((micros shr ((7 - i) * 8)) and 0xFF).toByte()
|
for (i in 0 until 8) timestamp[i] = ((micros shr ((7 - i) * 8)) and 0xFF).toByte()
|
||||||
try {
|
try {
|
||||||
sendOnChannel(MSG_DATA, timestamp + payload)
|
sendOnChannel(MSG_DATA, timestamp + pcm.copyOf(size))
|
||||||
} catch (e: java.io.IOException) {
|
} catch (e: java.io.IOException) {
|
||||||
GearslipLog.e("$tag: the link died mid-stream", e)
|
|
||||||
started = false
|
started = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
synchronized(ackLock) { inFlight++ }
|
inFlight++
|
||||||
lastPayloadBytes = payload.size
|
|
||||||
chunks++
|
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() {
|
fun close() {
|
||||||
configTimer?.interrupt()
|
|
||||||
configTimer = null
|
|
||||||
stop()
|
stop()
|
||||||
handBackSpeakers()
|
|
||||||
ready = false
|
ready = false
|
||||||
onClose(this)
|
CarAudio.detach(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -535,41 +134,15 @@ class AudioLink(
|
|||||||
const val MSG_FOCUS_RESPONSE = 19
|
const val MSG_FOCUS_RESPONSE = 19
|
||||||
private const val MSG_CHANNEL_OPEN_RESPONSE = 8
|
private const val MSG_CHANNEL_OPEN_RESPONSE = 8
|
||||||
private const val MSG_DATA = 0
|
private const val MSG_DATA = 0
|
||||||
private const val MSG_CODEC_CONFIG = 1
|
|
||||||
private const val MSG_SETUP = 32768
|
private const val MSG_SETUP = 32768
|
||||||
private const val MSG_START = 32769
|
private const val MSG_START = 32769
|
||||||
private const val MSG_STOP = 32770
|
private const val MSG_STOP = 32770
|
||||||
private const val MSG_CONFIG = 32771
|
private const val MSG_CONFIG = 32771
|
||||||
private const val MSG_ACK = 32772
|
private const val MSG_ACK = 32772
|
||||||
private const val MSG_SETUP_ANSWER = 255
|
|
||||||
private const val MSG_UNDERFLOW = 32779 // 0x800B, car to phone, no body
|
|
||||||
private const val PAD_STEP_MS = 60
|
|
||||||
private const val MAX_PAD_MS = 240
|
|
||||||
private const val STATUS_READY = 2
|
private const val STATUS_READY = 2
|
||||||
private const val FOCUS_GAIN = 1
|
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_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 SESSION_ID = 2
|
||||||
private const val STALL_MS = 2_000L
|
private const val STALL_MS = 2_000L
|
||||||
/** Acks measured with the window full before the chunk size is judged. */
|
|
||||||
private const val ADAPT_ACKS = 20
|
|
||||||
/** Acks this much further apart than a chunk lasts mean the car is the one holding back. */
|
|
||||||
private const val SLOW_ACK_RATIO = 1.5
|
|
||||||
/** About 170 ms at 48 kHz stereo: the most delay a bigger chunk may add. */
|
|
||||||
private const val MAX_CHUNK_BYTES = 32_768
|
|
||||||
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",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,15 +6,12 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Joins the two ends of the audio path: [AudioCaptureService] on the phone, which hears what the
|
* Joins the two ends of the audio path: [AudioCaptureService] on the phone, which hears what a
|
||||||
* phone is playing, and the car's audio channel, which is where it has to end up.
|
* media app plays, 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
|
* They come and go independently. The phone side needs on-device consent and can be running on
|
||||||
* dialog that cannot be answered in advance or remembered, so Gearslip asks once, when a head unit
|
* the bench with no car attached; the car side exists only while a head unit is connected and
|
||||||
* first offers a sink to play through, and then holds that grant for as long as the car is
|
* offers a media audio sink. Whichever pair is present, samples flow.
|
||||||
* 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 {
|
object CarAudio {
|
||||||
|
|
||||||
@@ -39,115 +36,42 @@ object CarAudio {
|
|||||||
/** Set by the activity: shows the permission and consent dialogs, which only it can. */
|
/** Set by the activity: shows the permission and consent dialogs, which only it can. */
|
||||||
var onConsentRequested: (() -> Unit)? = null
|
var onConsentRequested: (() -> Unit)? = null
|
||||||
|
|
||||||
/** Whether the driver has asked for the car to be fed at all; see CarSettings.pipeAudio. */
|
/** The uid being captured (or waited on). */
|
||||||
@Volatile private var wanted = false
|
@Volatile var uid: Int = -1
|
||||||
|
private set
|
||||||
|
|
||||||
@Volatile private var link: Link? = null
|
@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
|
|
||||||
|
|
||||||
/** The car offers a music channel, open or waiting to be. */
|
|
||||||
val sinkOffered: Boolean get() = link != null || openSink != null
|
|
||||||
|
|
||||||
/** Rate and channel count the capture should use: the car's, or a sensible default. */
|
/** Rate and channel count the capture should use: the car's, or a sensible default. */
|
||||||
val sampleRate get() = link?.sampleRate ?: 48_000
|
val sampleRate get() = link?.sampleRate ?: 48_000
|
||||||
val channels get() = link?.channels ?: 2
|
val channels get() = link?.channels ?: 2
|
||||||
|
|
||||||
private val main by lazy { android.os.Handler(android.os.Looper.getMainLooper()) }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The car left and none came back: stop capturing, which ends the screen sharing Android
|
|
||||||
* shows for it and gives the phone its speaker back. Nothing else stopped it once the drive
|
|
||||||
* was over; with a wireless dongle Gearslip's screen stays open, so it ran on (issue #43).
|
|
||||||
*/
|
|
||||||
private val releaseWhenGone = Runnable {
|
|
||||||
if (link != null || _capture.value == Capture.OFF) return@Runnable
|
|
||||||
GearslipLog.i("audio: no car for ${CAR_GONE_MS / 1000}s - stopping the capture and screen sharing")
|
|
||||||
release()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun attach(link: Link) {
|
fun attach(link: Link) {
|
||||||
main.removeCallbacks(releaseWhenGone)
|
|
||||||
this.link = link
|
this.link = link
|
||||||
GearslipLog.i("audio: the car offered a sink at ${link.sampleRate}Hz x${link.channels}")
|
if (_capture.value == Capture.ON) link.start()
|
||||||
when {
|
|
||||||
_capture.value == Capture.ON -> link.start()
|
|
||||||
wanted -> ask()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The car's music channel closed. A car that comes straight back (a cable that slips) keeps
|
|
||||||
* the capture it had, so the driver isn't asked again mid-drive.
|
|
||||||
*/
|
|
||||||
fun detach(link: Link) {
|
fun detach(link: Link) {
|
||||||
if (this.link !== link) return
|
if (this.link === link) this.link = null
|
||||||
this.link = null
|
|
||||||
main.removeCallbacks(releaseWhenGone)
|
|
||||||
main.postDelayed(releaseWhenGone, CAR_GONE_MS)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** A media app was opened: start hearing it. */
|
||||||
* How long after the car leaves the capture waits for it to come back: a cable that slips, or a
|
fun request(uid: Int) {
|
||||||
* wireless link that drops and reconnects, which can take well over 3 seconds. Stopping sooner
|
if (this.uid == uid && _capture.value != Capture.OFF && _capture.value != Capture.DENIED) return
|
||||||
* would have Android ask the driver to share the screen again in the middle of a drive.
|
if (this.uid != uid) AudioCaptureService.stop()
|
||||||
*/
|
this.uid = uid
|
||||||
private const val CAR_GONE_MS = 30_000L
|
_capture.value = Capture.ASKING
|
||||||
|
val ask = onConsentRequested
|
||||||
/**
|
if (ask == null) {
|
||||||
* The car wants sound. Safe to call as often as the session likes: the consent dialog appears
|
GearslipLog.w("audio: nothing on the phone can ask for capture consent")
|
||||||
* at most once, and only once there is a sink to play through - asking on the bench, where the
|
_capture.value = Capture.DENIED
|
||||||
* 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 {
|
} else {
|
||||||
ask()
|
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Capture has heard nothing for a while. If a media app that blocks capture is the one
|
|
||||||
* playing, the car screen says why the music is silent, once a drive: Spotify does this.
|
|
||||||
*/
|
|
||||||
internal fun silent(context: android.content.Context) = android.os.Handler(android.os.Looper.getMainLooper()).post {
|
|
||||||
if (toldBlocked) return@post
|
|
||||||
val media = app.seb3thehacker.gearslip.car.CarServices.mediaOrNull() ?: return@post
|
|
||||||
if (!media.now.value.playing) return@post
|
|
||||||
val name = BLOCKS_CAPTURE[media.packageName] ?: return@post
|
|
||||||
toldBlocked = true
|
|
||||||
GearslipLog.i("audio: $name is playing and capture hears nothing: it blocks other apps from playing its sound")
|
|
||||||
app.seb3thehacker.gearslip.car.CarToasts.show(this, context.getString(app.seb3thehacker.gearslip.R.string.car_audio_blocked, name), androidx.car.app.CarToast.LENGTH_LONG)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Volatile private var toldBlocked = false
|
|
||||||
|
|
||||||
/** Media apps known to keep other apps from capturing their sound, by package. */
|
|
||||||
private val BLOCKS_CAPTURE = mapOf("com.spotify.music" to "Spotify")
|
|
||||||
|
|
||||||
/** The car went away. Everything is torn down; the next connection asks again. */
|
|
||||||
fun release() {
|
fun release() {
|
||||||
main.removeCallbacks(releaseWhenGone)
|
uid = -1
|
||||||
wanted = false
|
|
||||||
toldBlocked = false
|
|
||||||
AudioCaptureService.stop()
|
AudioCaptureService.stop()
|
||||||
_capture.value = Capture.OFF
|
_capture.value = Capture.OFF
|
||||||
}
|
}
|
||||||
@@ -156,26 +80,6 @@ object CarAudio {
|
|||||||
_capture.value = Capture.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() {
|
internal fun captureStarted() {
|
||||||
_capture.value = Capture.ON
|
_capture.value = Capture.ON
|
||||||
link?.start()
|
link?.start()
|
||||||
@@ -189,59 +93,6 @@ object CarAudio {
|
|||||||
|
|
||||||
internal fun deliver(pcm: ByteArray, size: Int, peak: Float) {
|
internal fun deliver(pcm: ByteArray, size: Int, peak: Float) {
|
||||||
_level.value = peak
|
_level.value = peak
|
||||||
val voice = synchronized(voiceLock) { spoken }
|
link?.write(pcm, size)
|
||||||
if (voice == null) link?.write(pcm, size) else link?.write(mixVoice(pcm, size), size)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private val voiceLock = Any()
|
|
||||||
private var spoken: ByteArray? = null
|
|
||||||
private var spokenAt = 0
|
|
||||||
private var spokenDone: java.util.concurrent.CountDownLatch? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mixes a spoken turn into the music from the next chunk on, with the music turned down
|
|
||||||
* under it, and waits until it has all gone out. [pcm] is 16-bit, in the sink's own format.
|
|
||||||
* False when no music is flowing to carry it, so the caller sends it some other way.
|
|
||||||
*/
|
|
||||||
fun speakOverMusic(pcm: ByteArray): Boolean {
|
|
||||||
if (_capture.value != Capture.ON || (link as? AudioLink)?.streaming != true) return false
|
|
||||||
val done = java.util.concurrent.CountDownLatch(1)
|
|
||||||
synchronized(voiceLock) {
|
|
||||||
spokenDone?.countDown()
|
|
||||||
spoken = pcm
|
|
||||||
spokenAt = 0
|
|
||||||
spokenDone = done
|
|
||||||
}
|
|
||||||
val ms = pcm.size * 1000L / (2 * channels * sampleRate)
|
|
||||||
if (!done.await(ms + VOICE_SLACK_MS, java.util.concurrent.TimeUnit.MILLISECONDS)) {
|
|
||||||
// Capture stopped mid-turn; the rest of it is dropped rather than played late.
|
|
||||||
synchronized(voiceLock) { if (spokenDone === done) spoken = null }
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun mixVoice(pcm: ByteArray, size: Int): ByteArray = synchronized(voiceLock) {
|
|
||||||
val voice = spoken ?: return pcm
|
|
||||||
val out = pcm.copyOf(size)
|
|
||||||
var i = 0
|
|
||||||
while (i + 1 < size) {
|
|
||||||
val music = (out[i].toInt() and 0xFF) or (out[i + 1].toInt() shl 8)
|
|
||||||
val word = if (spokenAt + 1 < voice.size) (voice[spokenAt].toInt() and 0xFF) or (voice[spokenAt + 1].toInt() shl 8) else 0
|
|
||||||
val mixed = (music * DUCK_PERCENT / 100 + word).coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt())
|
|
||||||
out[i] = mixed.toByte()
|
|
||||||
out[i + 1] = (mixed shr 8).toByte()
|
|
||||||
i += 2
|
|
||||||
spokenAt += 2
|
|
||||||
}
|
|
||||||
if (spokenAt >= voice.size) {
|
|
||||||
spoken = null
|
|
||||||
spokenDone?.countDown()
|
|
||||||
spokenDone = null
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/** How loud the music stays under a spoken turn. */
|
|
||||||
private const val DUCK_PERCENT = 30
|
|
||||||
private const val VOICE_SLACK_MS = 2_000L
|
|
||||||
}
|
}
|
||||||
@@ -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,212 +0,0 @@
|
|||||||
package app.seb3thehacker.gearslip.browser
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import java.net.URLEncoder
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import org.json.JSONArray
|
|
||||||
import org.json.JSONObject
|
|
||||||
|
|
||||||
/** A page saved to the browser's start page. */
|
|
||||||
data class Bookmark(val title: String, val url: String) {
|
|
||||||
/** The host without "www.", for labels and icons. */
|
|
||||||
val host: String get() = runCatching { android.net.Uri.parse(url).host.orEmpty() }.getOrDefault("").removePrefix("www.")
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Where typed searches go. [query] is the address with the search terms appended, encoded. */
|
|
||||||
enum class SearchEngine(val id: String, val label: String, private val query: String) {
|
|
||||||
DUCKDUCKGO("duckduckgo", "DuckDuckGo", "https://duckduckgo.com/?q="),
|
|
||||||
STARTPAGE("startpage", "Startpage", "https://www.startpage.com/do/search?q="),
|
|
||||||
BRAVE("brave", "Brave Search", "https://search.brave.com/search?q="),
|
|
||||||
ECOSIA("ecosia", "Ecosia", "https://www.ecosia.org/search?q="),
|
|
||||||
GOOGLE("google", "Google", "https://www.google.com/search?q="),
|
|
||||||
BING("bing", "Bing", "https://www.bing.com/search?q=");
|
|
||||||
|
|
||||||
fun search(text: String): String = query + URLEncoder.encode(text, "UTF-8")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A filter list the ad blocker can use: one of the two it ships with, or one the driver added by
|
|
||||||
* its address. [id] names the cached copy on disk.
|
|
||||||
*/
|
|
||||||
data class FilterList(val id: String, val title: String, val url: String, val builtIn: Boolean, val enabled: Boolean)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The browser's own settings: bookmarks, the search engine, and the ad blocker's switches and
|
|
||||||
* lists. Kept in a preferences file of their own and mirrored into flows, so the car's browser
|
|
||||||
* and the phone's settings show the same thing. The ad blocker reads only the flows' values,
|
|
||||||
* never the file, since it answers on WebView's request thread (see [adblock.AdBlockManager]).
|
|
||||||
*/
|
|
||||||
object BrowserSettings {
|
|
||||||
|
|
||||||
private const val FILE = "browser"
|
|
||||||
private const val KEY_BOOKMARKS = "bookmarks"
|
|
||||||
private const val KEY_SEARCH = "search_engine"
|
|
||||||
private const val KEY_ADBLOCK = "adblock"
|
|
||||||
private const val KEY_YOUTUBE = "adblock_youtube"
|
|
||||||
private const val KEY_LISTS_OFF = "adblock_lists_off"
|
|
||||||
private const val KEY_CUSTOM_LISTS = "adblock_custom_lists"
|
|
||||||
private const val KEY_LAST_CHECK = "adblock_last_check"
|
|
||||||
|
|
||||||
/** The lists Gearslip ships a snapshot of, on unless turned off. */
|
|
||||||
val BUILT_IN_LISTS = listOf(
|
|
||||||
FilterList("easylist", "EasyList", "https://easylist.to/easylist/easylist.txt", builtIn = true, enabled = true),
|
|
||||||
FilterList("easyprivacy", "EasyPrivacy", "https://easylist.to/easylist/easyprivacy.txt", builtIn = true, enabled = true),
|
|
||||||
)
|
|
||||||
|
|
||||||
/** What a new install's start page offers, until the driver changes it. */
|
|
||||||
private val DEFAULT_BOOKMARKS = listOf(
|
|
||||||
Bookmark("YouTube", "https://m.youtube.com"),
|
|
||||||
Bookmark("Wikipedia", "https://en.m.wikipedia.org"),
|
|
||||||
)
|
|
||||||
|
|
||||||
private val bookmarksFlow = MutableStateFlow<List<Bookmark>>(emptyList())
|
|
||||||
private val searchFlow = MutableStateFlow(SearchEngine.DUCKDUCKGO)
|
|
||||||
private val adBlockFlow = MutableStateFlow(true)
|
|
||||||
private val youTubeFlow = MutableStateFlow(true)
|
|
||||||
private val listsFlow = MutableStateFlow(BUILT_IN_LISTS)
|
|
||||||
|
|
||||||
val bookmarks: StateFlow<List<Bookmark>> = bookmarksFlow
|
|
||||||
val searchEngine: StateFlow<SearchEngine> = searchFlow
|
|
||||||
val adBlock: StateFlow<Boolean> = adBlockFlow
|
|
||||||
val youTubeAdBlock: StateFlow<Boolean> = youTubeFlow
|
|
||||||
val filterLists: StateFlow<List<FilterList>> = listsFlow
|
|
||||||
|
|
||||||
@Volatile private var prefs: SharedPreferences? = null
|
|
||||||
|
|
||||||
/** Loads everything once per process. Safe to call from anywhere that has a context. */
|
|
||||||
fun init(context: Context) {
|
|
||||||
if (prefs != null) return
|
|
||||||
synchronized(this) {
|
|
||||||
if (prefs != null) return
|
|
||||||
val p = context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
|
|
||||||
bookmarksFlow.value = if (p.contains(KEY_BOOKMARKS)) readBookmarks(p.getString(KEY_BOOKMARKS, "[]").orEmpty()) else DEFAULT_BOOKMARKS
|
|
||||||
searchFlow.value = SearchEngine.entries.firstOrNull { it.id == p.getString(KEY_SEARCH, null) } ?: SearchEngine.DUCKDUCKGO
|
|
||||||
adBlockFlow.value = p.getBoolean(KEY_ADBLOCK, true)
|
|
||||||
youTubeFlow.value = p.getBoolean(KEY_YOUTUBE, true)
|
|
||||||
listsFlow.value = readLists(p)
|
|
||||||
prefs = p
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bookmarks
|
|
||||||
|
|
||||||
fun isBookmarked(url: String?): Boolean = url != null && bookmarksFlow.value.any { same(it.url, url) }
|
|
||||||
|
|
||||||
/** Adds [url], or renames it when it's already saved. Returns false for something that isn't a web address. */
|
|
||||||
fun addBookmark(title: String, url: String): Boolean {
|
|
||||||
val clean = url.trim()
|
|
||||||
if (!clean.startsWith("http://") && !clean.startsWith("https://")) return false
|
|
||||||
val name = title.trim().ifEmpty { Bookmark("", clean).host.ifEmpty { clean } }
|
|
||||||
val list = bookmarksFlow.value
|
|
||||||
val existing = list.indexOfFirst { same(it.url, clean) }
|
|
||||||
saveBookmarks(if (existing >= 0) list.toMutableList().also { it[existing] = Bookmark(name, list[existing].url) } else list + Bookmark(name, clean))
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
fun removeBookmark(url: String) = saveBookmarks(bookmarksFlow.value.filterNot { same(it.url, url) })
|
|
||||||
|
|
||||||
/** Moves the bookmark at [from] to [to], for reordering from the phone. */
|
|
||||||
fun moveBookmark(from: Int, to: Int) {
|
|
||||||
val list = bookmarksFlow.value.toMutableList()
|
|
||||||
if (from !in list.indices || to !in list.indices) return
|
|
||||||
list.add(to, list.removeAt(from))
|
|
||||||
saveBookmarks(list)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The same page whatever the trailing slash or scheme. */
|
|
||||||
private fun same(a: String, b: String): Boolean {
|
|
||||||
fun key(u: String) = u.trim().removePrefix("https://").removePrefix("http://").removePrefix("www.").trimEnd('/')
|
|
||||||
return key(a).equals(key(b), ignoreCase = true)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun saveBookmarks(list: List<Bookmark>) {
|
|
||||||
bookmarksFlow.value = list
|
|
||||||
val array = JSONArray()
|
|
||||||
list.forEach { array.put(JSONObject().put("title", it.title).put("url", it.url)) }
|
|
||||||
prefs?.edit()?.putString(KEY_BOOKMARKS, array.toString())?.apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun readBookmarks(json: String): List<Bookmark> = runCatching {
|
|
||||||
val array = JSONArray(json)
|
|
||||||
(0 until array.length()).map { array.getJSONObject(it) }.map { Bookmark(it.optString("title"), it.optString("url")) }
|
|
||||||
.filter { it.url.isNotEmpty() }
|
|
||||||
}.getOrDefault(emptyList())
|
|
||||||
|
|
||||||
// Search
|
|
||||||
|
|
||||||
fun setSearchEngine(engine: SearchEngine) {
|
|
||||||
searchFlow.value = engine
|
|
||||||
prefs?.edit()?.putString(KEY_SEARCH, engine.id)?.apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Something that looks like an address opens as one; anything else is searched for. */
|
|
||||||
fun addressToUrl(typed: String): String {
|
|
||||||
val text = typed.trim()
|
|
||||||
return when {
|
|
||||||
text.startsWith("http://") || text.startsWith("https://") -> text
|
|
||||||
' ' !in text && '.' in text -> "https://$text"
|
|
||||||
else -> searchFlow.value.search(text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ad blocking
|
|
||||||
|
|
||||||
fun setAdBlock(on: Boolean) {
|
|
||||||
adBlockFlow.value = on
|
|
||||||
prefs?.edit()?.putBoolean(KEY_ADBLOCK, on)?.apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setYouTubeAdBlock(on: Boolean) {
|
|
||||||
youTubeFlow.value = on
|
|
||||||
prefs?.edit()?.putBoolean(KEY_YOUTUBE, on)?.apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setListEnabled(id: String, on: Boolean) {
|
|
||||||
val p = prefs ?: return
|
|
||||||
val off = p.getStringSet(KEY_LISTS_OFF, emptySet()).orEmpty().toMutableSet()
|
|
||||||
if (on) off.remove(id) else off.add(id)
|
|
||||||
p.edit().putStringSet(KEY_LISTS_OFF, off).apply()
|
|
||||||
listsFlow.value = readLists(p)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Adds a list by its address. Returns false for something that isn't one, or one already there. */
|
|
||||||
fun addCustomList(url: String): Boolean {
|
|
||||||
val p = prefs ?: return false
|
|
||||||
val clean = url.trim()
|
|
||||||
if (!clean.startsWith("https://") && !clean.startsWith("http://")) return false
|
|
||||||
if (listsFlow.value.any { it.url.equals(clean, ignoreCase = true) }) return false
|
|
||||||
p.edit().putString(KEY_CUSTOM_LISTS, (customUrls(p) + clean).joinToString("\n")).apply()
|
|
||||||
listsFlow.value = readLists(p)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
fun removeCustomList(id: String) {
|
|
||||||
val p = prefs ?: return
|
|
||||||
val list = listsFlow.value.firstOrNull { it.id == id && !it.builtIn } ?: return
|
|
||||||
p.edit().putString(KEY_CUSTOM_LISTS, customUrls(p).filterNot { it == list.url }.joinToString("\n")).apply()
|
|
||||||
listsFlow.value = readLists(p)
|
|
||||||
}
|
|
||||||
|
|
||||||
var lastUpdateCheck: Long
|
|
||||||
get() = prefs?.getLong(KEY_LAST_CHECK, 0L) ?: 0L
|
|
||||||
set(value) { prefs?.edit()?.putLong(KEY_LAST_CHECK, value)?.apply() }
|
|
||||||
|
|
||||||
private fun customUrls(p: SharedPreferences) = p.getString(KEY_CUSTOM_LISTS, "").orEmpty().lines().filter { it.isNotBlank() }
|
|
||||||
|
|
||||||
private fun readLists(p: SharedPreferences): List<FilterList> {
|
|
||||||
val off = p.getStringSet(KEY_LISTS_OFF, emptySet()).orEmpty()
|
|
||||||
val custom = customUrls(p).map { url ->
|
|
||||||
val name = runCatching { android.net.Uri.parse(url).lastPathSegment }.getOrNull()?.takeIf { it.isNotBlank() } ?: url
|
|
||||||
FilterList(customId(url), name, url, builtIn = false, enabled = true)
|
|
||||||
}
|
|
||||||
return (BUILT_IN_LISTS + custom).map { it.copy(enabled = it.id !in off) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A stable file name for a list known only by its address. */
|
|
||||||
private fun customId(url: String): String {
|
|
||||||
val digest = java.security.MessageDigest.getInstance("SHA-256").digest(url.toByteArray())
|
|
||||||
return "custom-" + digest.take(8).joinToString("") { "%02x".format(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
package app.seb3thehacker.gearslip.browser
|
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.widget.Toast
|
|
||||||
import app.seb3thehacker.gearslip.R
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The "Add to Gearslip" entry in Android's share sheet. A page shared from any browser becomes a
|
|
||||||
* tile on the car browser's start page, so nobody has to type an address on the car's keyboard.
|
|
||||||
* It shows nothing of its own: a toast says whether it worked, and it closes.
|
|
||||||
*/
|
|
||||||
class ShareToBrowserActivity : Activity() {
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
BrowserSettings.init(this)
|
|
||||||
val text = intent?.takeIf { it.action == Intent.ACTION_SEND }?.getStringExtra(Intent.EXTRA_TEXT).orEmpty()
|
|
||||||
// Browsers share the bare address; some apps wrap it in a sentence, so take the first link.
|
|
||||||
val url = URL_PATTERN.find(text)?.value
|
|
||||||
val title = intent?.getStringExtra(Intent.EXTRA_SUBJECT) ?: intent?.getStringExtra(Intent.EXTRA_TITLE) ?: ""
|
|
||||||
val added = url != null && BrowserSettings.addBookmark(title, url)
|
|
||||||
Toast.makeText(this, getString(if (added) R.string.browser_shared_added else R.string.browser_shared_not_url), Toast.LENGTH_SHORT).show()
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
val URL_PATTERN = Regex("""https?://\S+""")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
package app.seb3thehacker.gearslip.browser
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import app.seb3thehacker.gearslip.GearslipLog
|
|
||||||
import java.io.File
|
|
||||||
import java.net.HttpURLConnection
|
|
||||||
import java.net.URL
|
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Each site's icon for the start page's tiles, kept on disk by host. The browser saves the icon
|
|
||||||
* a page hands it while loading ([save]); a bookmark that hasn't been opened yet asks its own
|
|
||||||
* site once ([fetch]), for its touch icon and then its favicon. Nothing goes to a third-party
|
|
||||||
* icon service, so no list of the driver's bookmarks leaves the phone.
|
|
||||||
*/
|
|
||||||
object SiteIcons {
|
|
||||||
|
|
||||||
private const val DIRECTORY = "site-icons"
|
|
||||||
/** Smaller than this is a 16px favicon that would look blurry on a tile; a bigger one wins. */
|
|
||||||
private const val GOOD_ENOUGH_PX = 96
|
|
||||||
|
|
||||||
private val memory = ConcurrentHashMap<String, Bitmap>()
|
|
||||||
private val asked = ConcurrentHashMap.newKeySet<String>()
|
|
||||||
private val changed = MutableStateFlow(0)
|
|
||||||
|
|
||||||
/** Bumps whenever an icon is saved, so tiles look again. */
|
|
||||||
val version: StateFlow<Int> = changed
|
|
||||||
|
|
||||||
fun icon(context: Context, host: String): Bitmap? {
|
|
||||||
if (host.isEmpty()) return null
|
|
||||||
memory[host]?.let { return it }
|
|
||||||
val file = file(context, host)
|
|
||||||
if (!file.isFile) return null
|
|
||||||
return runCatching { BitmapFactory.decodeFile(file.path) }.getOrNull()?.also { memory[host] = it }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An icon the page gave the browser. Kept when it's bigger than the one already saved. */
|
|
||||||
fun save(context: Context, host: String, bitmap: Bitmap) {
|
|
||||||
if (host.isEmpty()) return
|
|
||||||
val current = icon(context, host)
|
|
||||||
if (current != null && current.width >= bitmap.width) return
|
|
||||||
store(context, host, bitmap)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Asks [host] for its icon in the background, once per run, when none is saved or it's small. */
|
|
||||||
fun fetch(context: Context, host: String) {
|
|
||||||
if (host.isEmpty() || !asked.add(host)) return
|
|
||||||
val current = icon(context, host)
|
|
||||||
if (current != null && current.width >= GOOD_ENOUGH_PX) return
|
|
||||||
val app = context.applicationContext
|
|
||||||
Thread({
|
|
||||||
for (path in listOf("/apple-touch-icon.png", "/favicon.ico")) {
|
|
||||||
val bitmap = runCatching { download("https://$host$path") }.getOrNull() ?: continue
|
|
||||||
if (current == null || bitmap.width > current.width) store(app, host, bitmap)
|
|
||||||
return@Thread
|
|
||||||
}
|
|
||||||
}, "site-icon").apply { isDaemon = true }.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun store(context: Context, host: String, bitmap: Bitmap) {
|
|
||||||
memory[host] = bitmap
|
|
||||||
runCatching {
|
|
||||||
val file = file(context, host)
|
|
||||||
file.parentFile?.mkdirs()
|
|
||||||
file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
|
||||||
}.onFailure { GearslipLog.w("browser: couldn't save the icon for $host: ${it.message}") }
|
|
||||||
changed.value++
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun download(url: String): Bitmap? {
|
|
||||||
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
|
|
||||||
connectTimeout = 10_000
|
|
||||||
readTimeout = 10_000
|
|
||||||
instanceFollowRedirects = true
|
|
||||||
}
|
|
||||||
return try {
|
|
||||||
if (connection.responseCode !in 200..299) return null
|
|
||||||
val bytes = connection.inputStream.use { it.readBytes() }
|
|
||||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
|
||||||
} finally {
|
|
||||||
connection.disconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun file(context: Context, host: String) =
|
|
||||||
File(File(context.filesDir, DIRECTORY), host.replace(Regex("[^A-Za-z0-9.-]"), "_") + ".png")
|
|
||||||
}
|
|
||||||
@@ -1,417 +0,0 @@
|
|||||||
package app.seb3thehacker.gearslip.browser.adblock
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.ConnectivityManager
|
|
||||||
import android.net.NetworkCapabilities
|
|
||||||
import android.util.Base64
|
|
||||||
import android.webkit.WebResourceRequest
|
|
||||||
import android.webkit.WebResourceResponse
|
|
||||||
import app.seb3thehacker.gearslip.GearslipLog
|
|
||||||
import app.seb3thehacker.gearslip.browser.BrowserSettings
|
|
||||||
import app.seb3thehacker.gearslip.browser.FilterList
|
|
||||||
import java.io.ByteArrayInputStream
|
|
||||||
import java.io.File
|
|
||||||
import java.net.HttpURLConnection
|
|
||||||
import java.net.URL
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
|
||||||
import java.util.concurrent.atomic.AtomicLong
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Owns the filter lists: downloading them, keeping a parsed [FilterEngine] in memory, and
|
|
||||||
* answering the two questions the WebView asks: block this request, and what to hide on
|
|
||||||
* this page.
|
|
||||||
*
|
|
||||||
* Everything the request path touches is cached in memory. `shouldInterceptRequest` runs on a
|
|
||||||
* background thread for every subresource on the page, so it must never read preferences or
|
|
||||||
* touch disk. The switches come from [BrowserSettings]'s flows, which are plain memory reads.
|
|
||||||
*
|
|
||||||
* Downloading a list contacts the server it comes from (easylist.to for the two built in), and
|
|
||||||
* nothing else: no page the driver visits is ever sent anywhere.
|
|
||||||
*/
|
|
||||||
object AdBlockManager {
|
|
||||||
|
|
||||||
sealed class RefreshResult {
|
|
||||||
data class Success(val networkRules: Int, val cosmeticRules: Int) : RefreshResult()
|
|
||||||
data class Failure(val message: String) : RefreshResult()
|
|
||||||
}
|
|
||||||
|
|
||||||
private const val DIRECTORY = "adblock"
|
|
||||||
private const val BUNDLED_ASSET = "adblock/bundled.txt"
|
|
||||||
private const val MAX_LIST_BYTES = 12L * 1024 * 1024
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How stale the lists may get before the browser refreshes them on its own. EasyList
|
|
||||||
* publishes several times a day, but the parts that matter, the domains ads are served from,
|
|
||||||
* turn over slowly, and each update costs a few megabytes.
|
|
||||||
*/
|
|
||||||
private const val UPDATE_INTERVAL_MS = 7L * 24 * 60 * 60 * 1000
|
|
||||||
|
|
||||||
// 1x1 transparent GIF, returned for blocked images so pages show a gap rather than a
|
|
||||||
// broken-image icon.
|
|
||||||
private const val TRANSPARENT_GIF_BASE64 =
|
|
||||||
"R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
|
|
||||||
|
|
||||||
private val EMPTY = ByteArray(0)
|
|
||||||
private val EMPTY_JSON = "{}".toByteArray()
|
|
||||||
|
|
||||||
val transparentGifBytes: ByteArray by lazy {
|
|
||||||
Base64.decode(TRANSPARENT_GIF_BASE64, Base64.DEFAULT)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var engine: FilterEngine? = null
|
|
||||||
|
|
||||||
/** No per-site allowlist yet; kept so the request path reads the same as AABrowser's. */
|
|
||||||
private val allowlist: Set<String> = emptySet()
|
|
||||||
|
|
||||||
private val enabled: Boolean get() = BrowserSettings.adBlock.value
|
|
||||||
private val youTubeEnabled: Boolean get() = BrowserSettings.youTubeAdBlock.value
|
|
||||||
|
|
||||||
private val loadStarted = AtomicBoolean(false)
|
|
||||||
private val refreshRunning = AtomicBoolean(false)
|
|
||||||
private val autoUpdateAttempted = AtomicBoolean(false)
|
|
||||||
private val blockedCount = AtomicLong(0)
|
|
||||||
// Time spent deciding requests since the last page load, for the log line that tells whether
|
|
||||||
// the blocker is what makes a page or a video slow to start.
|
|
||||||
private val checkedCount = AtomicLong(0)
|
|
||||||
private val checkedNanos = AtomicLong(0)
|
|
||||||
private val blockedSincePage = AtomicLong(0)
|
|
||||||
|
|
||||||
val isEngineReady: Boolean get() = engine != null
|
|
||||||
val blockedRequestCount: Long get() = blockedCount.get()
|
|
||||||
val networkRuleCount: Int get() = engine?.networkRuleCount ?: 0
|
|
||||||
val cosmeticRuleCount: Int get() = engine?.cosmeticRuleCount ?: 0
|
|
||||||
|
|
||||||
/** Loads the cached lists from disk once per process, on a background thread. */
|
|
||||||
fun ensureInitialized(context: Context) {
|
|
||||||
BrowserSettings.init(context)
|
|
||||||
if (!enabled) return
|
|
||||||
if (!loadStarted.compareAndSet(false, true)) return
|
|
||||||
|
|
||||||
val appContext = context.applicationContext
|
|
||||||
Thread({
|
|
||||||
runCatching { loadFromDisk(appContext) }
|
|
||||||
.onFailure { GearslipLog.w("adblock: could not load the filter lists: ${it.message}") }
|
|
||||||
}, "adblock-load").apply {
|
|
||||||
// Blocking does nothing until this finishes, so it gets a normal share of the CPU.
|
|
||||||
// On a Pixel 6 a release build parses both lists in about 250ms.
|
|
||||||
priority = Thread.NORM_PRIORITY
|
|
||||||
start()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Rebuilds the engine from what's on disk after a list was turned on or off. */
|
|
||||||
fun reload(context: Context) {
|
|
||||||
val appContext = context.applicationContext
|
|
||||||
loadStarted.set(true)
|
|
||||||
Thread({ runCatching { loadFromDisk(appContext) } }, "adblock-load").start()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun lastUpdatedAt(context: Context): Long =
|
|
||||||
BrowserSettings.filterLists.value.mapNotNull { cacheFile(context, it).takeIf { file -> file.length() > 0 } }
|
|
||||||
.maxOfOrNull { it.lastModified() } ?: 0L
|
|
||||||
|
|
||||||
private fun cacheFile(context: Context, source: FilterList): File =
|
|
||||||
File(File(context.filesDir, DIRECTORY), "${source.id}.txt")
|
|
||||||
|
|
||||||
private fun loadFromDisk(context: Context) {
|
|
||||||
val lists = BrowserSettings.filterLists.value.filter { it.enabled }
|
|
||||||
val files = lists.map { cacheFile(context, it) }.filter { it.length() > 0 }
|
|
||||||
val builder = FilterEngine.Builder()
|
|
||||||
|
|
||||||
// A built-in list that hasn't downloaded yet comes from the snapshot in the APK, so
|
|
||||||
// blocking works on the very first page rather than only after a download.
|
|
||||||
val needsBundled = lists.any { it.builtIn && cacheFile(context, it).length() == 0L }
|
|
||||||
if (needsBundled) {
|
|
||||||
context.assets.open(BUNDLED_ASSET).bufferedReader(Charsets.UTF_8).use { reader ->
|
|
||||||
while (true) {
|
|
||||||
val line = reader.readLine() ?: break
|
|
||||||
builder.addLine(line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (file in files) {
|
|
||||||
file.bufferedReader(bufferSize = 64 * 1024).use { reader ->
|
|
||||||
while (true) {
|
|
||||||
val line = reader.readLine() ?: break
|
|
||||||
builder.addLine(line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
engine = builder.finish()
|
|
||||||
GearslipLog.i("adblock: ${networkRuleCount} network and ${cosmeticRuleCount} hiding rules from ${lists.size} lists")
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Downloads every list that's on, replaces the cached copies, and rebuilds the engine. */
|
|
||||||
fun refreshLists(context: Context): RefreshResult {
|
|
||||||
// The automatic refresh and the button in settings can both land here; two downloads
|
|
||||||
// writing the same files would leave one of them truncated.
|
|
||||||
val appContext = context.applicationContext
|
|
||||||
if (!refreshRunning.compareAndSet(false, true)) {
|
|
||||||
return RefreshResult.Failure("An update is already running.")
|
|
||||||
}
|
|
||||||
return try {
|
|
||||||
refresh(appContext).also {
|
|
||||||
// A manual update counts as a check, so the automatic one waits a full interval.
|
|
||||||
if (it is RefreshResult.Success) BrowserSettings.lastUpdateCheck = System.currentTimeMillis()
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
refreshRunning.set(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the lists when they have gone stale and the phone is on Wi-Fi, on a background
|
|
||||||
* thread. Runs when the car's browser opens; there is no scheduled job. Only one attempt per
|
|
||||||
* process, and the time is written on success alone, so a phone that was offline tries again
|
|
||||||
* next time rather than in a week.
|
|
||||||
*/
|
|
||||||
fun refreshIfDue(context: Context) {
|
|
||||||
val appContext = context.applicationContext
|
|
||||||
BrowserSettings.init(appContext)
|
|
||||||
if (!enabled || !isUpdateDue(appContext) || !onUnmeteredNetwork(appContext)) return
|
|
||||||
if (!autoUpdateAttempted.compareAndSet(false, true)) return
|
|
||||||
Thread({
|
|
||||||
val result = refreshLists(appContext)
|
|
||||||
GearslipLog.i("adblock: weekly list update - $result")
|
|
||||||
}, "adblock-update").start()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isUpdateDue(context: Context): Boolean {
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
val lastCheck = BrowserSettings.lastUpdateCheck
|
|
||||||
if (lastCheck > 0 && now - lastCheck < UPDATE_INTERVAL_MS) return false
|
|
||||||
val baseline = lastUpdatedAt(context)
|
|
||||||
// Nothing downloaded yet: the snapshot in the APK may be months old, so fetch now.
|
|
||||||
if (baseline <= 0L) return true
|
|
||||||
// A clock that has jumped backwards would otherwise freeze the lists indefinitely.
|
|
||||||
if (baseline > now) return true
|
|
||||||
return now - baseline >= UPDATE_INTERVAL_MS
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onUnmeteredNetwork(context: Context): Boolean {
|
|
||||||
val manager = context.getSystemService(ConnectivityManager::class.java) ?: return false
|
|
||||||
val caps = manager.getNetworkCapabilities(manager.activeNetwork) ?: return false
|
|
||||||
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun refresh(context: Context): RefreshResult {
|
|
||||||
val directory = File(context.filesDir, DIRECTORY)
|
|
||||||
if (!directory.exists() && !directory.mkdirs()) {
|
|
||||||
return RefreshResult.Failure("Could not create the filter list folder.")
|
|
||||||
}
|
|
||||||
|
|
||||||
for (source in BrowserSettings.filterLists.value.filter { it.enabled }) {
|
|
||||||
val result = runCatching { download(source, cacheFile(context, source)) }
|
|
||||||
val error = result.exceptionOrNull()
|
|
||||||
if (error != null) return RefreshResult.Failure("${source.title}: ${error.message ?: "download failed"}")
|
|
||||||
val failure = result.getOrNull()
|
|
||||||
if (failure != null) return RefreshResult.Failure("${source.title}: $failure")
|
|
||||||
}
|
|
||||||
|
|
||||||
loadStarted.set(true)
|
|
||||||
val loadError = runCatching { loadFromDisk(context) }.exceptionOrNull()
|
|
||||||
if (loadError != null) return RefreshResult.Failure(loadError.message ?: "Could not read the lists.")
|
|
||||||
|
|
||||||
val current = engine ?: return RefreshResult.Failure("The lists downloaded but contained no rules.")
|
|
||||||
return RefreshResult.Success(current.networkRuleCount, current.cosmeticRuleCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns null on success, or a human-readable reason for failure. */
|
|
||||||
private fun download(source: FilterList, destination: File): String? {
|
|
||||||
val connection = (URL(source.url).openConnection() as HttpURLConnection).apply {
|
|
||||||
connectTimeout = 15_000
|
|
||||||
readTimeout = 30_000
|
|
||||||
setRequestProperty("Accept", "text/plain")
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (connection.responseCode !in 200..299) return "HTTP ${connection.responseCode}"
|
|
||||||
val temporary = File(destination.parentFile, destination.name + ".tmp")
|
|
||||||
var total = 0L
|
|
||||||
|
|
||||||
temporary.outputStream().use { output ->
|
|
||||||
connection.inputStream.use { input ->
|
|
||||||
val buffer = ByteArray(16 * 1024)
|
|
||||||
while (true) {
|
|
||||||
val read = input.read(buffer)
|
|
||||||
if (read < 0) break
|
|
||||||
total += read
|
|
||||||
if (total > MAX_LIST_BYTES) {
|
|
||||||
temporary.delete()
|
|
||||||
return "list is unexpectedly large"
|
|
||||||
}
|
|
||||||
output.write(buffer, 0, read)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (total == 0L) {
|
|
||||||
temporary.delete()
|
|
||||||
return "empty response"
|
|
||||||
}
|
|
||||||
if (!temporary.renameTo(destination)) {
|
|
||||||
temporary.delete()
|
|
||||||
return "could not save the list"
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
connection.disconnect()
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Deletes the cached copy of a list the driver removed. */
|
|
||||||
fun forget(context: Context, list: FilterList) {
|
|
||||||
cacheFile(context, list).delete()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isAllowlisted(host: String?): Boolean {
|
|
||||||
if (host.isNullOrEmpty()) return false
|
|
||||||
val current = allowlist
|
|
||||||
if (current.isEmpty()) return false
|
|
||||||
val normalized = host.lowercase()
|
|
||||||
return current.any { DomainUtils.hostMatchesDomain(normalized, it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decides a single subresource request and, if it should be blocked, builds the response for
|
|
||||||
* it. [pageUrl] is the document the request belongs to, tracked by the WebView client because
|
|
||||||
* a WebView's own url can only be read on the UI thread. Null means let the request through.
|
|
||||||
*/
|
|
||||||
fun response(request: WebResourceRequest, pageUrl: String?): WebResourceResponse? {
|
|
||||||
if (!enabled) return null
|
|
||||||
// A ranged request is a video or audio stream fetching its next piece: ads never come
|
|
||||||
// that way, and a stream makes hundreds of them, so they skip the lists entirely.
|
|
||||||
if (request.requestHeaders?.keys?.any { it.equals("Range", ignoreCase = true) } == true) return null
|
|
||||||
val started = System.nanoTime()
|
|
||||||
try {
|
|
||||||
return decide(request, pageUrl)
|
|
||||||
} finally {
|
|
||||||
checkedCount.incrementAndGet()
|
|
||||||
checkedNanos.addAndGet(System.nanoTime() - started)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Logs what the blocker cost the page that just finished loading, and starts counting afresh. */
|
|
||||||
fun logPageCost(url: String?) {
|
|
||||||
val count = checkedCount.getAndSet(0)
|
|
||||||
val ms = checkedNanos.getAndSet(0) / 1_000_000
|
|
||||||
val blocked = blockedSincePage.getAndSet(0)
|
|
||||||
if (count > 0) GearslipLog.i("adblock: $count requests checked in ${ms}ms, $blocked blocked, for ${url?.substringBefore('?')}")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun decide(request: WebResourceRequest, pageUrl: String?): WebResourceResponse? {
|
|
||||||
val current = engine ?: return null
|
|
||||||
|
|
||||||
// Never interfere with top-level navigation: a mistake there costs the whole page.
|
|
||||||
if (request.isForMainFrame) return null
|
|
||||||
|
|
||||||
val url = request.url ?: return null
|
|
||||||
val scheme = url.scheme?.lowercase()
|
|
||||||
if (scheme != "http" && scheme != "https") return null
|
|
||||||
|
|
||||||
val documentHost = pageUrl?.let { runCatching { android.net.Uri.parse(it).host }.getOrNull() }
|
|
||||||
if (isAllowlisted(documentHost)) return null
|
|
||||||
|
|
||||||
val requestHost = url.host?.lowercase()
|
|
||||||
val type = ResourceType.infer(request)
|
|
||||||
val isThirdParty = DomainUtils.isThirdParty(requestHost, documentHost)
|
|
||||||
|
|
||||||
val match = runCatching {
|
|
||||||
current.matchBlockingRule(url.toString(), requestHost, documentHost, type, isThirdParty)
|
|
||||||
}.getOrNull() ?: return null
|
|
||||||
|
|
||||||
blockedCount.incrementAndGet()
|
|
||||||
blockedSincePage.incrementAndGet()
|
|
||||||
return blockedResponse(request, match.redirectResource)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The stand-in served for a blocked request.
|
|
||||||
*
|
|
||||||
* The content type matters as much as the emptiness. A page that asked for a script and
|
|
||||||
* receives `text/plain` does not get a script that does nothing — it gets a resource the
|
|
||||||
* browser refuses to execute, and any code waiting on that script waits until it times
|
|
||||||
* out. YouTube's player does exactly this with its ad-status check, which showed up as a
|
|
||||||
* five to ten second stall before playback. So each blocked request is answered with a
|
|
||||||
* valid, empty thing of the kind it asked for, which is what uBlock Origin's `noop`
|
|
||||||
* redirect resources do.
|
|
||||||
*
|
|
||||||
* When the matched rule named a specific `$redirect=` resource we recognise, that exact
|
|
||||||
* stub is served instead of the generic empty one -- see [RedirectResources] for why that
|
|
||||||
* sometimes matters.
|
|
||||||
*/
|
|
||||||
private fun blockedResponse(request: WebResourceRequest, redirectResource: String?): WebResourceResponse {
|
|
||||||
val headers = mapOf(
|
|
||||||
"Access-Control-Allow-Origin" to "*",
|
|
||||||
"Cache-Control" to "no-store"
|
|
||||||
)
|
|
||||||
|
|
||||||
if (redirectResource != null) {
|
|
||||||
RedirectResources.response(redirectResource, headers)?.let { return it }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun response(mimeType: String, encoding: String?, body: ByteArray) =
|
|
||||||
WebResourceResponse(mimeType, encoding, 200, "OK", headers, ByteArrayInputStream(body))
|
|
||||||
|
|
||||||
return when (ResourceType.infer(request)) {
|
|
||||||
ResourceType.IMAGE -> response("image/gif", null, transparentGifBytes)
|
|
||||||
ResourceType.SCRIPT -> response("application/javascript", "utf-8", EMPTY)
|
|
||||||
ResourceType.STYLESHEET -> response("text/css", "utf-8", EMPTY)
|
|
||||||
ResourceType.SUBDOCUMENT, ResourceType.DOCUMENT -> response("text/html", "utf-8", EMPTY)
|
|
||||||
// Callers parse this; an empty body would throw where "{}" quietly yields nothing.
|
|
||||||
ResourceType.XHR -> response("application/json", "utf-8", EMPTY_JSON)
|
|
||||||
ResourceType.MEDIA -> response("video/mp4", null, EMPTY)
|
|
||||||
ResourceType.FONT -> response("font/woff2", null, EMPTY)
|
|
||||||
else -> response("text/plain", "utf-8", EMPTY)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** CSS injected at document start, or an empty string when nothing applies. */
|
|
||||||
fun cssForHost(host: String?): String {
|
|
||||||
if (!enabled) return ""
|
|
||||||
if (isAllowlisted(host)) return ""
|
|
||||||
return engine?.cssForHost(host).orEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether the YouTube-specific ad-stripping scripts should run for [host]. */
|
|
||||||
fun isYouTubeBlockingEnabled(host: String?): Boolean {
|
|
||||||
if (!enabled || !youTubeEnabled) return false
|
|
||||||
return !isAllowlisted(host)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether non-ad cosmetic site tweaks (hiding a redundant footer nav, a Shorts shelf, ...)
|
|
||||||
* should run. Independent of ad blocking: a user may want one without the other, and unlike
|
|
||||||
* ad blocking there is no per-host allowlist for it, since it isn't blocking anything.
|
|
||||||
*/
|
|
||||||
fun isCustomSiteTweaksEnabled(): Boolean = false
|
|
||||||
|
|
||||||
/** Whether a page's own fixed bottom bar should be hidden, per the in-drawer toggle. */
|
|
||||||
fun isHideSiteFootersEnabled(): Boolean = false
|
|
||||||
|
|
||||||
/** CSS for generic rules whose class or id turned up in the page's DOM. */
|
|
||||||
fun cssForTokens(host: String?, classNames: String, ids: String): String {
|
|
||||||
if (!enabled) return ""
|
|
||||||
if (isAllowlisted(host)) return ""
|
|
||||||
return engine?.cssForTokens(host, classNames, ids).orEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The `:has-text()` rules for [host], as a JSON array of `[selector, regexSource]` pairs, for
|
|
||||||
* the page's own JavaScript to apply -- there is no CSS equivalent for a text match, unlike
|
|
||||||
* every other cosmetic rule this engine serves.
|
|
||||||
*/
|
|
||||||
fun hasTextRulesForHost(host: String?): String {
|
|
||||||
if (!enabled) return "[]"
|
|
||||||
if (isAllowlisted(host)) return "[]"
|
|
||||||
val rules = engine?.hasTextRulesForHost(host) ?: return "[]"
|
|
||||||
if (rules.isEmpty()) return "[]"
|
|
||||||
|
|
||||||
val array = org.json.JSONArray()
|
|
||||||
for ((selector, pattern) in rules) {
|
|
||||||
array.put(org.json.JSONArray().put(selector).put(pattern))
|
|
||||||
}
|
|
||||||
return array.toString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
package app.seb3thehacker.gearslip.browser.adblock
|
|
||||||
|
|
||||||
import android.webkit.WebResourceRequest
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resource types used by filter list options ($script, $image, ...).
|
|
||||||
*
|
|
||||||
* WebResourceRequest does not expose the resource type directly, so [infer] reconstructs it
|
|
||||||
* from the Sec-Fetch-Dest request header where WebView sends one, falling back to the file
|
|
||||||
* extension and then the Accept header.
|
|
||||||
*/
|
|
||||||
internal object ResourceType {
|
|
||||||
const val NONE = 0
|
|
||||||
const val DOCUMENT = 1 shl 0
|
|
||||||
const val SUBDOCUMENT = 1 shl 1
|
|
||||||
const val SCRIPT = 1 shl 2
|
|
||||||
const val IMAGE = 1 shl 3
|
|
||||||
const val STYLESHEET = 1 shl 4
|
|
||||||
const val XHR = 1 shl 5
|
|
||||||
const val FONT = 1 shl 6
|
|
||||||
const val MEDIA = 1 shl 7
|
|
||||||
const val WEBSOCKET = 1 shl 8
|
|
||||||
const val PING = 1 shl 9
|
|
||||||
const val OBJECT = 1 shl 10
|
|
||||||
const val OTHER = 1 shl 11
|
|
||||||
|
|
||||||
fun fromOptionName(name: String): Int = when (name) {
|
|
||||||
"document" -> DOCUMENT
|
|
||||||
"subdocument", "frame" -> SUBDOCUMENT
|
|
||||||
"script" -> SCRIPT
|
|
||||||
"image" -> IMAGE
|
|
||||||
"stylesheet", "css" -> STYLESHEET
|
|
||||||
"xmlhttprequest", "xhr" -> XHR
|
|
||||||
"font" -> FONT
|
|
||||||
"media" -> MEDIA
|
|
||||||
"websocket" -> WEBSOCKET
|
|
||||||
"ping", "beacon" -> PING
|
|
||||||
"object", "object-subrequest" -> OBJECT
|
|
||||||
"other" -> OTHER
|
|
||||||
else -> NONE
|
|
||||||
}
|
|
||||||
|
|
||||||
fun infer(request: WebResourceRequest): Int {
|
|
||||||
if (request.isForMainFrame) return DOCUMENT
|
|
||||||
|
|
||||||
val headers = request.requestHeaders
|
|
||||||
val dest = headers?.entries
|
|
||||||
?.firstOrNull { it.key.equals("Sec-Fetch-Dest", ignoreCase = true) }
|
|
||||||
?.value
|
|
||||||
?.lowercase()
|
|
||||||
|
|
||||||
if (!dest.isNullOrEmpty() && dest != "empty") {
|
|
||||||
val fromDest = when (dest) {
|
|
||||||
"document" -> DOCUMENT
|
|
||||||
"iframe", "frame", "embed" -> SUBDOCUMENT
|
|
||||||
"script", "worker", "sharedworker", "serviceworker" -> SCRIPT
|
|
||||||
"image" -> IMAGE
|
|
||||||
"style" -> STYLESHEET
|
|
||||||
"font" -> FONT
|
|
||||||
"audio", "video", "track", "audioworklet" -> MEDIA
|
|
||||||
"object" -> OBJECT
|
|
||||||
"report" -> PING
|
|
||||||
"manifest" -> OTHER
|
|
||||||
else -> NONE
|
|
||||||
}
|
|
||||||
if (fromDest != NONE) return fromDest
|
|
||||||
}
|
|
||||||
|
|
||||||
val fromExtension = extensionType(request.url?.path)
|
|
||||||
if (fromExtension != NONE) return fromExtension
|
|
||||||
|
|
||||||
val accept = headers?.entries
|
|
||||||
?.firstOrNull { it.key.equals("Accept", ignoreCase = true) }
|
|
||||||
?.value
|
|
||||||
?.lowercase()
|
|
||||||
if (accept != null) {
|
|
||||||
when {
|
|
||||||
accept.startsWith("image/") || accept.contains("image/avif") -> return IMAGE
|
|
||||||
accept.startsWith("text/css") -> return STYLESHEET
|
|
||||||
accept.startsWith("video/") || accept.startsWith("audio/") -> return MEDIA
|
|
||||||
accept.contains("application/javascript") -> return SCRIPT
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// "empty" is what fetch()/XMLHttpRequest report, and is also the fallback when
|
|
||||||
// WebView sends no Sec-Fetch-Dest at all.
|
|
||||||
return if (dest == "empty") XHR else OTHER
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun extensionType(path: String?): Int {
|
|
||||||
if (path.isNullOrEmpty()) return NONE
|
|
||||||
val dot = path.lastIndexOf('.')
|
|
||||||
if (dot < 0 || dot == path.length - 1) return NONE
|
|
||||||
return when (path.substring(dot + 1).lowercase()) {
|
|
||||||
"js", "mjs" -> SCRIPT
|
|
||||||
"css" -> STYLESHEET
|
|
||||||
"png", "jpg", "jpeg", "gif", "webp", "svg", "ico", "bmp", "avif" -> IMAGE
|
|
||||||
"woff", "woff2", "ttf", "otf", "eot" -> FONT
|
|
||||||
"mp4", "webm", "ogg", "mp3", "m4a", "m4s", "ts", "m3u8", "mpd" -> MEDIA
|
|
||||||
"json" -> XHR
|
|
||||||
"swf" -> OBJECT
|
|
||||||
else -> NONE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Helpers for comparing hosts without pulling in a full public suffix list. */
|
|
||||||
internal object DomainUtils {
|
|
||||||
/**
|
|
||||||
* Common multi-label public suffixes. This is deliberately a short list rather than the
|
|
||||||
* full PSL: it is only used to decide first-party vs third-party, where being wrong means
|
|
||||||
* a $third-party rule misfires on an uncommon TLD, not a broken page.
|
|
||||||
*/
|
|
||||||
private val MULTI_LABEL_SUFFIXES = hashSetOf(
|
|
||||||
"co.uk", "org.uk", "me.uk", "ac.uk", "gov.uk", "net.uk", "sch.uk",
|
|
||||||
"co.jp", "ne.jp", "or.jp", "ac.jp", "go.jp",
|
|
||||||
"com.au", "net.au", "org.au", "edu.au", "gov.au",
|
|
||||||
"co.nz", "net.nz", "org.nz",
|
|
||||||
"com.br", "net.br", "org.br",
|
|
||||||
"co.in", "net.in", "org.in",
|
|
||||||
"com.cn", "net.cn", "org.cn",
|
|
||||||
"co.kr", "or.kr",
|
|
||||||
"com.mx", "com.ar", "com.tr", "com.tw", "com.hk", "com.sg", "com.my",
|
|
||||||
"co.za", "com.pl", "com.ua", "co.il", "com.vn", "com.ph", "co.id",
|
|
||||||
"com.es", "com.pt", "com.gr", "com.ro", "com.ru", "org.ru", "net.ru"
|
|
||||||
)
|
|
||||||
|
|
||||||
fun registrableDomain(host: String?): String? {
|
|
||||||
if (host.isNullOrEmpty()) return null
|
|
||||||
val normalized = host.trimEnd('.').lowercase()
|
|
||||||
if (normalized.isEmpty()) return null
|
|
||||||
if (normalized.indexOf(':') >= 0) return normalized
|
|
||||||
|
|
||||||
val labels = normalized.split('.')
|
|
||||||
if (labels.size <= 2) return normalized
|
|
||||||
if (labels.all { label -> label.isNotEmpty() && label.all { it in '0'..'9' } }) return normalized
|
|
||||||
|
|
||||||
val lastTwo = labels[labels.size - 2] + "." + labels[labels.size - 1]
|
|
||||||
if (lastTwo in MULTI_LABEL_SUFFIXES && labels.size >= 3) {
|
|
||||||
return labels[labels.size - 3] + "." + lastTwo
|
|
||||||
}
|
|
||||||
return lastTwo
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isThirdParty(requestHost: String?, documentHost: String?): Boolean {
|
|
||||||
if (requestHost.isNullOrEmpty() || documentHost.isNullOrEmpty()) return false
|
|
||||||
val request = registrableDomain(requestHost) ?: return false
|
|
||||||
val document = registrableDomain(documentHost) ?: return false
|
|
||||||
return !request.equals(document, ignoreCase = true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True when [host] is [domain] itself or a subdomain of it. */
|
|
||||||
fun hostMatchesDomain(host: String, domain: String): Boolean {
|
|
||||||
if (host == domain) return true
|
|
||||||
return host.length > domain.length &&
|
|
||||||
host.endsWith(domain) &&
|
|
||||||
host[host.length - domain.length - 1] == '.'
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Emits `a.b.example.com`, `b.example.com`, `example.com`, `com`. */
|
|
||||||
inline fun forEachHostSuffix(host: String, action: (String) -> Unit) {
|
|
||||||
var index = 0
|
|
||||||
while (true) {
|
|
||||||
action(if (index == 0) host else host.substring(index))
|
|
||||||
val dot = host.indexOf('.', index)
|
|
||||||
if (dot < 0) return
|
|
||||||
index = dot + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,336 +0,0 @@
|
|||||||
package app.seb3thehacker.gearslip.browser.adblock
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.webkit.JavascriptInterface
|
|
||||||
import android.webkit.WebView
|
|
||||||
import androidx.webkit.WebViewCompat
|
|
||||||
import androidx.webkit.WebViewFeature
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies element-hiding rules to a page.
|
|
||||||
*
|
|
||||||
* Which rules apply depends on the page's hostname and on what is actually in its DOM, neither
|
|
||||||
* of which is known until the document starts loading, so the page fetches its own stylesheet
|
|
||||||
* through a JavaScript bridge. The bridge exposes two pure functions that return CSS: they read
|
|
||||||
* no page state and perform no action, so handing them to untrusted page content costs nothing.
|
|
||||||
*
|
|
||||||
* Hiding happens in two passes. At document start the page gets the rules written for its own
|
|
||||||
* host, which must land before anything renders — injecting on page finish would let ads appear
|
|
||||||
* and then vanish, which is worse than not hiding them. The generic rules follow once the DOM
|
|
||||||
* exists, keyed on the classes and ids actually present, because shipping all of them up front
|
|
||||||
* would mean roughly 400 KB of CSS per page.
|
|
||||||
*/
|
|
||||||
object CosmeticFilterBridge {
|
|
||||||
|
|
||||||
const val BRIDGE_OBJECT_NAME = "GearslipContentBlocker"
|
|
||||||
|
|
||||||
/** Stop watching for new elements after this long, to spare the CPU on a head unit. */
|
|
||||||
private const val OBSERVE_DURATION_MS = 30000
|
|
||||||
|
|
||||||
/** Upper bound on how many new class/id names are sent to the bridge in one pass. */
|
|
||||||
private const val MAX_TOKENS_PER_PASS = 4000
|
|
||||||
|
|
||||||
/** True when document-start injection is unavailable and the client must inject later. */
|
|
||||||
val needsPageStartFallback: Boolean
|
|
||||||
get() = !WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)
|
|
||||||
|
|
||||||
val INJECTION_JS: String = """
|
|
||||||
(function () {
|
|
||||||
try {
|
|
||||||
if (window.__gearslipCosmetic) { return; }
|
|
||||||
var bridge = window.$BRIDGE_OBJECT_NAME;
|
|
||||||
if (!bridge || typeof bridge.cssForHost !== 'function') { return; }
|
|
||||||
|
|
||||||
var host = location.hostname;
|
|
||||||
var seenClass = Object.create(null);
|
|
||||||
var seenId = Object.create(null);
|
|
||||||
window.__gearslipCosmetic = true;
|
|
||||||
|
|
||||||
function addStyle(css) {
|
|
||||||
if (!css) { return; }
|
|
||||||
var style = document.createElement('style');
|
|
||||||
style.setAttribute('data-gearslip', 'content-blocker');
|
|
||||||
style.textContent = css;
|
|
||||||
var root = document.head || document.documentElement;
|
|
||||||
if (root) { root.appendChild(style); }
|
|
||||||
}
|
|
||||||
|
|
||||||
// The engine loads from disk in the background and may not be ready when the
|
|
||||||
// first page starts. Keep asking until it answers, so a page that loses the race
|
|
||||||
// still gets its hiding rules rather than none at all.
|
|
||||||
var hostCssApplied = false;
|
|
||||||
function applyHostCss() {
|
|
||||||
if (hostCssApplied) { return true; }
|
|
||||||
var css = bridge.cssForHost(host);
|
|
||||||
if (!css) { return false; }
|
|
||||||
hostCssApplied = true;
|
|
||||||
addStyle(css);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
applyHostCss();
|
|
||||||
var retries = [250, 750, 1500, 3000, 5000, 8000, 12000];
|
|
||||||
for (var r = 0; r < retries.length; r++) {
|
|
||||||
setTimeout(function () {
|
|
||||||
try {
|
|
||||||
if (!hostCssApplied && applyHostCss()) { collect(); }
|
|
||||||
} catch (e) {}
|
|
||||||
}, retries[r]);
|
|
||||||
}
|
|
||||||
|
|
||||||
var classes = [];
|
|
||||||
var ids = [];
|
|
||||||
|
|
||||||
function scan(root, budget) {
|
|
||||||
if (!root || root.nodeType !== 1) { return budget; }
|
|
||||||
var elements = root.getElementsByTagName('*');
|
|
||||||
var total = elements.length + 1;
|
|
||||||
for (var i = 0; i < total && budget > 0; i++) {
|
|
||||||
var element = (i === 0) ? root : elements[i - 1];
|
|
||||||
var id = element.id;
|
|
||||||
if (id && !seenId[id]) { seenId[id] = true; ids.push(id); budget--; }
|
|
||||||
// SVG elements expose className as an SVGAnimatedString, not a string.
|
|
||||||
var names = element.className;
|
|
||||||
if (names && typeof names === 'string') {
|
|
||||||
var parts = names.split(/\s+/);
|
|
||||||
for (var j = 0; j < parts.length && budget > 0; j++) {
|
|
||||||
var name = parts[j];
|
|
||||||
if (name && !seenClass[name]) {
|
|
||||||
seenClass[name] = true;
|
|
||||||
classes.push(name);
|
|
||||||
budget--;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return budget;
|
|
||||||
}
|
|
||||||
|
|
||||||
function collect() {
|
|
||||||
classes.length = 0;
|
|
||||||
ids.length = 0;
|
|
||||||
var budget = $MAX_TOKENS_PER_PASS;
|
|
||||||
if (!scannedOnce) {
|
|
||||||
scannedOnce = true;
|
|
||||||
budget = scan(document.documentElement, budget);
|
|
||||||
} else {
|
|
||||||
// Only look at what the page actually added. Re-walking the whole document
|
|
||||||
// on every mutation costs far more than it finds: on a large single-page app
|
|
||||||
// it means a full DOM traversal every 50ms while the page is starting up.
|
|
||||||
for (var i = 0; i < pending.length && budget > 0; i++) {
|
|
||||||
budget = scan(pending[i], budget);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pending.length = 0;
|
|
||||||
if (classes.length || ids.length) {
|
|
||||||
addStyle(bridge.cssForTokens(host, classes.join(' '), ids.join(' ')));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var scannedOnce = false;
|
|
||||||
var pending = [];
|
|
||||||
var scheduled = false;
|
|
||||||
function schedule() {
|
|
||||||
if (scheduled) { return; }
|
|
||||||
scheduled = true;
|
|
||||||
setTimeout(function () {
|
|
||||||
scheduled = false;
|
|
||||||
try { collect(); } catch (e) {}
|
|
||||||
}, 50);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', schedule);
|
|
||||||
} else {
|
|
||||||
schedule();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof MutationObserver === 'function' && document.documentElement) {
|
|
||||||
var observer = new MutationObserver(function (records) {
|
|
||||||
for (var i = 0; i < records.length; i++) {
|
|
||||||
var added = records[i].addedNodes;
|
|
||||||
for (var j = 0; j < added.length; j++) {
|
|
||||||
if (added[j].nodeType === 1) { pending.push(added[j]); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// A burst larger than this is a page rebuilding itself; the tail is enough.
|
|
||||||
if (pending.length > 300) { pending.splice(0, pending.length - 300); }
|
|
||||||
if (pending.length) { schedule(); }
|
|
||||||
});
|
|
||||||
observer.observe(document.documentElement, { childList: true, subtree: true });
|
|
||||||
setTimeout(function () {
|
|
||||||
try { observer.disconnect(); } catch (e) {}
|
|
||||||
}, $OBSERVE_DURATION_MS);
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
})();
|
|
||||||
(function () {
|
|
||||||
// Hides a site's own fixed bottom nav bar, which duplicates navigation the app
|
|
||||||
// already provides and costs a sixth of the screen on a ~480px-tall car display.
|
|
||||||
// Found by measuring rather than by a list of known selectors, so it works on any
|
|
||||||
// site. Toggled from the drawer.
|
|
||||||
//
|
|
||||||
// Hiding rather than repositioning is deliberate: an earlier version moved these
|
|
||||||
// bars instead, which fought sites that animate the bar's own position (Twitch
|
|
||||||
// slides its nav bar on scroll) and left it stranded mid-page. display:none has
|
|
||||||
// nothing to fight.
|
|
||||||
try {
|
|
||||||
if (window.__gearslipFooterHiding) { return; }
|
|
||||||
window.__gearslipFooterHiding = true;
|
|
||||||
var bridge = window.$BRIDGE_OBJECT_NAME;
|
|
||||||
if (!bridge || typeof bridge.hideSiteFootersEnabled !== 'function') { return; }
|
|
||||||
if (!bridge.hideSiteFootersEnabled()) { return; }
|
|
||||||
|
|
||||||
var MAX_BAR_HEIGHT = 140;
|
|
||||||
|
|
||||||
function hideBottomBars(root, depth) {
|
|
||||||
if (!root || depth < 0 || !root.children) { return; }
|
|
||||||
for (var i = 0; i < root.children.length; i++) {
|
|
||||||
var el = root.children[i];
|
|
||||||
if (el.__gearslipFooterHidden) { continue; }
|
|
||||||
var cs;
|
|
||||||
try { cs = getComputedStyle(el); } catch (e) { continue; }
|
|
||||||
if (cs.display === 'none') { continue; }
|
|
||||||
if (cs.position === 'fixed' || cs.position === 'sticky') {
|
|
||||||
var rect;
|
|
||||||
try { rect = el.getBoundingClientRect(); } catch (e) { rect = null; }
|
|
||||||
if (rect && rect.width >= window.innerWidth * 0.6 &&
|
|
||||||
rect.height > 0 && rect.height <= MAX_BAR_HEIGHT &&
|
|
||||||
Math.abs(window.innerHeight - rect.bottom) < 6) {
|
|
||||||
el.__gearslipFooterHidden = true;
|
|
||||||
el.style.setProperty('display', 'none', 'important');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
hideBottomBars(el, depth - 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function run() {
|
|
||||||
try {
|
|
||||||
hideBottomBars(document.body, 3);
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', run);
|
|
||||||
} else {
|
|
||||||
run();
|
|
||||||
}
|
|
||||||
// Single-page apps mount their nav bar well after first paint, and swap it out
|
|
||||||
// again on navigation, so one pass at load isn't enough.
|
|
||||||
var passes = 0;
|
|
||||||
var timer = setInterval(function () {
|
|
||||||
run();
|
|
||||||
if (++passes > 40) { clearInterval(timer); }
|
|
||||||
}, 500);
|
|
||||||
} catch (e) {}
|
|
||||||
})();
|
|
||||||
(function () {
|
|
||||||
// ":has-text(pattern)" cosmetic rules: hides elements matching a base selector whose
|
|
||||||
// own text also matches a pattern. There is no CSS equivalent for the text half, so
|
|
||||||
// this runs the check in JavaScript instead of the plain <style> the rest of the
|
|
||||||
// cosmetic filters use.
|
|
||||||
try {
|
|
||||||
if (window.__gearslipHasText) { return; }
|
|
||||||
var bridge = window.$BRIDGE_OBJECT_NAME;
|
|
||||||
if (!bridge || typeof bridge.hasTextRulesForHost !== 'function') { return; }
|
|
||||||
|
|
||||||
var rules;
|
|
||||||
try {
|
|
||||||
rules = JSON.parse(bridge.hasTextRulesForHost(location.hostname) || '[]');
|
|
||||||
} catch (e) {
|
|
||||||
rules = [];
|
|
||||||
}
|
|
||||||
if (!rules.length) { return; }
|
|
||||||
window.__gearslipHasText = true;
|
|
||||||
|
|
||||||
var compiled = [];
|
|
||||||
for (var i = 0; i < rules.length; i++) {
|
|
||||||
try {
|
|
||||||
compiled.push({ selector: rules[i][0], regex: new RegExp(rules[i][1], 'i') });
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
if (!compiled.length) { return; }
|
|
||||||
|
|
||||||
function run() {
|
|
||||||
for (var i = 0; i < compiled.length; i++) {
|
|
||||||
var rule = compiled[i];
|
|
||||||
var elements;
|
|
||||||
try { elements = document.querySelectorAll(rule.selector); } catch (e) { continue; }
|
|
||||||
for (var j = 0; j < elements.length; j++) {
|
|
||||||
var el = elements[j];
|
|
||||||
if (el.__gearslipHasTextHidden) { continue; }
|
|
||||||
var text = el.textContent;
|
|
||||||
if (text && rule.regex.test(text)) {
|
|
||||||
el.__gearslipHasTextHidden = true;
|
|
||||||
el.style.setProperty('display', 'none', 'important');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', run);
|
|
||||||
} else {
|
|
||||||
run();
|
|
||||||
}
|
|
||||||
// Matches uBlock Origin's own behaviour for this rule type: it re-evaluates on a
|
|
||||||
// timer rather than per-mutation, since a text match can only be judged once an
|
|
||||||
// element's subtree has actually finished changing.
|
|
||||||
var passes = 0;
|
|
||||||
var timer = setInterval(function () {
|
|
||||||
run();
|
|
||||||
if (++passes > 40) { clearInterval(timer); }
|
|
||||||
}, 500);
|
|
||||||
} catch (e) {}
|
|
||||||
})();
|
|
||||||
""".trimIndent()
|
|
||||||
|
|
||||||
fun attach(webView: WebView) {
|
|
||||||
webView.addJavascriptInterface(Bridge(webView.context.applicationContext), BRIDGE_OBJECT_NAME)
|
|
||||||
if (!needsPageStartFallback) {
|
|
||||||
runCatching {
|
|
||||||
WebViewCompat.addDocumentStartJavaScript(webView, INJECTION_JS, setOf("*"))
|
|
||||||
}
|
|
||||||
runCatching {
|
|
||||||
WebViewCompat.addDocumentStartJavaScript(
|
|
||||||
webView,
|
|
||||||
YouTubeAdBlock.INJECTION_JS,
|
|
||||||
setOf("*")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class Bridge(private val context: Context) {
|
|
||||||
@JavascriptInterface
|
|
||||||
fun cssForHost(host: String?): String = runCatching {
|
|
||||||
AdBlockManager.cssForHost(host)
|
|
||||||
}.getOrDefault("")
|
|
||||||
|
|
||||||
@JavascriptInterface
|
|
||||||
fun youtubeBlockingEnabled(host: String?): Boolean = runCatching {
|
|
||||||
AdBlockManager.isYouTubeBlockingEnabled(host)
|
|
||||||
}.getOrDefault(false)
|
|
||||||
|
|
||||||
@JavascriptInterface
|
|
||||||
fun customSiteTweaksEnabled(): Boolean = runCatching {
|
|
||||||
AdBlockManager.isCustomSiteTweaksEnabled()
|
|
||||||
}.getOrDefault(false)
|
|
||||||
|
|
||||||
@JavascriptInterface
|
|
||||||
fun hideSiteFootersEnabled(): Boolean = runCatching {
|
|
||||||
AdBlockManager.isHideSiteFootersEnabled()
|
|
||||||
}.getOrDefault(false)
|
|
||||||
|
|
||||||
@JavascriptInterface
|
|
||||||
fun hasTextRulesForHost(host: String?): String = runCatching {
|
|
||||||
AdBlockManager.hasTextRulesForHost(host)
|
|
||||||
}.getOrDefault("[]")
|
|
||||||
|
|
||||||
@JavascriptInterface
|
|
||||||
fun cssForTokens(host: String?, classNames: String?, ids: String?): String = runCatching {
|
|
||||||
AdBlockManager.cssForTokens(host, classNames.orEmpty(), ids.orEmpty())
|
|
||||||
}.getOrDefault("")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,610 +0,0 @@
|
|||||||
package app.seb3thehacker.gearslip.browser.adblock
|
|
||||||
|
|
||||||
import android.util.LruCache
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An immutable, queryable index over a set of parsed filter rules.
|
|
||||||
*
|
|
||||||
* Network rules are bucketed so that a request only ever tests a handful of them:
|
|
||||||
* - `||host^` rules are indexed by host and found by walking the request host's suffixes;
|
|
||||||
* - every other rule is indexed under one literal token from its pattern, and a request only
|
|
||||||
* tests rules whose token appears in the URL;
|
|
||||||
* - the few rules with no usable token are tested on every request.
|
|
||||||
*
|
|
||||||
* Element-hiding rules are split the same way. Nearly all generic hiding rules are anchored on
|
|
||||||
* a single class or id (`.ad-banner`, `#sponsored`), so they are indexed by that name and only
|
|
||||||
* handed to a page once that class or id actually appears in its DOM. Injecting all of them
|
|
||||||
* unconditionally would mean roughly 400 KB of CSS on every page load.
|
|
||||||
*/
|
|
||||||
internal class FilterEngine private constructor(
|
|
||||||
private val blockRules: RuleSet,
|
|
||||||
private val allowRules: RuleSet,
|
|
||||||
private val genericComplexSelectors: List<String>,
|
|
||||||
private val genericByClass: HashMap<String, MutableList<String>>,
|
|
||||||
private val genericById: HashMap<String, MutableList<String>>,
|
|
||||||
private val specificSelectors: HashMap<String, MutableList<String>>,
|
|
||||||
private val cosmeticExceptions: HashMap<String, MutableList<String>>,
|
|
||||||
private val genericHasText: List<Pair<String, String>>,
|
|
||||||
private val specificHasText: HashMap<String, MutableList<Pair<String, String>>>,
|
|
||||||
val networkRuleCount: Int,
|
|
||||||
val cosmeticRuleCount: Int
|
|
||||||
) {
|
|
||||||
|
|
||||||
private val hostCssCache = LruCache<String, String>(32)
|
|
||||||
|
|
||||||
/** True when the request should be blocked at all, regardless of which rule matched. */
|
|
||||||
fun shouldBlock(
|
|
||||||
url: String,
|
|
||||||
requestHost: String?,
|
|
||||||
documentHost: String?,
|
|
||||||
type: Int,
|
|
||||||
isThirdParty: Boolean
|
|
||||||
): Boolean = matchBlockingRule(url, requestHost, documentHost, type, isThirdParty) != null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The block match for this request, or null when it is not blocked. Split out from
|
|
||||||
* [shouldBlock] so the caller can also read the matched rule's `$redirect=` resource name
|
|
||||||
* without a second, separate walk over the same rule sets. A plain `||host^` match (the
|
|
||||||
* common case) carries no [NetworkRule] object at all, hence [BlockMatch] rather than
|
|
||||||
* returning the rule directly.
|
|
||||||
*/
|
|
||||||
fun matchBlockingRule(
|
|
||||||
url: String,
|
|
||||||
requestHost: String?,
|
|
||||||
documentHost: String?,
|
|
||||||
type: Int,
|
|
||||||
isThirdParty: Boolean
|
|
||||||
): BlockMatch? {
|
|
||||||
val lowerUrl = url.lowercase()
|
|
||||||
val hostStart = hostStart(lowerUrl)
|
|
||||||
val hostEnd = hostEnd(lowerUrl, hostStart)
|
|
||||||
val tokens = ArrayList<String>(16)
|
|
||||||
tokenize(lowerUrl, tokens)
|
|
||||||
|
|
||||||
val blockingMatch = matchingRule(blockRules, lowerUrl, hostStart, hostEnd, requestHost, documentHost, type, isThirdParty, tokens)
|
|
||||||
?: return null
|
|
||||||
if (matchesAny(allowRules, lowerUrl, hostStart, hostEnd, requestHost, documentHost, type, isThirdParty, tokens)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return blockingMatch
|
|
||||||
}
|
|
||||||
|
|
||||||
class BlockMatch(val redirectResource: String?) {
|
|
||||||
companion object {
|
|
||||||
val PLAIN = BlockMatch(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The stylesheet injected at document start: rules written for this site, plus the handful
|
|
||||||
* of generic rules that are not anchored on a class or id.
|
|
||||||
*/
|
|
||||||
fun cssForHost(host: String?): String {
|
|
||||||
val normalized = host?.lowercase().orEmpty()
|
|
||||||
hostCssCache.get(normalized)?.let { return it }
|
|
||||||
|
|
||||||
val exceptions = exceptionsFor(normalized)
|
|
||||||
val selectors = ArrayList<String>(genericComplexSelectors.size + 32)
|
|
||||||
if (normalized.isNotEmpty()) {
|
|
||||||
DomainUtils.forEachHostSuffix(normalized) { suffix ->
|
|
||||||
specificSelectors[suffix]?.let { selectors.addAll(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
selectors.addAll(genericComplexSelectors)
|
|
||||||
|
|
||||||
val css = buildHidingCss(selectors, exceptions)
|
|
||||||
hostCssCache.put(normalized, css)
|
|
||||||
return css
|
|
||||||
}
|
|
||||||
|
|
||||||
private val hasTextCache = LruCache<String, List<Pair<String, String>>>(32)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The `:has-text()` rules (base selector, JS regex source) that apply to [host]. Delivered
|
|
||||||
* separately from [cssForHost] because there is no CSS equivalent for a text match -- the
|
|
||||||
* caller runs these with JavaScript instead of a `<style>` element.
|
|
||||||
*/
|
|
||||||
fun hasTextRulesForHost(host: String?): List<Pair<String, String>> {
|
|
||||||
val normalized = host?.lowercase().orEmpty()
|
|
||||||
hasTextCache.get(normalized)?.let { return it }
|
|
||||||
|
|
||||||
val rules = ArrayList<Pair<String, String>>(genericHasText.size + 4)
|
|
||||||
if (normalized.isNotEmpty()) {
|
|
||||||
DomainUtils.forEachHostSuffix(normalized) { suffix ->
|
|
||||||
specificHasText[suffix]?.let { rules.addAll(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rules.addAll(genericHasText)
|
|
||||||
|
|
||||||
hasTextCache.put(normalized, rules)
|
|
||||||
return rules
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The stylesheet for generic rules whose class or id has turned up in the page. [classNames]
|
|
||||||
* and [ids] are whitespace-separated, as collected from the live DOM.
|
|
||||||
*/
|
|
||||||
fun cssForTokens(host: String?, classNames: String, ids: String): String {
|
|
||||||
val normalized = host?.lowercase().orEmpty()
|
|
||||||
val exceptions = exceptionsFor(normalized)
|
|
||||||
val selectors = ArrayList<String>()
|
|
||||||
|
|
||||||
collectByToken(classNames, genericByClass, selectors)
|
|
||||||
collectByToken(ids, genericById, selectors)
|
|
||||||
if (selectors.isEmpty()) return ""
|
|
||||||
|
|
||||||
return buildHidingCss(selectors, exceptions)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun collectByToken(
|
|
||||||
tokens: String,
|
|
||||||
index: HashMap<String, MutableList<String>>,
|
|
||||||
out: MutableList<String>
|
|
||||||
) {
|
|
||||||
if (tokens.isEmpty() || index.isEmpty()) return
|
|
||||||
var start = 0
|
|
||||||
val length = tokens.length
|
|
||||||
while (start < length) {
|
|
||||||
while (start < length && tokens[start].isWhitespace()) start++
|
|
||||||
if (start >= length) break
|
|
||||||
var end = start
|
|
||||||
while (end < length && !tokens[end].isWhitespace()) end++
|
|
||||||
index[tokens.substring(start, end)]?.let { out.addAll(it) }
|
|
||||||
start = end
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun exceptionsFor(host: String): Set<String> {
|
|
||||||
if (host.isEmpty() || cosmeticExceptions.isEmpty()) return emptySet()
|
|
||||||
var result: MutableSet<String>? = null
|
|
||||||
DomainUtils.forEachHostSuffix(host) { suffix ->
|
|
||||||
cosmeticExceptions[suffix]?.let { exceptions ->
|
|
||||||
val target = result ?: HashSet<String>().also { result = it }
|
|
||||||
target.addAll(exceptions)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result ?: emptySet()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildHidingCss(selectors: List<String>, exceptions: Set<String>): String {
|
|
||||||
val usable = if (exceptions.isEmpty()) {
|
|
||||||
selectors
|
|
||||||
} else {
|
|
||||||
selectors.filterNot { it in exceptions }
|
|
||||||
}
|
|
||||||
if (usable.isEmpty()) return ""
|
|
||||||
|
|
||||||
val builder = StringBuilder(usable.size * 24)
|
|
||||||
// One rule per chunk rather than one rule for everything: a browser drops an entire
|
|
||||||
// rule if any selector in it fails to parse, so a chunk limits that blast radius.
|
|
||||||
for (chunk in usable.chunked(SELECTORS_PER_RULE)) {
|
|
||||||
builder.append(chunk.joinToString(","))
|
|
||||||
builder.append("{display:none!important}")
|
|
||||||
}
|
|
||||||
return builder.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun matchesAny(
|
|
||||||
ruleSet: RuleSet,
|
|
||||||
url: String,
|
|
||||||
hostStart: Int,
|
|
||||||
hostEnd: Int,
|
|
||||||
requestHost: String?,
|
|
||||||
documentHost: String?,
|
|
||||||
type: Int,
|
|
||||||
isThirdParty: Boolean,
|
|
||||||
tokens: List<String>
|
|
||||||
): Boolean {
|
|
||||||
if (!requestHost.isNullOrEmpty()) {
|
|
||||||
var found = false
|
|
||||||
DomainUtils.forEachHostSuffix(requestHost) { suffix ->
|
|
||||||
if (!found) {
|
|
||||||
if (suffix in ruleSet.plainHosts) {
|
|
||||||
found = true
|
|
||||||
} else {
|
|
||||||
val candidates = ruleSet.hostIndex[suffix]
|
|
||||||
if (candidates != null) {
|
|
||||||
for (rule in candidates) {
|
|
||||||
if (optionsMatch(rule, type, isThirdParty, documentHost)) {
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (found) return true
|
|
||||||
}
|
|
||||||
|
|
||||||
for (token in tokens) {
|
|
||||||
val candidates = ruleSet.tokenIndex[token] ?: continue
|
|
||||||
for (rule in candidates) {
|
|
||||||
if (optionsMatch(rule, type, isThirdParty, documentHost) &&
|
|
||||||
matchesUrl(rule, url, hostStart, hostEnd)
|
|
||||||
) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (rule in ruleSet.catchAll) {
|
|
||||||
if (optionsMatch(rule, type, isThirdParty, documentHost) &&
|
|
||||||
matchesUrl(rule, url, hostStart, hostEnd)
|
|
||||||
) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Same walk as [matchesAny], but returns the match itself instead of a plain boolean. */
|
|
||||||
private fun matchingRule(
|
|
||||||
ruleSet: RuleSet,
|
|
||||||
url: String,
|
|
||||||
hostStart: Int,
|
|
||||||
hostEnd: Int,
|
|
||||||
requestHost: String?,
|
|
||||||
documentHost: String?,
|
|
||||||
type: Int,
|
|
||||||
isThirdParty: Boolean,
|
|
||||||
tokens: List<String>
|
|
||||||
): BlockMatch? {
|
|
||||||
if (!requestHost.isNullOrEmpty()) {
|
|
||||||
var result: BlockMatch? = null
|
|
||||||
DomainUtils.forEachHostSuffix(requestHost) { suffix ->
|
|
||||||
if (result == null) {
|
|
||||||
if (suffix in ruleSet.plainHosts) {
|
|
||||||
result = BlockMatch.PLAIN
|
|
||||||
} else {
|
|
||||||
val candidates = ruleSet.hostIndex[suffix]
|
|
||||||
if (candidates != null) {
|
|
||||||
for (rule in candidates) {
|
|
||||||
if (optionsMatch(rule, type, isThirdParty, documentHost)) {
|
|
||||||
result = BlockMatch(rule.redirectResource)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (result != null) return result
|
|
||||||
}
|
|
||||||
|
|
||||||
for (token in tokens) {
|
|
||||||
val candidates = ruleSet.tokenIndex[token] ?: continue
|
|
||||||
for (rule in candidates) {
|
|
||||||
if (optionsMatch(rule, type, isThirdParty, documentHost) &&
|
|
||||||
matchesUrl(rule, url, hostStart, hostEnd)
|
|
||||||
) {
|
|
||||||
return BlockMatch(rule.redirectResource)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (rule in ruleSet.catchAll) {
|
|
||||||
if (optionsMatch(rule, type, isThirdParty, documentHost) &&
|
|
||||||
matchesUrl(rule, url, hostStart, hostEnd)
|
|
||||||
) {
|
|
||||||
return BlockMatch(rule.redirectResource)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun optionsMatch(
|
|
||||||
rule: NetworkRule,
|
|
||||||
type: Int,
|
|
||||||
isThirdParty: Boolean,
|
|
||||||
documentHost: String?
|
|
||||||
): Boolean {
|
|
||||||
if (rule.typeMask != 0 && (rule.typeMask and type) == 0) return false
|
|
||||||
if (rule.excludedTypeMask != 0 && (rule.excludedTypeMask and type) != 0) return false
|
|
||||||
|
|
||||||
val thirdParty = rule.thirdParty
|
|
||||||
if (thirdParty != null && thirdParty != isThirdParty) return false
|
|
||||||
|
|
||||||
val include = rule.includeDomains
|
|
||||||
val exclude = rule.excludeDomains
|
|
||||||
if (include != null || exclude != null) {
|
|
||||||
val host = documentHost ?: return false
|
|
||||||
if (exclude != null && exclude.any { DomainUtils.hostMatchesDomain(host, it) }) return false
|
|
||||||
if (include != null && !include.any { DomainUtils.hostMatchesDomain(host, it) }) return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun matchesUrl(rule: NetworkRule, url: String, hostStart: Int, hostEnd: Int): Boolean {
|
|
||||||
if (rule.anchorDomain) {
|
|
||||||
if (matchFrom(url, hostStart, rule)) return true
|
|
||||||
var index = hostStart
|
|
||||||
while (index < hostEnd) {
|
|
||||||
if (url[index] == '.' && matchFrom(url, index + 1, rule)) return true
|
|
||||||
index++
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (rule.anchorStart) return matchFrom(url, 0, rule)
|
|
||||||
|
|
||||||
val first = rule.segments[0]
|
|
||||||
if (first.isNotEmpty() && first.indexOf('^') < 0) {
|
|
||||||
var index = url.indexOf(first)
|
|
||||||
while (index >= 0) {
|
|
||||||
if (matchFrom(url, index, rule)) return true
|
|
||||||
index = url.indexOf(first, index + 1)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
var index = 0
|
|
||||||
while (index <= url.length) {
|
|
||||||
if (matchFrom(url, index, rule)) return true
|
|
||||||
index++
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun matchFrom(url: String, start: Int, rule: NetworkRule): Boolean {
|
|
||||||
val segments = rule.segments
|
|
||||||
var position = matchSegmentAt(url, start, segments[0])
|
|
||||||
if (position < 0) return false
|
|
||||||
|
|
||||||
for (i in 1 until segments.size) {
|
|
||||||
val segment = segments[i]
|
|
||||||
if (segment.isEmpty()) continue
|
|
||||||
var probe = position
|
|
||||||
var matchedEnd = -1
|
|
||||||
while (probe <= url.length) {
|
|
||||||
val end = matchSegmentAt(url, probe, segment)
|
|
||||||
if (end >= 0) {
|
|
||||||
matchedEnd = end
|
|
||||||
break
|
|
||||||
}
|
|
||||||
probe++
|
|
||||||
}
|
|
||||||
if (matchedEnd < 0) return false
|
|
||||||
position = matchedEnd
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rule.anchorEnd && segments[segments.size - 1].isNotEmpty() && position != url.length) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns the index just past the match, or -1. `^` matches one separator or end-of-URL. */
|
|
||||||
private fun matchSegmentAt(url: String, start: Int, segment: String): Int {
|
|
||||||
var position = start
|
|
||||||
for (i in segment.indices) {
|
|
||||||
val expected = segment[i]
|
|
||||||
if (expected == '^') {
|
|
||||||
if (position >= url.length) {
|
|
||||||
// A separator may stand for the end of the address, but only as the last
|
|
||||||
// character of the segment.
|
|
||||||
return if (i == segment.length - 1) position else -1
|
|
||||||
}
|
|
||||||
if (!isSeparator(url[position])) return -1
|
|
||||||
position++
|
|
||||||
} else {
|
|
||||||
if (position >= url.length || url[position] != expected) return -1
|
|
||||||
position++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return position
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val MIN_TOKEN_LENGTH = 3
|
|
||||||
private const val SELECTORS_PER_RULE = 100
|
|
||||||
|
|
||||||
private fun isSeparator(c: Char): Boolean = when {
|
|
||||||
c in 'a'..'z' -> false
|
|
||||||
c in 'A'..'Z' -> false
|
|
||||||
c in '0'..'9' -> false
|
|
||||||
c == '_' || c == '-' || c == '.' || c == '%' -> false
|
|
||||||
else -> true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun hostStart(url: String): Int {
|
|
||||||
val scheme = url.indexOf("://")
|
|
||||||
return if (scheme < 0) 0 else scheme + 3
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun hostEnd(url: String, hostStart: Int): Int {
|
|
||||||
var index = hostStart
|
|
||||||
while (index < url.length) {
|
|
||||||
val c = url[index]
|
|
||||||
if (c == '/' || c == '?' || c == '#' || c == ':') return index
|
|
||||||
index++
|
|
||||||
}
|
|
||||||
return url.length
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun tokenize(text: String, out: MutableList<String>) {
|
|
||||||
var index = 0
|
|
||||||
val length = text.length
|
|
||||||
while (index < length) {
|
|
||||||
val c = text[index]
|
|
||||||
if (c.isLetterOrDigit()) {
|
|
||||||
var end = index + 1
|
|
||||||
while (end < length && text[end].isLetterOrDigit()) end++
|
|
||||||
if (end - index >= MIN_TOKEN_LENGTH) out.add(text.substring(index, end))
|
|
||||||
index = end
|
|
||||||
} else {
|
|
||||||
index++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The longest literal token in the pattern, used as the rule's index key. */
|
|
||||||
private fun bestToken(rule: NetworkRule): String? {
|
|
||||||
var best: String? = null
|
|
||||||
val tokens = ArrayList<String>(4)
|
|
||||||
for (segment in rule.segments) {
|
|
||||||
tokens.clear()
|
|
||||||
tokenize(segment, tokens)
|
|
||||||
for (token in tokens) {
|
|
||||||
val current = best
|
|
||||||
if (current == null || token.length > current.length) best = token
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return best
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The leading class or id a selector is anchored on, as (`.` or `#`) to name, or null
|
|
||||||
* when the selector does not start with a plain class or id.
|
|
||||||
*/
|
|
||||||
private fun anchorToken(selector: String): Pair<Char, String>? {
|
|
||||||
if (selector.length < 2) return null
|
|
||||||
val kind = selector[0]
|
|
||||||
if (kind != '.' && kind != '#') return null
|
|
||||||
var end = 1
|
|
||||||
while (end < selector.length) {
|
|
||||||
val c = selector[end]
|
|
||||||
if (c.isLetterOrDigit() || c == '-' || c == '_') end++ else break
|
|
||||||
}
|
|
||||||
if (end == 1) return null
|
|
||||||
return kind to selector.substring(1, end)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun build(lines: Sequence<String>): FilterEngine {
|
|
||||||
val builder = Builder()
|
|
||||||
for (line in lines) builder.addLine(line)
|
|
||||||
return builder.finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Accumulates rules one line at a time, fed straight from a reader so that the whole list
|
|
||||||
* never exists in memory as a collection of lines. Parsing EasyList and EasyPrivacy
|
|
||||||
* together takes roughly 250ms on a Pixel 6 in a release build. Be wary of profiling this
|
|
||||||
* in a debug build: `android:debuggable` disables ART's inlining and made the same work
|
|
||||||
* take over nine seconds, which looks like an algorithmic problem but is not one.
|
|
||||||
*/
|
|
||||||
internal class Builder {
|
|
||||||
private val blockRules = RuleSet()
|
|
||||||
private val allowRules = RuleSet()
|
|
||||||
private val genericComplex = LinkedHashSet<String>()
|
|
||||||
// Pre-sized: EasyList and EasyPrivacy together fill these to roughly these counts, and
|
|
||||||
// rehashing a 90k-entry map on the way there is pure waste.
|
|
||||||
private val genericByClass = HashMap<String, MutableList<String>>(16384)
|
|
||||||
private val genericById = HashMap<String, MutableList<String>>(8192)
|
|
||||||
private val specificSelectors = HashMap<String, MutableList<String>>(16384)
|
|
||||||
private val cosmeticExceptions = HashMap<String, MutableList<String>>(1024)
|
|
||||||
private val genericHasText = mutableListOf<Pair<String, String>>()
|
|
||||||
private val specificHasText = HashMap<String, MutableList<Pair<String, String>>>()
|
|
||||||
private var networkRuleCount = 0
|
|
||||||
private var cosmeticRuleCount = 0
|
|
||||||
|
|
||||||
fun addLine(rawLine: String) {
|
|
||||||
if (rawLine.isEmpty()) return
|
|
||||||
// Only pay for trim() on the rare line that actually has surrounding whitespace.
|
|
||||||
val needsTrim = rawLine[0] == ' ' || rawLine[0] == '\t' ||
|
|
||||||
rawLine[rawLine.length - 1] == ' ' || rawLine[rawLine.length - 1] == '\t'
|
|
||||||
val line = if (needsTrim) rawLine.trim() else rawLine
|
|
||||||
if (line.isEmpty()) return
|
|
||||||
if (line[0] == '!' || line[0] == '[') return
|
|
||||||
|
|
||||||
val separator = FilterParser.cosmeticSeparatorIndex(line)
|
|
||||||
if (separator >= 0) {
|
|
||||||
addCosmeticRule(line, separator)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val isException = line.startsWith("@@")
|
|
||||||
val rule = FilterParser.parseNetworkRule(line) ?: return
|
|
||||||
networkRuleCount++
|
|
||||||
val target = if (isException) allowRules else blockRules
|
|
||||||
|
|
||||||
val hostAnchor = rule.hostAnchor
|
|
||||||
if (hostAnchor != null) {
|
|
||||||
if (rule.hasNoOptions) {
|
|
||||||
target.plainHosts.add(hostAnchor)
|
|
||||||
} else {
|
|
||||||
target.hostIndex.getOrPut(hostAnchor) { mutableListOf() }.add(rule)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val token = bestToken(rule)
|
|
||||||
if (token != null) {
|
|
||||||
target.tokenIndex.getOrPut(token) { mutableListOf() }.add(rule)
|
|
||||||
} else {
|
|
||||||
target.catchAll.add(rule)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun addCosmeticRule(line: String, separator: Int) {
|
|
||||||
val rule = FilterParser.parseCosmeticRule(line, separator) ?: return
|
|
||||||
cosmeticRuleCount++
|
|
||||||
|
|
||||||
// :has-text() has no CSS equivalent, so it is matched by JavaScript from its own
|
|
||||||
// list instead of joining the CSS selector strings above. Exceptions for it are not
|
|
||||||
// supported -- rare enough in EasyList/EasyPrivacy that dropping them outright (by
|
|
||||||
// not indexing this rule) is a safe simplification rather than a real gap.
|
|
||||||
val textPattern = rule.hasTextPattern
|
|
||||||
if (textPattern != null) {
|
|
||||||
if (rule.isException) return
|
|
||||||
val entry = rule.selector to textPattern
|
|
||||||
if (rule.includeDomains != null) {
|
|
||||||
rule.includeDomains.forEach { domain ->
|
|
||||||
specificHasText.getOrPut(domain) { mutableListOf() }.add(entry)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
genericHasText.add(entry)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
when {
|
|
||||||
rule.isException -> {
|
|
||||||
rule.includeDomains?.forEach { domain ->
|
|
||||||
cosmeticExceptions.getOrPut(domain) { mutableListOf() }.add(rule.selector)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rule.includeDomains != null -> {
|
|
||||||
rule.includeDomains.forEach { domain ->
|
|
||||||
specificSelectors.getOrPut(domain) { mutableListOf() }.add(rule.selector)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
val anchor = anchorToken(rule.selector)
|
|
||||||
if (anchor == null) {
|
|
||||||
genericComplex.add(rule.selector)
|
|
||||||
} else {
|
|
||||||
val index = if (anchor.first == '.') genericByClass else genericById
|
|
||||||
index.getOrPut(anchor.second) { mutableListOf() }.add(rule.selector)
|
|
||||||
}
|
|
||||||
// `~example.com##.ad` hides everywhere except the excluded domains.
|
|
||||||
rule.excludeDomains?.forEach { domain ->
|
|
||||||
cosmeticExceptions.getOrPut(domain) { mutableListOf() }.add(rule.selector)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun finish(): FilterEngine = FilterEngine(
|
|
||||||
blockRules = blockRules,
|
|
||||||
allowRules = allowRules,
|
|
||||||
genericComplexSelectors = genericComplex.toList(),
|
|
||||||
genericByClass = genericByClass,
|
|
||||||
genericById = genericById,
|
|
||||||
specificSelectors = specificSelectors,
|
|
||||||
cosmeticExceptions = cosmeticExceptions,
|
|
||||||
genericHasText = genericHasText,
|
|
||||||
specificHasText = specificHasText,
|
|
||||||
networkRuleCount = networkRuleCount,
|
|
||||||
cosmeticRuleCount = cosmeticRuleCount
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal class RuleSet {
|
|
||||||
/** Bare `||host^` blocks with no options: matching the host is the whole test. */
|
|
||||||
val plainHosts = HashSet<String>()
|
|
||||||
val hostIndex = HashMap<String, MutableList<NetworkRule>>()
|
|
||||||
val tokenIndex = HashMap<String, MutableList<NetworkRule>>()
|
|
||||||
val catchAll = ArrayList<NetworkRule>()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,330 +0,0 @@
|
|||||||
package app.seb3thehacker.gearslip.browser.adblock
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A parsed network filter rule (Adblock Plus syntax subset).
|
|
||||||
*
|
|
||||||
* [segments] is the pattern split on `*`; each segment is matched literally except for `^`,
|
|
||||||
* which stands for a single separator character (or the end of the URL).
|
|
||||||
*/
|
|
||||||
internal class NetworkRule(
|
|
||||||
val segments: Array<String>,
|
|
||||||
val anchorDomain: Boolean,
|
|
||||||
val anchorStart: Boolean,
|
|
||||||
val anchorEnd: Boolean,
|
|
||||||
val typeMask: Int,
|
|
||||||
val excludedTypeMask: Int,
|
|
||||||
val thirdParty: Boolean?,
|
|
||||||
val includeDomains: Array<String>?,
|
|
||||||
val excludeDomains: Array<String>?,
|
|
||||||
/** Set when the whole pattern is a bare `||host^`, which lets the engine index it by host. */
|
|
||||||
val hostAnchor: String?,
|
|
||||||
/** Set for `$redirect=name` / `$redirect-rule=name` rules with a resource we can serve. */
|
|
||||||
val redirectResource: String?
|
|
||||||
) {
|
|
||||||
/**
|
|
||||||
* True when the rule carries no options, so matching the host is the whole test. The great
|
|
||||||
* majority of list entries are of this shape and the engine stores them as bare strings.
|
|
||||||
*/
|
|
||||||
val hasNoOptions: Boolean
|
|
||||||
get() = typeMask == 0 && excludedTypeMask == 0 && thirdParty == null &&
|
|
||||||
includeDomains == null && excludeDomains == null && redirectResource == null
|
|
||||||
}
|
|
||||||
|
|
||||||
internal class CosmeticRule(
|
|
||||||
val selector: String,
|
|
||||||
val includeDomains: List<String>?,
|
|
||||||
val excludeDomains: List<String>?,
|
|
||||||
val isException: Boolean,
|
|
||||||
/** Set for a `selector:has-text(pattern)` rule: the text [pattern] to match within
|
|
||||||
* [selector]'s matches, applied by JavaScript rather than as plain CSS. */
|
|
||||||
val hasTextPattern: String? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
internal object FilterParser {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Options that change *how* a request is answered rather than whether it is blocked.
|
|
||||||
* A rule carrying one of these is dropped: honouring only its matching half would block
|
|
||||||
* requests the list author meant to rewrite or merely restrict.
|
|
||||||
*/
|
|
||||||
private val UNSUPPORTED_OPTIONS = hashSetOf(
|
|
||||||
"csp", "removeparam", "rewrite", "replace", "empty", "mp4",
|
|
||||||
"inline-script", "inline-font", "genericblock", "generichide", "specifichide", "elemhide",
|
|
||||||
"badfilter", "denyallow", "method", "header", "to", "from", "ipaddress", "permissions",
|
|
||||||
"uritransform", "urlskip", "urltransform", "strict1p", "strict3p", "popup", "popunder",
|
|
||||||
"cname", "webrtc", "stealth", "cookie", "network", "app", "extension", "jsonprune",
|
|
||||||
"hls", "referrerpolicy", "match-case-path"
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Procedural cosmetic syntax that needs uBlock Origin's own runtime to evaluate. These
|
|
||||||
* selectors cannot be expressed as plain CSS, so the rules are skipped.
|
|
||||||
*
|
|
||||||
* `:has()` is not in this list: modern WebView's Chromium supports it natively as plain CSS,
|
|
||||||
* so those selectors are just passed straight through. `:has-text()` is handled separately in
|
|
||||||
* [parseCosmeticRule] rather than being skipped outright -- the simple, common form (a plain
|
|
||||||
* selector followed by one `:has-text(...)`) is split out and matched by JavaScript instead of
|
|
||||||
* CSS, since there is no CSS equivalent for a text match.
|
|
||||||
*/
|
|
||||||
private val PROCEDURAL_MARKERS = arrayOf(
|
|
||||||
// ":has-text(" stays here as a safety net: it's stripped out before this check runs
|
|
||||||
// when it's the simple trailing form [extractHasText] understands, so a leftover
|
|
||||||
// occurrence here means that extraction either wasn't attempted (not a cosmetic rule
|
|
||||||
// that reached it) or failed (nested/combined usage), and the rule should be dropped
|
|
||||||
// rather than shipped as literal, invalid CSS.
|
|
||||||
":has-text(",
|
|
||||||
":matches-css", ":matches-media", ":matches-path", ":matches-attr",
|
|
||||||
":xpath(", ":upward(", ":nth-ancestor(", ":style(", ":remove(", ":remove-attr(",
|
|
||||||
":remove-class(", ":watch-attr(", ":min-text-length(", ":others(", ":shadow(", ":if(",
|
|
||||||
":if-not(", ":not(:has"
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun isHostCharacter(c: Char): Boolean =
|
|
||||||
c in 'a'..'z' || c in '0'..'9' || c == '.' || c == '-' || c == '_'
|
|
||||||
|
|
||||||
fun parseNetworkRule(line: String): NetworkRule? {
|
|
||||||
var body = line
|
|
||||||
if (body.startsWith("@@")) body = body.substring(2)
|
|
||||||
if (body.isEmpty()) return null
|
|
||||||
|
|
||||||
var pattern = body
|
|
||||||
var optionText: String? = null
|
|
||||||
val dollar = body.lastIndexOf('$')
|
|
||||||
if (dollar >= 0) {
|
|
||||||
pattern = body.substring(0, dollar)
|
|
||||||
optionText = body.substring(dollar + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular-expression rules (/pattern/) are not supported.
|
|
||||||
if (pattern.length > 2 && pattern.startsWith("/") && pattern.endsWith("/")) return null
|
|
||||||
if (pattern.isEmpty()) return null
|
|
||||||
|
|
||||||
var anchorDomain = false
|
|
||||||
var anchorStart = false
|
|
||||||
var anchorEnd = false
|
|
||||||
if (pattern.startsWith("||")) {
|
|
||||||
anchorDomain = true
|
|
||||||
pattern = pattern.substring(2)
|
|
||||||
} else if (pattern.startsWith("|")) {
|
|
||||||
anchorStart = true
|
|
||||||
pattern = pattern.substring(1)
|
|
||||||
}
|
|
||||||
if (pattern.endsWith("|")) {
|
|
||||||
anchorEnd = true
|
|
||||||
pattern = pattern.dropLast(1)
|
|
||||||
}
|
|
||||||
if (pattern.isEmpty()) return null
|
|
||||||
pattern = pattern.lowercase()
|
|
||||||
|
|
||||||
var typeMask = 0
|
|
||||||
var excludedTypeMask = 0
|
|
||||||
var thirdParty: Boolean? = null
|
|
||||||
var includeDomains: MutableList<String>? = null
|
|
||||||
var excludeDomains: MutableList<String>? = null
|
|
||||||
var redirectResource: String? = null
|
|
||||||
|
|
||||||
if (optionText != null) {
|
|
||||||
for (rawOption in optionText.split(',')) {
|
|
||||||
var option = rawOption.trim().lowercase()
|
|
||||||
if (option.isEmpty()) continue
|
|
||||||
|
|
||||||
var negated = false
|
|
||||||
if (option.startsWith("~")) {
|
|
||||||
negated = true
|
|
||||||
option = option.substring(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
val equals = option.indexOf('=')
|
|
||||||
val key = if (equals >= 0) option.substring(0, equals) else option
|
|
||||||
val value = if (equals >= 0) option.substring(equals + 1) else null
|
|
||||||
|
|
||||||
if (key.isEmpty()) return null
|
|
||||||
if (key in UNSUPPORTED_OPTIONS) return null
|
|
||||||
|
|
||||||
when (key) {
|
|
||||||
"domain" -> {
|
|
||||||
if (value.isNullOrEmpty()) return null
|
|
||||||
for (entry in value.split('|')) {
|
|
||||||
val domain = entry.trim()
|
|
||||||
if (domain.isEmpty()) continue
|
|
||||||
if (domain.startsWith("~")) {
|
|
||||||
val excluded = domain.substring(1)
|
|
||||||
if (excluded.isNotEmpty()) {
|
|
||||||
(excludeDomains ?: mutableListOf<String>()
|
|
||||||
.also { excludeDomains = it }).add(excluded)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
(includeDomains ?: mutableListOf<String>()
|
|
||||||
.also { includeDomains = it }).add(domain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"third-party", "3p" -> thirdParty = !negated
|
|
||||||
"first-party", "1p" -> thirdParty = negated
|
|
||||||
// Patterns are lower-cased for matching, so $match-case degrades to
|
|
||||||
// case-insensitive. It only ever widens the match.
|
|
||||||
"match-case", "important" -> Unit
|
|
||||||
"all" -> Unit
|
|
||||||
// `redirect-rule` only fires when another rule would already block the
|
|
||||||
// request; treating it the same as `redirect` is a harmless simplification
|
|
||||||
// since we only reach here once a block decision is otherwise made.
|
|
||||||
"redirect", "redirect-rule" -> {
|
|
||||||
// AdGuard's priority suffix (`name:10`) doesn't apply to a single-engine
|
|
||||||
// blocker with no other redirect rule to rank against.
|
|
||||||
val name = value?.substringBefore(':')
|
|
||||||
if (name.isNullOrEmpty()) return null
|
|
||||||
redirectResource = name
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
val bit = ResourceType.fromOptionName(key)
|
|
||||||
if (bit == ResourceType.NONE) return null
|
|
||||||
if (negated) excludedTypeMask = excludedTypeMask or bit
|
|
||||||
else typeMask = typeMask or bit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val segments = splitOnWildcards(pattern)
|
|
||||||
if (segments.isEmpty()) return null
|
|
||||||
|
|
||||||
var hostAnchor: String? = null
|
|
||||||
if (anchorDomain && !anchorEnd && segments.size == 1) {
|
|
||||||
val candidate = if (pattern.endsWith("^")) pattern.dropLast(1) else pattern
|
|
||||||
if (candidate.isNotEmpty() && candidate.all { isHostCharacter(it) }) {
|
|
||||||
hostAnchor = candidate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NetworkRule(
|
|
||||||
segments = segments,
|
|
||||||
anchorDomain = anchorDomain,
|
|
||||||
anchorStart = anchorStart,
|
|
||||||
anchorEnd = anchorEnd,
|
|
||||||
typeMask = typeMask,
|
|
||||||
excludedTypeMask = excludedTypeMask,
|
|
||||||
thirdParty = thirdParty,
|
|
||||||
includeDomains = includeDomains?.toTypedArray(),
|
|
||||||
excludeDomains = excludeDomains?.toTypedArray(),
|
|
||||||
hostAnchor = hostAnchor,
|
|
||||||
redirectResource = redirectResource
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun splitOnWildcards(pattern: String): Array<String> {
|
|
||||||
if (pattern.indexOf('*') < 0) return arrayOf(pattern)
|
|
||||||
return pattern.split('*').toTypedArray()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns the index of the cosmetic separator, or -1 when the line is not a cosmetic rule. */
|
|
||||||
fun cosmeticSeparatorIndex(line: String): Int {
|
|
||||||
var search = 0
|
|
||||||
while (true) {
|
|
||||||
val hash = line.indexOf('#', search)
|
|
||||||
if (hash < 0) return -1
|
|
||||||
if (hash + 1 < line.length) {
|
|
||||||
val next = line[hash + 1]
|
|
||||||
if (next == '#') return hash
|
|
||||||
if (next == '@' && hash + 2 < line.length && line[hash + 2] == '#') return hash
|
|
||||||
// #?#, #$#, #$?#, #%# and friends are procedural or scriptlet syntax.
|
|
||||||
if (next == '?' || next == '$' || next == '%') return hash
|
|
||||||
}
|
|
||||||
search = hash + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun parseCosmeticRule(line: String, separatorIndex: Int): CosmeticRule? {
|
|
||||||
val isException: Boolean
|
|
||||||
val selectorStart: Int
|
|
||||||
when {
|
|
||||||
line.startsWith("#@#", separatorIndex) -> {
|
|
||||||
isException = true
|
|
||||||
selectorStart = separatorIndex + 3
|
|
||||||
}
|
|
||||||
line.startsWith("##", separatorIndex) -> {
|
|
||||||
isException = false
|
|
||||||
selectorStart = separatorIndex + 2
|
|
||||||
}
|
|
||||||
// Procedural / scriptlet separators cannot be represented as CSS.
|
|
||||||
else -> return null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectorStart >= line.length) return null
|
|
||||||
var selector = line.substring(selectorStart).trim()
|
|
||||||
if (selector.isEmpty()) return null
|
|
||||||
if (selector.startsWith("+js(") || selector.startsWith("script:")) return null
|
|
||||||
|
|
||||||
var hasTextPattern: String? = null
|
|
||||||
val hasTextSplit = extractHasText(selector)
|
|
||||||
if (hasTextSplit != null) {
|
|
||||||
selector = hasTextSplit.first
|
|
||||||
hasTextPattern = hasTextSplit.second
|
|
||||||
}
|
|
||||||
|
|
||||||
val lowerSelector = selector.lowercase()
|
|
||||||
if (PROCEDURAL_MARKERS.any { lowerSelector.contains(it) }) return null
|
|
||||||
// A stray brace would let a selector escape its own rule when concatenated.
|
|
||||||
if (selector.contains('{') || selector.contains('}')) return null
|
|
||||||
|
|
||||||
var includeDomains: MutableList<String>? = null
|
|
||||||
var excludeDomains: MutableList<String>? = null
|
|
||||||
val domainText = line.substring(0, separatorIndex).trim()
|
|
||||||
if (domainText.isNotEmpty()) {
|
|
||||||
for (entry in domainText.split(',')) {
|
|
||||||
val domain = entry.trim().lowercase()
|
|
||||||
if (domain.isEmpty()) continue
|
|
||||||
if (domain.startsWith("~")) {
|
|
||||||
val excluded = domain.substring(1)
|
|
||||||
if (excluded.isNotEmpty()) {
|
|
||||||
(excludeDomains ?: mutableListOf<String>().also { excludeDomains = it })
|
|
||||||
.add(excluded)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
(includeDomains ?: mutableListOf<String>().also { includeDomains = it })
|
|
||||||
.add(domain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return CosmeticRule(selector, includeDomains, excludeDomains, isException, hasTextPattern)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Splits `selector:has-text(pattern)` into (`selector`, JS regex source). Only the simple,
|
|
||||||
* common form is handled -- one `:has-text(...)` as the very last thing in the selector, with
|
|
||||||
* a non-empty selector before it. Anything else (nested inside `:has()`, combined with other
|
|
||||||
* procedural syntax, an empty base selector matching every element) returns null and the
|
|
||||||
* caller falls back to discarding the rule, same as before this existed.
|
|
||||||
*/
|
|
||||||
private fun extractHasText(selector: String): Pair<String, String>? {
|
|
||||||
val markerIndex = selector.indexOf(":has-text(")
|
|
||||||
if (markerIndex <= 0) return null
|
|
||||||
|
|
||||||
val argStart = markerIndex + ":has-text(".length
|
|
||||||
var depth = 1
|
|
||||||
var index = argStart
|
|
||||||
while (index < selector.length && depth > 0) {
|
|
||||||
when (selector[index]) {
|
|
||||||
'(' -> depth++
|
|
||||||
')' -> depth--
|
|
||||||
}
|
|
||||||
if (depth > 0) index++
|
|
||||||
}
|
|
||||||
// The closing paren must be the last character: a trailing combinator or pseudo after
|
|
||||||
// it means this is a more complex selector than the simple form handled here.
|
|
||||||
if (depth != 0 || index != selector.length - 1) return null
|
|
||||||
|
|
||||||
val base = selector.substring(0, markerIndex).trim()
|
|
||||||
if (base.isEmpty() || base.contains(":has-text(")) return null
|
|
||||||
|
|
||||||
var arg = selector.substring(argStart, index).trim()
|
|
||||||
if (arg.isEmpty()) return null
|
|
||||||
|
|
||||||
val pattern = if (arg.length > 1 && arg.startsWith("/") && arg.lastIndexOf('/') > 0) {
|
|
||||||
arg.substring(1, arg.lastIndexOf('/'))
|
|
||||||
} else {
|
|
||||||
Regex.escape(arg)
|
|
||||||
}
|
|
||||||
return base to pattern
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
package app.seb3thehacker.gearslip.browser.adblock
|
|
||||||
|
|
||||||
import android.webkit.WebResourceResponse
|
|
||||||
import java.io.ByteArrayInputStream
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A small subset of uBlock Origin's / AdGuard's named `$redirect=` resources.
|
|
||||||
*
|
|
||||||
* Blocking most tracking scripts outright is safe -- the page never had a live reference to
|
|
||||||
* them, so nothing calls into the empty response [AdBlockManager.blockedResponse] already
|
|
||||||
* serves. But a handful of scripts are different: page code assumes the object they define
|
|
||||||
* (`ga`, `dataLayer`, `googletag`, ...) exists and calls it immediately, so an empty response
|
|
||||||
* leaves that call throwing a `ReferenceError` that can break unrelated script on the same page.
|
|
||||||
* These stubs define just enough of that surface to no-op safely, which is what the named
|
|
||||||
* `$redirect=` resource is for in the filter list that requested it.
|
|
||||||
*
|
|
||||||
* Anything not in this map still gets blocked -- just with the generic typed-empty response
|
|
||||||
* instead of an exact stub, same as before this existed.
|
|
||||||
*/
|
|
||||||
internal object RedirectResources {
|
|
||||||
|
|
||||||
private val EMPTY_JS = "".toByteArray()
|
|
||||||
|
|
||||||
private val GOOGLE_ANALYTICS_GA = """
|
|
||||||
(function(){function g(){}var t=function(){t.c[t.q.push(arguments)-1]=null};
|
|
||||||
t.q=[];t.c=[];window.ga=window.ga||t;window._gaq=window._gaq||{push:g};
|
|
||||||
window.GoogleAnalyticsObject=window.GoogleAnalyticsObject||'ga';})();
|
|
||||||
""".trimIndent().toByteArray()
|
|
||||||
|
|
||||||
private val GOOGLE_ANALYTICS_ANALYTICS = """
|
|
||||||
(function(){function s(){}var g={
|
|
||||||
create:function(){return{get:s,set:s,send:s}},getAll:function(){return[]},
|
|
||||||
getByName:function(){return null},remove:s};
|
|
||||||
window.ga=window.ga||function(){(window.ga.q=window.ga.q||[]).push(arguments)};
|
|
||||||
window.ga.create=g.create;window.ga.getAll=g.getAll;window.ga.getByName=g.getByName;
|
|
||||||
window.ga.remove=g.remove;window.gaplugins={Linker:function(){this.decorate=s;this.autoLink=s}};
|
|
||||||
})();
|
|
||||||
""".trimIndent().toByteArray()
|
|
||||||
|
|
||||||
private val GOOGLETAGMANAGER_GTM = """
|
|
||||||
(function(){window.dataLayer=window.dataLayer||[];
|
|
||||||
window.dataLayer.push=function(){};
|
|
||||||
window.google_tag_manager=window.google_tag_manager||{};})();
|
|
||||||
""".trimIndent().toByteArray()
|
|
||||||
|
|
||||||
private val GOOGLETAGSERVICES_GPT = """
|
|
||||||
(function(){function s(){}function u(){return{addService:function(){return this},
|
|
||||||
setTargeting:s,setCollapseEmptyDiv:s,addEventListener:function(){return this},
|
|
||||||
removeEventListener:s,get:s,set:s,getSlotElementId:function(){return''},
|
|
||||||
getSlotId:function(){return{getDomId:function(){return''}}}}}
|
|
||||||
window.googletag=window.googletag||{};
|
|
||||||
window.googletag.cmd=window.googletag.cmd||{push:function(fn){try{fn()}catch(e){}}};
|
|
||||||
var pubads=u();
|
|
||||||
window.googletag.pubads=function(){return pubads};
|
|
||||||
window.googletag.defineSlot=function(){return u()};
|
|
||||||
window.googletag.defineOutOfPageSlot=function(){return u()};
|
|
||||||
window.googletag.enableServices=s;window.googletag.display=s;
|
|
||||||
window.googletag.apiReady=true;})();
|
|
||||||
""".trimIndent().toByteArray()
|
|
||||||
|
|
||||||
private val NOOP_CSS = "".toByteArray()
|
|
||||||
private val NOOP_HTML = "<!doctype html><html><head></head><body></body></html>".toByteArray()
|
|
||||||
private val NOOP_JSON = "{}".toByteArray()
|
|
||||||
|
|
||||||
private val STUBS: Map<String, Pair<String, ByteArray>> = buildMap {
|
|
||||||
// Names as they appear across uBO's and AdGuard's resources.txt / redirect syntax.
|
|
||||||
for (name in listOf("noopjs", "noop.js", "noop-js")) put(name, "application/javascript" to EMPTY_JS)
|
|
||||||
for (name in listOf("noopcss", "noop.css", "noop-css")) put(name, "text/css" to NOOP_CSS)
|
|
||||||
for (name in listOf("noop.txt", "nooptext")) put(name, "text/plain" to "".toByteArray())
|
|
||||||
for (name in listOf("noop.json", "empty")) put(name, "application/json" to NOOP_JSON)
|
|
||||||
for (name in listOf("noop.html", "click2load.html")) put(name, "text/html" to NOOP_HTML)
|
|
||||||
for (name in listOf("google-analytics_ga.js", "google-analytics.com/ga.js", "ga.js"))
|
|
||||||
put(name, "application/javascript" to GOOGLE_ANALYTICS_GA)
|
|
||||||
for (name in listOf(
|
|
||||||
"google-analytics_analytics.js", "google-analytics.com/analytics.js",
|
|
||||||
"googletagmanager_gtag.js", "googletagmanager-gtag"
|
|
||||||
)) put(name, "application/javascript" to GOOGLE_ANALYTICS_ANALYTICS)
|
|
||||||
for (name in listOf("googletagmanager_gtm.js", "googletagmanager.com/gtm.js", "gtm.js"))
|
|
||||||
put(name, "application/javascript" to GOOGLETAGMANAGER_GTM)
|
|
||||||
for (name in listOf("googletagservices_gpt.js", "googletagservices.com/gpt.js", "gpt.js"))
|
|
||||||
put(name, "application/javascript" to GOOGLETAGSERVICES_GPT)
|
|
||||||
for (name in listOf("1x1.gif", "1x1-transparent.gif", "2x2.png", "3x2.png", "32x32.png"))
|
|
||||||
put(name, "image/gif" to AdBlockManager.transparentGifBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Null when [name] is not one of the resources we ship a stub for. */
|
|
||||||
fun response(name: String, headers: Map<String, String>): WebResourceResponse? {
|
|
||||||
val stub = STUBS[name.lowercase()] ?: return null
|
|
||||||
val (mimeType, body) = stub
|
|
||||||
val encoding = if (mimeType.startsWith("image/")) null else "utf-8"
|
|
||||||
return WebResourceResponse(mimeType, encoding, 200, "OK", headers, ByteArrayInputStream(body))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
package app.seb3thehacker.gearslip.browser.adblock
|
|
||||||
|
|
||||||
/**
|
|
||||||
* YouTube ad blocking, plus the cosmetic cleanup that rides along with it.
|
|
||||||
*
|
|
||||||
* Filter lists cannot touch YouTube's video ads. The ad and the video are delivered by the same
|
|
||||||
* hosts, so there is no request to block and no element to hide: the player decides to show an
|
|
||||||
* ad because its own API response told it to. What can be done is what uBlock Origin's
|
|
||||||
* scriptlets do — edit that response before the player reads it.
|
|
||||||
*
|
|
||||||
* Three hooks, all installed before any page script runs:
|
|
||||||
* - `ytInitialPlayerResponse`, the JSON embedded in the page for the first video watched;
|
|
||||||
* - `fetch`, used by the single-page app for every later video;
|
|
||||||
* - `JSON.parse`, a backstop for whatever path the two above miss.
|
|
||||||
*
|
|
||||||
* Each strips the keys the player uses to schedule ads. A periodic sweep then clicks any skip
|
|
||||||
* button and seeks past anything that still slipped through, which also covers ads stitched
|
|
||||||
* into the stream server-side, where nothing client-side can remove them cleanly. All of this,
|
|
||||||
* plus hiding the ad's own banner/in-feed markup, is gated on "Block YouTube video ads": it's
|
|
||||||
* inherently maintenance-prone (YouTube changes these field and class names when it wants to,
|
|
||||||
* and may respond with an anti-adblock notice), so it needs to be switchable off as a unit
|
|
||||||
* without losing ordinary filter-list blocking.
|
|
||||||
*
|
|
||||||
* The Shorts shelf and filter chip row are a separate concern -- cosmetic preference, not ad
|
|
||||||
* blocking -- so they sit behind their own "Custom site tweaks" setting instead, independent of
|
|
||||||
* whether YouTube ad blocking is on. The floating menu button covering a page's own fixed
|
|
||||||
* bottom bar is handled generically for every site in [CosmeticFilterBridge], not here.
|
|
||||||
*/
|
|
||||||
object YouTubeAdBlock {
|
|
||||||
|
|
||||||
val INJECTION_JS: String = """
|
|
||||||
(function () {
|
|
||||||
try {
|
|
||||||
var host = location.hostname || '';
|
|
||||||
if (!/(^|\.)(youtube\.com|youtube-nocookie\.com|youtubekids\.com)${'$'}/.test(host)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var bridge = window.${CosmeticFilterBridge.BRIDGE_OBJECT_NAME};
|
|
||||||
if (!bridge) { return; }
|
|
||||||
var adBlockingOn = typeof bridge.youtubeBlockingEnabled === 'function' &&
|
|
||||||
bridge.youtubeBlockingEnabled(host);
|
|
||||||
var tweaksOn = typeof bridge.customSiteTweaksEnabled === 'function' &&
|
|
||||||
bridge.customSiteTweaksEnabled();
|
|
||||||
if (!adBlockingOn && !tweaksOn) { return; }
|
|
||||||
if (window.__gearslipYouTube) { return; }
|
|
||||||
window.__gearslipYouTube = true;
|
|
||||||
|
|
||||||
var styleRules = '';
|
|
||||||
|
|
||||||
if (adBlockingOn) {
|
|
||||||
// The fields the player consults to schedule ad breaks.
|
|
||||||
var AD_KEYS = [
|
|
||||||
'adPlacements', 'playerAds', 'adSlots', 'adBreakHeartbeatParams'
|
|
||||||
];
|
|
||||||
|
|
||||||
var stripKeys = function (object) {
|
|
||||||
if (!object || typeof object !== 'object') { return; }
|
|
||||||
for (var i = 0; i < AD_KEYS.length; i++) {
|
|
||||||
if (AD_KEYS[i] in object) {
|
|
||||||
try { delete object[AD_KEYS[i]]; } catch (e) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Deliberately shallow. A player response is a large object and walking all of
|
|
||||||
// it on every parse would cost more than the ads do.
|
|
||||||
var prune = function (payload) {
|
|
||||||
if (!payload || typeof payload !== 'object') { return payload; }
|
|
||||||
if (Array.isArray(payload)) {
|
|
||||||
for (var i = 0; i < payload.length && i < 50; i++) { prune(payload[i]); }
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
stripKeys(payload);
|
|
||||||
if (payload.playerResponse) { stripKeys(payload.playerResponse); }
|
|
||||||
if (payload.response) { stripKeys(payload.response); }
|
|
||||||
if (payload.playerData) { stripKeys(payload.playerData); }
|
|
||||||
return payload;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 1. The first video: YouTube assigns this from an inline script in the document.
|
|
||||||
try {
|
|
||||||
var initial;
|
|
||||||
Object.defineProperty(window, 'ytInitialPlayerResponse', {
|
|
||||||
configurable: true,
|
|
||||||
get: function () { return initial; },
|
|
||||||
set: function (value) { initial = prune(value); }
|
|
||||||
});
|
|
||||||
} catch (e) {}
|
|
||||||
|
|
||||||
// 2. Every later video in the single-page app.
|
|
||||||
var originalFetch = window.fetch;
|
|
||||||
if (typeof originalFetch === 'function') {
|
|
||||||
window.fetch = function (input, init) {
|
|
||||||
var url = '';
|
|
||||||
try {
|
|
||||||
url = (typeof input === 'string') ? input : ((input && input.url) || '');
|
|
||||||
} catch (e) {}
|
|
||||||
var pending = originalFetch.apply(this, arguments);
|
|
||||||
// Only the player call carries ad scheduling. /next is the comments and
|
|
||||||
// related-video payload: large, and re-serialising it buys nothing.
|
|
||||||
if (url.indexOf('/youtubei/v1/player') < 0) {
|
|
||||||
return pending;
|
|
||||||
}
|
|
||||||
return pending.then(function (response) {
|
|
||||||
return response.text().then(function (text) {
|
|
||||||
var body = text;
|
|
||||||
// Cheap string test first: most responses have nothing to strip, and
|
|
||||||
// parsing plus re-serialising a player response is not free.
|
|
||||||
if (text.indexOf('"adPlacements"') >= 0 ||
|
|
||||||
text.indexOf('"playerAds"') >= 0 ||
|
|
||||||
text.indexOf('"adSlots"') >= 0) {
|
|
||||||
try {
|
|
||||||
var data = JSON.parse(text);
|
|
||||||
prune(data);
|
|
||||||
body = JSON.stringify(data);
|
|
||||||
} catch (e) {
|
|
||||||
body = text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Content-Length describes the original bytes, and Content-Encoding a
|
|
||||||
// compression this body no longer has. Copying either onto the edited
|
|
||||||
// body leaves the player waiting on bytes that never arrive.
|
|
||||||
var headers;
|
|
||||||
try {
|
|
||||||
headers = new Headers(response.headers);
|
|
||||||
headers.delete('content-length');
|
|
||||||
headers.delete('content-encoding');
|
|
||||||
} catch (e) {
|
|
||||||
headers = undefined;
|
|
||||||
}
|
|
||||||
return new Response(body, {
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
headers: headers
|
|
||||||
});
|
|
||||||
}).catch(function () { return response; });
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Backstop for any path the first two miss.
|
|
||||||
var originalParse = JSON.parse;
|
|
||||||
JSON.parse = function (text, reviver) {
|
|
||||||
var result = originalParse.apply(this, arguments);
|
|
||||||
try { prune(result); } catch (e) {}
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Hide the banner, overlay and in-feed ads, which are ordinary elements. The
|
|
||||||
// :has() rules collapse the wrapping feed section too, instead of leaving an
|
|
||||||
// empty gap where the ad used to be.
|
|
||||||
styleRules +=
|
|
||||||
'ytd-promoted-sparkles-web-renderer,ytd-promoted-video-renderer,' +
|
|
||||||
'ytd-display-ad-renderer,ytd-ad-slot-renderer,ytd-in-feed-ad-layout-renderer,' +
|
|
||||||
'ytd-banner-promo-renderer,ytd-statement-banner-renderer,' +
|
|
||||||
'ytd-companion-slot-renderer,ytd-action-companion-ad-renderer,' +
|
|
||||||
'ytm-promoted-video-renderer,ytm-companion-ad-renderer,' +
|
|
||||||
'ytm-promoted-sparkles-web-renderer,ytm-search-promoted-video-renderer,' +
|
|
||||||
'.ytp-ad-overlay-container,.ytp-ad-overlay-slot,.ytp-ad-message-container,' +
|
|
||||||
'#masthead-ad,#player-ads,' +
|
|
||||||
'ytm-item-section-renderer:has(>*>ytm-companion-ad-renderer),' +
|
|
||||||
'ytm-item-section-renderer:has(>*>ytm-promoted-sparkles-web-renderer),' +
|
|
||||||
'ytm-item-section-renderer:has(>*>ytm-promoted-video-renderer),' +
|
|
||||||
'ytm-item-section-renderer:has(>*>ytm-search-promoted-video-renderer),' +
|
|
||||||
'ytm-item-section-renderer:has(>*>ytd-ad-slot-renderer),' +
|
|
||||||
// The "desktop site" grid feed uses different wrapper elements entirely: each
|
|
||||||
// card is a ytd-rich-item-renderer grid cell, so hiding just the ad renderer
|
|
||||||
// inside it still leaves an empty cell-shaped hole in the grid.
|
|
||||||
'ytd-rich-item-renderer:has(ytd-display-ad-renderer),' +
|
|
||||||
'ytd-rich-item-renderer:has(ytd-ad-slot-renderer),' +
|
|
||||||
'ytd-rich-item-renderer:has(ytd-promoted-sparkles-web-renderer),' +
|
|
||||||
'ytd-rich-item-renderer:has(ytd-statement-banner-renderer),' +
|
|
||||||
'ytd-rich-item-renderer:has(ytd-banner-promo-renderer),' +
|
|
||||||
// Ads can also occupy a whole grid row, or a section above/between rows, not
|
|
||||||
// just a single cell -- same empty-hole problem, one level up each time.
|
|
||||||
'ytd-rich-grid-row:has(ytd-display-ad-renderer),' +
|
|
||||||
'ytd-rich-grid-row:has(ytd-ad-slot-renderer),' +
|
|
||||||
'ytd-rich-grid-row:has(ytd-promoted-sparkles-web-renderer),' +
|
|
||||||
'ytd-rich-grid-row:has(ytd-statement-banner-renderer),' +
|
|
||||||
'ytd-rich-grid-row:has(ytd-banner-promo-renderer),' +
|
|
||||||
'ytd-rich-section-renderer:has(ytd-ad-slot-renderer),' +
|
|
||||||
'ytd-rich-section-renderer:has(ytd-display-ad-renderer),' +
|
|
||||||
'ytd-rich-section-renderer:has(ytd-statement-banner-renderer),' +
|
|
||||||
'ytd-rich-section-renderer:has(ytd-banner-promo-renderer),' +
|
|
||||||
'ytd-ad-slot-renderer,ytd-primetime-promo-renderer' +
|
|
||||||
'{display:none!important}';
|
|
||||||
|
|
||||||
// Sweep for anything still playing: press skip, or seek to the end of the ad.
|
|
||||||
var adTicks = 0;
|
|
||||||
var sweep = function () {
|
|
||||||
try {
|
|
||||||
var skip = document.querySelector(
|
|
||||||
'.ytp-ad-skip-button,.ytp-ad-skip-button-modern,.ytp-skip-ad-button,' +
|
|
||||||
'.ytp-ad-skip-button-container button'
|
|
||||||
);
|
|
||||||
if (skip) { skip.click(); }
|
|
||||||
var player = document.querySelector('.html5-video-player');
|
|
||||||
if (player && player.classList.contains('ad-showing')) {
|
|
||||||
// Only seek once the ad state has held for two ticks. Seeking during the
|
|
||||||
// player's own transition makes it re-buffer, which looks like a stall.
|
|
||||||
adTicks++;
|
|
||||||
var video = player.querySelector('video');
|
|
||||||
if (adTicks >= 2 && video && isFinite(video.duration) && video.duration > 0) {
|
|
||||||
video.currentTime = video.duration;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
adTicks = 0;
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
};
|
|
||||||
setInterval(sweep, 700);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tweaksOn) {
|
|
||||||
styleRules +=
|
|
||||||
// The mobile site's fixed Home/Shorts/You bar and the "desktop site" grid's
|
|
||||||
// reel shelf section it sits inside of.
|
|
||||||
'ytm-pivot-bar-renderer,' +
|
|
||||||
'ytd-rich-section-renderer:has(ytd-reel-shelf-renderer),' +
|
|
||||||
// Shorts: a different, faster-cut format than the rest of the feed, and its
|
|
||||||
// vertical-video shelf doesn't fit a landscape car display well either way.
|
|
||||||
'ytm-reel-shelf-renderer,ytm-rich-shelf-renderer:has(ytm-reel-item-renderer),' +
|
|
||||||
'ytm-reel-item-renderer,ytd-reel-shelf-renderer,' +
|
|
||||||
// The "All / Podcasts / News / ..." filter chip row above the feed.
|
|
||||||
'ytm-chip-bar-renderer,ytm-feed-filter-chip-bar-renderer' +
|
|
||||||
'{display:none!important}';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (styleRules) {
|
|
||||||
var style = document.createElement('style');
|
|
||||||
style.setAttribute('data-gearslip', 'youtube');
|
|
||||||
style.textContent = styleRules;
|
|
||||||
var root = document.head || document.documentElement;
|
|
||||||
if (root) { root.appendChild(style); }
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
})();
|
|
||||||
""".trimIndent()
|
|
||||||
}
|
|
||||||
@@ -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,108 +0,0 @@
|
|||||||
package app.seb3thehacker.gearslip.call
|
|
||||||
|
|
||||||
import android.os.SystemClock
|
|
||||||
import android.view.KeyEvent
|
|
||||||
import app.seb3thehacker.gearslip.GearslipLog
|
|
||||||
import app.seb3thehacker.gearslip.Protobuf
|
|
||||||
import app.seb3thehacker.gearslip.Wire
|
|
||||||
import app.seb3thehacker.gearslip.car.CarKeys
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tells the car about calls, so its own screens show them: the instrument cluster, the steering
|
|
||||||
* wheel display, the car's phone page. Android Auto does the same over the car's
|
|
||||||
* PhoneStatusService (Service field 10):
|
|
||||||
*
|
|
||||||
* -> PhoneStatusUpdate { repeated PhoneCall calls = 1 }
|
|
||||||
* PhoneCall { state = 1 (1 in call, 4 incoming, 6 muted); duration_seconds = 2;
|
|
||||||
* number = 3; name = 4 }
|
|
||||||
* <- PhoneStatusInput { input = 1 { action = 1 (7 call) }; caller = 2; name = 3 }
|
|
||||||
*
|
|
||||||
* Read once a second from [CarCalls]. An update goes when the call changes, and every second
|
|
||||||
* while one is going, so the car's timer counts along; no calls goes once, as an empty list.
|
|
||||||
* The car's own call button answers through [CarKeys], like the steering wheel's.
|
|
||||||
*/
|
|
||||||
class CarPhoneStatus(
|
|
||||||
val channelId: Int,
|
|
||||||
private val sendOnChannel: (messageId: Int, body: ByteArray) -> Unit,
|
|
||||||
) {
|
|
||||||
@Volatile private var running = false
|
|
||||||
private var sent: String? = null
|
|
||||||
|
|
||||||
fun onMessage(messageId: Int, body: ByteArray) {
|
|
||||||
when (messageId) {
|
|
||||||
MSG_CHANNEL_OPEN_RESPONSE -> {
|
|
||||||
val status = Protobuf.readInt32Field(body, 1)
|
|
||||||
GearslipLog.i("phone status: <- ChannelOpenResponse: status=$status")
|
|
||||||
if (status == 0) start()
|
|
||||||
}
|
|
||||||
MSG_INPUT -> onInput(body)
|
|
||||||
else -> GearslipLog.i("phone status: <- unhandled message id=$messageId (${body.size} bytes)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun start() {
|
|
||||||
if (running) return
|
|
||||||
running = true
|
|
||||||
Thread({
|
|
||||||
while (running) {
|
|
||||||
runCatching { tick() }.onFailure { GearslipLog.w("phone status: ${it.message}") }
|
|
||||||
Thread.sleep(TICK_MS)
|
|
||||||
}
|
|
||||||
}, "gearslip-phone-status").start()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stop() {
|
|
||||||
running = false
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun tick() {
|
|
||||||
val call = when (val state = CarCalls.state.value) {
|
|
||||||
CallUiState.None -> null
|
|
||||||
is CallUiState.Ringing -> Call(STATE_INCOMING, 0, state.caller)
|
|
||||||
is CallUiState.Active -> Call(
|
|
||||||
if (CarCalls.muted.value) STATE_MUTED else STATE_IN_CALL,
|
|
||||||
((SystemClock.uptimeMillis() - state.since) / 1000).coerceAtLeast(0),
|
|
||||||
state.caller,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Only a call that's going changes from one second to the next.
|
|
||||||
val key = call?.let { "${it.state}|${it.caller?.number}|${it.caller?.name}|${if (it.state == STATE_INCOMING) 0 else it.seconds}" } ?: ""
|
|
||||||
if (key == sent) return
|
|
||||||
val changed = call?.state != lastState
|
|
||||||
sent = key
|
|
||||||
lastState = call?.state
|
|
||||||
val body = call?.let { Protobuf.bytesField(1, it.encode()) } ?: ByteArray(0)
|
|
||||||
sendOnChannel(MSG_STATUS, body)
|
|
||||||
if (changed) GearslipLog.i("phone status: -> ${call?.let { STATE_NAMES[it.state] } ?: "no calls"}")
|
|
||||||
}
|
|
||||||
|
|
||||||
private var lastState: Int? = null
|
|
||||||
|
|
||||||
private class Call(val state: Int, val seconds: Long, val caller: Caller?) {
|
|
||||||
fun encode(): ByteArray {
|
|
||||||
var body = Protobuf.varintField(1, state.toLong()) + Protobuf.varintField(2, seconds)
|
|
||||||
caller?.number?.takeIf { it.isNotBlank() }?.let { body += Protobuf.stringField(3, it) }
|
|
||||||
caller?.name?.takeIf { it.isNotBlank() }?.let { body += Protobuf.stringField(4, it) }
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The car's own phone buttons. Only "call" does anything: it answers, as the wheel's does. */
|
|
||||||
private fun onInput(body: ByteArray) {
|
|
||||||
val action = Wire.bytes(Wire.fields(body), 1)?.let { Wire.varint(Wire.fields(it), 1)?.toInt() }
|
|
||||||
GearslipLog.i("phone status: <- input action=${action ?: "?"}")
|
|
||||||
if (action == ACTION_CALL) CarKeys.onKey(KeyEvent.KEYCODE_CALL, down = true, longPress = false)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val TICK_MS = 1_000L
|
|
||||||
private const val MSG_CHANNEL_OPEN_RESPONSE = 8
|
|
||||||
private const val MSG_STATUS = 32769 // 0x8001
|
|
||||||
private const val MSG_INPUT = 32770 // 0x8002
|
|
||||||
private const val STATE_IN_CALL = 1
|
|
||||||
private const val STATE_INCOMING = 4
|
|
||||||
private const val STATE_MUTED = 6
|
|
||||||
private const val ACTION_CALL = 7
|
|
||||||
private val STATE_NAMES = mapOf(STATE_IN_CALL to "in a call", STATE_INCOMING to "ringing", STATE_MUTED to "in a call, muted")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,65 +0,0 @@
|
|||||||
package app.seb3thehacker.gearslip.car
|
|
||||||
|
|
||||||
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.lazy.grid.GridCells
|
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.produceState
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import app.seb3thehacker.gearslip.R
|
|
||||||
import app.seb3thehacker.gearslip.mirror.PhoneApp
|
|
||||||
import app.seb3thehacker.gearslip.mirror.PhoneApps
|
|
||||||
import androidx.core.graphics.drawable.toBitmap
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Launcher mode's app drawer: every app on the device, A to Z, each opened full screen. Gearslip
|
|
||||||
* comes first, for its own settings, which the car UI only partly covers.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun AllAppsScreen() {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val apps by produceState(initialValue = PhoneApps.cached() ?: emptyList(), context) {
|
|
||||||
value = PhoneApps.cached() ?: withContext(Dispatchers.IO) { PhoneApps.installed(context).also { PhoneApps.warm(context) } }
|
|
||||||
}
|
|
||||||
val gearslip = remember(context) {
|
|
||||||
val label = context.packageManager.getApplicationLabel(context.applicationInfo).toString()
|
|
||||||
val icon = runCatching { context.packageManager.getApplicationIcon(context.packageName).toBitmap(ICON_PX, ICON_PX).asImageBitmap() }
|
|
||||||
PhoneApp(context.packageName, label, icon.getOrNull())
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize().padding(start = 20.dp, end = 20.dp, top = 12.dp)) {
|
|
||||||
ScreenHeader(stringResource(R.string.app_all_apps))
|
|
||||||
Spacer(Modifier.height(12.dp))
|
|
||||||
LazyVerticalGrid(
|
|
||||||
columns = GridCells.Adaptive(minSize = 108.dp),
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentPadding = PaddingValues(bottom = 16.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
item {
|
|
||||||
PhoneAppTile(gearslip) { LauncherMode.launch(context, context.packageName) }
|
|
||||||
}
|
|
||||||
items(apps.sortedBy { it.label.lowercase() }) { app ->
|
|
||||||
PhoneAppTile(app) { LauncherMode.launch(context, app.packageName) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private const val ICON_PX = 128
|
|
||||||
@@ -1,405 +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(LauncherMode.active.value, LauncherMode.canCall(context)).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.
|
|
||||||
// "take me home", "go home": no "to", so no trigger would catch them.
|
|
||||||
var text = if (Regex("^(?:(?:take|bring|get|drive) me|go|drive|head|get|directions|go back|head back) home$").matches(heard)) {
|
|
||||||
"navigate to home"
|
|
||||||
} else {
|
|
||||||
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?"
|
|
||||||
// "home", "work", or a name from Saved places: its address is the search.
|
|
||||||
var spoken: String? = null
|
|
||||||
SavedPlaces.match(context, destination)?.let { (slot, saved) ->
|
|
||||||
val name = when (slot) {
|
|
||||||
SavedPlaces.HOME -> "home"
|
|
||||||
SavedPlaces.WORK -> "work"
|
|
||||||
else -> saved.label
|
|
||||||
}
|
|
||||||
if (saved.address.isBlank()) {
|
|
||||||
return "You haven't saved a $name address yet. Add it under Saved places in Gearslip's settings on your phone."
|
|
||||||
}
|
|
||||||
destination = saved.address
|
|
||||||
spoken = if (slot == SavedPlaces.HOME) "home" else "to $name"
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
spoken?.let { return "Navigating $it with ${chosen.label}." }
|
|
||||||
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,157 +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.res.stringResource
|
|
||||||
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 {
|
|
||||||
// Dark enough by the text that nothing on the screen behind reads through it.
|
|
||||||
drawRect(Brush.verticalGradient(0f to Color.Transparent, 0.3f to Color.Black.copy(alpha = 0.8f), 1f to Color.Black.copy(alpha = 0.92f)))
|
|
||||||
// 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 listeningText = stringResource(R.string.assist_listening)
|
|
||||||
val momentText = stringResource(R.string.assist_one_moment)
|
|
||||||
val waiting = state.heard.isBlank() &&
|
|
||||||
(state.phase == CarAssistant.Phase.LISTENING || state.phase == CarAssistant.Phase.THINKING)
|
|
||||||
val (small, big) = when (state.phase) {
|
|
||||||
CarAssistant.Phase.LISTENING -> "" to state.heard.ifBlank { if (state.ready) listeningText else momentText }
|
|
||||||
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 (waiting) Color.White.copy(alpha = 0.7f) else Color.White,
|
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
// What to say, until the driver starts saying it.
|
|
||||||
if (state.phase == CarAssistant.Phase.LISTENING && state.ready && state.heard.isBlank()) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.assist_examples),
|
|
||||||
color = Color.White.copy(alpha = 0.6f),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
maxLines = 2,
|
|
||||||
modifier = Modifier.padding(top = 4.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (state.missingPermission) {
|
|
||||||
GsButton(tone = GsTone.Tonal, onClick = {
|
|
||||||
context.startActivity(
|
|
||||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)),
|
|
||||||
)
|
|
||||||
}) { Text(stringResource(R.string.assist_grant)) }
|
|
||||||
}
|
|
||||||
GsIconButton(Icons.Filled.Close, stringResource(R.string.msg_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,179 +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 app.seb3thehacker.gearslip.R
|
|
||||||
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.
|
|
||||||
*
|
|
||||||
* Many cars switch Bluetooth music off while Android Auto runs and want it over Android Auto
|
|
||||||
* instead (a Skoda, a VW, an Audi, a Bolt). If the phone's Bluetooth audio to the car was up when
|
|
||||||
* the car showed Gearslip and drops after, that's what happened: this car's music goes through
|
|
||||||
* Gearslip from now on, and the car screen says so.
|
|
||||||
*/
|
|
||||||
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
|
|
||||||
/** Whether Bluetooth audio to the car was up when the car showed Gearslip; null until known. */
|
|
||||||
private var connectedAtStart: Boolean? = 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
|
|
||||||
connectedAtStart = null
|
|
||||||
audioToCar { connectedAtStart = it }
|
|
||||||
main.postDelayed(::start, WAIT_MS)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onCarDisconnected() = main.post {
|
|
||||||
armed = false
|
|
||||||
connectedAtStart = null
|
|
||||||
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.pipesNow) {
|
|
||||||
set(false, "audio goes through Gearslip")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
audioToCar { ok ->
|
|
||||||
if (ok == null) return@audioToCar
|
|
||||||
if (!ok && connectedAtStart == true) return@audioToCar switchToGearslip()
|
|
||||||
if (!CarSettings.bluetoothWarning.value) {
|
|
||||||
set(false, "the warning is turned off")
|
|
||||||
return@audioToCar
|
|
||||||
}
|
|
||||||
set(!ok, if (ok) "connected to the car" else "not connected to the car")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The car dropped Bluetooth music once Gearslip was up: send this car's music through Gearslip. */
|
|
||||||
private fun switchToGearslip() {
|
|
||||||
val car = CarBluetooth.carAddress ?: return
|
|
||||||
val context = appContext ?: return
|
|
||||||
if (!app.seb3thehacker.gearslip.audio.CarAudio.sinkOffered) return
|
|
||||||
log.i("Bluetooth audio: the car turned it off with Gearslip running, so this car's music goes through Gearslip")
|
|
||||||
CarSettings.rememberAudioCar(car)
|
|
||||||
set(false, "audio goes through Gearslip")
|
|
||||||
app.seb3thehacker.gearslip.audio.CarAudio.request()
|
|
||||||
CarToasts.show(this, context.getString(R.string.car_audio_switched), androidx.car.app.CarToast.LENGTH_LONG)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the phone's Bluetooth audio is connected to the car, on the main thread: null when
|
|
||||||
* that can't be told (no permission). Bluetooth switched off counts as not connected.
|
|
||||||
*/
|
|
||||||
@SuppressLint("MissingPermission")
|
|
||||||
private fun audioToCar(result: (Boolean?) -> Unit) {
|
|
||||||
val context = appContext ?: return result(null)
|
|
||||||
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 result(null)
|
|
||||||
}
|
|
||||||
val adapter = context.getSystemService(BluetoothManager::class.java)?.adapter ?: return result(null)
|
|
||||||
if (!adapter.isEnabled) return result(false)
|
|
||||||
val car = CarBluetooth.carAddress?.uppercase()
|
|
||||||
val asked = 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 { result(ok) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onServiceDisconnected(profile: Int) {}
|
|
||||||
}, BluetoothProfile.A2DP)
|
|
||||||
if (!asked) result(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
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,116 +0,0 @@
|
|||||||
package app.seb3thehacker.gearslip.car
|
|
||||||
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import app.seb3thehacker.gearslip.R
|
|
||||||
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 && !CarSettings.thisCarPipes.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 = stringResource(R.string.home_bt_title),
|
|
||||||
body = stringResource(R.string.overlay_bt_body),
|
|
||||||
secondary = stringResource(R.string.overlay_dont_show) to BluetoothAudioCheck::neverAgain,
|
|
||||||
primary = stringResource(R.string.overlay_ok) to BluetoothAudioCheck::dismissOnCar,
|
|
||||||
) else if (locationOff && !locationDismissed) PairCard(
|
|
||||||
title = stringResource(R.string.overlay_location_off),
|
|
||||||
body = stringResource(R.string.overlay_location_body),
|
|
||||||
secondary = stringResource(R.string.overlay_dont_show) to LocationCheck::neverAgain,
|
|
||||||
primary = stringResource(R.string.overlay_ok) to LocationCheck::dismissOnCar,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
CarBluetooth.Prompt.ASK -> PairCard(
|
|
||||||
title = stringResource(R.string.overlay_pair_title),
|
|
||||||
body = stringResource(if (bluetoothAudio) R.string.overlay_pair_body_music else R.string.overlay_pair_body),
|
|
||||||
secondary = stringResource(R.string.worked_not_now) to CarBluetooth::notNow,
|
|
||||||
primary = stringResource(R.string.overlay_pair) to CarBluetooth::pair,
|
|
||||||
)
|
|
||||||
CarBluetooth.Prompt.PAIRING -> PairCard(
|
|
||||||
title = stringResource(R.string.overlay_confirm_title),
|
|
||||||
body = stringResource(R.string.overlay_confirm_body),
|
|
||||||
secondary = null,
|
|
||||||
primary = stringResource(R.string.carset_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,182 +0,0 @@
|
|||||||
package app.seb3thehacker.gearslip.car
|
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
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.offset
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.layout.widthIn
|
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Add
|
|
||||||
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.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
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 app.seb3thehacker.gearslip.R
|
|
||||||
import app.seb3thehacker.gearslip.browser.Bookmark
|
|
||||||
import app.seb3thehacker.gearslip.browser.BrowserSettings
|
|
||||||
import app.seb3thehacker.gearslip.browser.SiteIcons
|
|
||||||
import app.seb3thehacker.gearslip.car.theme.GsIconBox
|
|
||||||
import app.seb3thehacker.gearslip.car.theme.GsTone
|
|
||||||
import app.seb3thehacker.gearslip.car.theme.gsColors
|
|
||||||
import app.seb3thehacker.gearslip.car.theme.gsShape
|
|
||||||
|
|
||||||
/** How wide a tile is, icon and label together; the grid fits as many as the screen allows. */
|
|
||||||
private val TILE_WIDTH = 112.dp
|
|
||||||
private val ICON_SIZE = 64.dp
|
|
||||||
private val SEARCH_MAX_WIDTH = 560.dp
|
|
||||||
/** Five tiles across at most, so a short list sits in the middle instead of along the left. */
|
|
||||||
private val GRID_MAX_WIDTH = TILE_WIDTH * 5
|
|
||||||
/** Room above each tile for the remove button, which sits on the icon's corner. */
|
|
||||||
private val REMOVE_ROOM = 10.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What the browser shows before a page is open: a search bar, and the bookmarks as big tiles.
|
|
||||||
* Holding a tile shows a remove button on every tile, as a launcher does; tapping anywhere
|
|
||||||
* else, or Done, puts them away. Adding happens from the browser's star, or on the phone.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
internal fun BrowserStartPage(onOpen: (String) -> Unit, onSearch: () -> Unit, onAdd: () -> Unit) {
|
|
||||||
val bookmarks by BrowserSettings.bookmarks.collectAsState()
|
|
||||||
var editing by remember { mutableStateOf(false) }
|
|
||||||
if (bookmarks.isEmpty()) editing = false
|
|
||||||
|
|
||||||
// A narrow, centred column: on a wide car screen a bar from edge to edge reads as a toolbar
|
|
||||||
// rather than a search box, and tiles spread across it look scattered.
|
|
||||||
Column(
|
|
||||||
Modifier.fillMaxSize().padding(horizontal = 16.dp).padding(top = 24.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
|
||||||
) {
|
|
||||||
SearchBar(onSearch)
|
|
||||||
LazyVerticalGrid(
|
|
||||||
columns = GridCells.Adaptive(TILE_WIDTH),
|
|
||||||
contentPadding = PaddingValues(bottom = 16.dp),
|
|
||||||
horizontalArrangement = Arrangement.Center,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
modifier = Modifier.widthIn(max = GRID_MAX_WIDTH).fillMaxSize(),
|
|
||||||
) {
|
|
||||||
items(bookmarks, key = { it.url }) { bookmark ->
|
|
||||||
BookmarkTile(
|
|
||||||
bookmark,
|
|
||||||
editing = editing,
|
|
||||||
onClick = { if (editing) editing = false else onOpen(bookmark.url) },
|
|
||||||
onHold = { editing = true },
|
|
||||||
onRemove = { BrowserSettings.removeBookmark(bookmark.url) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
item(key = "add") {
|
|
||||||
Box(Modifier.padding(top = REMOVE_ROOM)) {
|
|
||||||
if (editing) {
|
|
||||||
Tile(stringResource(R.string.web_done), onClick = { editing = false }) {
|
|
||||||
Icon(Icons.Filled.Close, null, Modifier.size(28.dp))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Tile(stringResource(R.string.web_add_bookmark), onClick = onAdd) {
|
|
||||||
Icon(Icons.Filled.Add, null, Modifier.size(28.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Looks like a text field, but opens the car keyboard: the same as the header's Search. */
|
|
||||||
@Composable
|
|
||||||
private fun SearchBar(onSearch: () -> Unit) {
|
|
||||||
val colors = gsColors(GsTone.Neutral)
|
|
||||||
GsIconBox(onSearch, Modifier.widthIn(max = SEARCH_MAX_WIDTH).fillMaxWidth().height(56.dp), colors, shape = CircleShape) {
|
|
||||||
Row(Modifier.fillMaxWidth().padding(horizontal = 20.dp), verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Icon(Icons.Filled.Search, null, Modifier.size(24.dp))
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.web_search),
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = Modifier.padding(start = 12.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun BookmarkTile(bookmark: Bookmark, editing: Boolean, onClick: () -> Unit, onHold: () -> Unit, onRemove: () -> Unit) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val host = bookmark.host
|
|
||||||
val version by SiteIcons.version.collectAsState()
|
|
||||||
LaunchedEffect(host) { SiteIcons.fetch(context, host) }
|
|
||||||
val icon = remember(host, version) { SiteIcons.icon(context, host) }
|
|
||||||
// The remove button overhangs the icon's corner, into the room left above it, so nothing
|
|
||||||
// clips it: neither the grid's edge nor the next row.
|
|
||||||
Box(Modifier.width(TILE_WIDTH).padding(top = REMOVE_ROOM)) {
|
|
||||||
Tile(bookmark.title, onClick = onClick, onHold = onHold) {
|
|
||||||
if (icon != null) {
|
|
||||||
Image(icon.asImageBitmap(), null, Modifier.size(40.dp).clip(gsShape()))
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
bookmark.title.firstOrNull()?.uppercase() ?: "?",
|
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (editing) {
|
|
||||||
GsIconBox(
|
|
||||||
onRemove,
|
|
||||||
Modifier.align(Alignment.TopCenter).offset(x = ICON_SIZE / 2, y = -REMOVE_ROOM).size(28.dp),
|
|
||||||
gsColors(GsTone.Danger),
|
|
||||||
shape = CircleShape,
|
|
||||||
) {
|
|
||||||
Icon(Icons.Filled.Close, stringResource(R.string.web_remove_bookmark, bookmark.title), Modifier.size(18.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An icon box with its label under it, the start page's one shape. */
|
|
||||||
@Composable
|
|
||||||
private fun Tile(label: String, onClick: () -> Unit, onHold: (() -> Unit)? = null, icon: @Composable () -> Unit) {
|
|
||||||
Column(Modifier.width(TILE_WIDTH), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
|
||||||
GsIconBox(onClick, Modifier.size(ICON_SIZE), gsColors(GsTone.Tonal), onLongClick = onHold) { icon() }
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.drawText
|
||||||
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws the raw video frame with pixel rulers, for measuring what the head unit actually shows.
|
||||||
|
*
|
||||||
|
* Numbers are frame pixels. Read the last number visible on each edge to see how much the head
|
||||||
|
* unit crops there (the frame is 800 wide by 480 tall when the ruler runs to those values), and
|
||||||
|
* tap anywhere: the crosshair is drawn where the phone believes the touch landed, so a mismatch
|
||||||
|
* with your finger is the touch offset. Ignores vehicle insets on purpose.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CalibrationApp() {
|
||||||
|
val navigator = LocalCarNavigator.current
|
||||||
|
val measurer = rememberTextMeasurer()
|
||||||
|
var touch by remember { mutableStateOf<Offset?>(null) }
|
||||||
|
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black)
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
awaitEachGesture {
|
||||||
|
val down = awaitFirstDown()
|
||||||
|
touch = down.position
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Canvas(Modifier.fillMaxSize()) {
|
||||||
|
val label = TextStyle(color = Color.White, fontSize = 11.sp)
|
||||||
|
val faint = Color(0x55FFFFFF)
|
||||||
|
val w = size.width
|
||||||
|
val h = size.height
|
||||||
|
|
||||||
|
var y = 0
|
||||||
|
while (y <= h) {
|
||||||
|
drawLine(if (y % 100 == 0) Color.White else faint, Offset(0f, y.toFloat()), Offset(w, y.toFloat()), strokeWidth = 1f)
|
||||||
|
if (y % 20 == 0) {
|
||||||
|
drawText(measurer, "$y", Offset(2f, y.toFloat()), style = label)
|
||||||
|
drawText(measurer, "$y", Offset(w - 30f, y.toFloat()), style = label)
|
||||||
|
}
|
||||||
|
y += 20
|
||||||
|
}
|
||||||
|
var x = 0
|
||||||
|
while (x <= w) {
|
||||||
|
drawLine(if (x % 100 == 0) Color.White else faint, Offset(x.toFloat(), 0f), Offset(x.toFloat(), h), strokeWidth = 1f)
|
||||||
|
if (x % 50 == 0 && x > 0) {
|
||||||
|
drawText(measurer, "$x", Offset(x.toFloat() + 2f, 2f), style = label)
|
||||||
|
drawText(measurer, "$x", Offset(x.toFloat() + 2f, h - 46f), style = label)
|
||||||
|
}
|
||||||
|
x += 20
|
||||||
|
}
|
||||||
|
// Frame border, drawn last and inset by 1px so it is the outermost thing that can show.
|
||||||
|
drawRect(Color.Red, topLeft = Offset(1f, 1f), size = androidx.compose.ui.geometry.Size(w - 2f, h - 2f), style = androidx.compose.ui.graphics.drawscope.Stroke(2f))
|
||||||
|
drawText(measurer, "frame ${w.toInt()}x${h.toInt()}", Offset(w / 2 - 50f, h / 2 - 60f), style = TextStyle(color = Color.Yellow, fontSize = 14.sp))
|
||||||
|
|
||||||
|
touch?.let { t ->
|
||||||
|
drawLine(Color.Green, Offset(t.x, 0f), Offset(t.x, h), strokeWidth = 2f)
|
||||||
|
drawLine(Color.Green, Offset(0f, t.y), Offset(w, t.y), strokeWidth = 2f)
|
||||||
|
drawText(measurer, "touch ${t.x.toInt()},${t.y.toInt()}", Offset(w / 2 - 50f, h / 2 + 40f), style = TextStyle(color = Color.Green, fontSize = 14.sp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button(onClick = { navigator.home() }, modifier = Modifier.align(Alignment.Center)) {
|
||||||
|
Text("Exit calibration")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
package app.seb3thehacker.gearslip.car
|
|
||||||
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import app.seb3thehacker.gearslip.R
|
|
||||||
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(
|
|
||||||
stringResource(R.string.call_incoming),
|
|
||||||
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, stringResource(R.string.call_decline), MaterialTheme.colorScheme.error, enabled = s.canDecline, onClick = onDecline)
|
|
||||||
CallButton(Icons.Filled.Call, stringResource(R.string.call_answer), MaterialTheme.colorScheme.primary, enabled = true, onClick = onAnswer)
|
|
||||||
}
|
|
||||||
if (!s.canDecline) {
|
|
||||||
Spacer(Modifier.height(20.dp))
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.call_ringing),
|
|
||||||
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,203 +0,0 @@
|
|||||||
package app.seb3thehacker.gearslip.car
|
|
||||||
|
|
||||||
import androidx.annotation.MainThread
|
|
||||||
import androidx.car.app.model.Action
|
|
||||||
import androidx.car.app.model.Alert
|
|
||||||
import androidx.car.app.model.AlertCallback
|
|
||||||
import androidx.compose.animation.core.Animatable
|
|
||||||
import androidx.compose.animation.core.LinearEasing
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.layout.widthIn
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Close
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.semantics.LiveRegionMode
|
|
||||||
import androidx.compose.ui.semantics.liveRegion
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
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 app.seb3thehacker.gearslip.R
|
|
||||||
import app.seb3thehacker.gearslip.car.theme.GsColors
|
|
||||||
import app.seb3thehacker.gearslip.car.theme.GsIconBox
|
|
||||||
import app.seb3thehacker.gearslip.host.cancel
|
|
||||||
import app.seb3thehacker.gearslip.host.click
|
|
||||||
import app.seb3thehacker.gearslip.host.color
|
|
||||||
import app.seb3thehacker.gearslip.host.dismissed
|
|
||||||
import app.seb3thehacker.gearslip.host.text
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The one alert on the car screen, from whichever hosted app sent it: Waze asking whether a
|
|
||||||
* hazard is still there, say. As in Android Auto, a newer alert replaces the one showing, and it
|
|
||||||
* shows over any screen, since a question about the road matters while music is up too.
|
|
||||||
*/
|
|
||||||
@MainThread
|
|
||||||
internal object CarAlerts {
|
|
||||||
class Shown(val owner: Any, val alert: Alert)
|
|
||||||
|
|
||||||
private val current = MutableStateFlow<Shown?>(null)
|
|
||||||
val shown: StateFlow<Shown?> = current.asStateFlow()
|
|
||||||
|
|
||||||
fun show(owner: Any, alert: Alert) {
|
|
||||||
val old = current.value
|
|
||||||
current.value = Shown(owner, alert)
|
|
||||||
GearslipLog.i("host: alert ${alert.id} \"${alert.title.text()}\" with ${alert.actions.size} buttons")
|
|
||||||
if (old != null && !(old.owner === owner && old.alert.id == alert.id)) old.alert.callbackDelegate.dismissed()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The app took its alert down; a no-op unless that alert is the one showing. */
|
|
||||||
fun dismiss(owner: Any, id: Int) {
|
|
||||||
val shown = current.value ?: return
|
|
||||||
if (shown.owner !== owner || shown.alert.id != id) return
|
|
||||||
current.value = null
|
|
||||||
shown.alert.callbackDelegate.dismissed()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The driver tapped one of its buttons: the app gets the tap, and the alert goes. */
|
|
||||||
fun tapped(shown: Shown, action: Action) {
|
|
||||||
action.onClickDelegate.click("alert button")
|
|
||||||
if (current.value !== shown) return
|
|
||||||
current.value = null
|
|
||||||
shown.alert.callbackDelegate.dismissed()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cancelled(shown: Shown, reason: Int) {
|
|
||||||
if (current.value !== shown) return
|
|
||||||
current.value = null
|
|
||||||
shown.alert.callbackDelegate.cancel(reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The app disconnected: its alert goes with it, and there is no one left to tell. */
|
|
||||||
fun clear(owner: Any) {
|
|
||||||
if (current.value?.owner === owner) current.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun CarAlertOverlay(modifier: Modifier = Modifier) {
|
|
||||||
val current by CarAlerts.shown.collectAsState()
|
|
||||||
val shown = current ?: return
|
|
||||||
val alert = shown.alert
|
|
||||||
val timed = alert.durationMillis in 1 until Alert.DURATION_SHOW_INDEFINITELY
|
|
||||||
// What's left of the alert's time, drawn as the bar along its foot.
|
|
||||||
val left = remember(shown) { Animatable(1f) }
|
|
||||||
LaunchedEffect(shown) {
|
|
||||||
if (!timed) return@LaunchedEffect
|
|
||||||
left.animateTo(0f, tween(alert.durationMillis.coerceAtMost(Int.MAX_VALUE.toLong()).toInt(), easing = LinearEasing))
|
|
||||||
CarAlerts.cancelled(shown, AlertCallback.REASON_TIMEOUT)
|
|
||||||
}
|
|
||||||
val scheme = MaterialTheme.colorScheme
|
|
||||||
val dark = scheme.surface.luminanceIsDark()
|
|
||||||
Surface(
|
|
||||||
modifier
|
|
||||||
.padding(horizontal = 24.dp, vertical = 16.dp)
|
|
||||||
.widthIn(max = 640.dp)
|
|
||||||
.fillMaxWidth()
|
|
||||||
.semantics { liveRegion = LiveRegionMode.Polite },
|
|
||||||
shape = RoundedCornerShape(24.dp),
|
|
||||||
color = scheme.surfaceContainerHigh,
|
|
||||||
shadowElevation = 8.dp,
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
Row(Modifier.padding(start = 20.dp, end = 12.dp, top = 16.dp), verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
if (alert.icon != null) {
|
|
||||||
CarGlyph(alert.icon, Modifier.size(40.dp))
|
|
||||||
Spacer(Modifier.width(16.dp))
|
|
||||||
}
|
|
||||||
Column(Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
alert.title.text(),
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
alert.subtitle?.text()?.takeIf { it.isNotEmpty() }?.let {
|
|
||||||
Text(
|
|
||||||
it,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = scheme.onSurfaceVariant,
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// With no timer and no buttons, nothing else would ever take it away.
|
|
||||||
if (!timed || alert.actions.isEmpty()) {
|
|
||||||
GsIconBox(
|
|
||||||
onClick = { CarAlerts.cancelled(shown, AlertCallback.REASON_USER_ACTION) },
|
|
||||||
modifier = Modifier.size(56.dp),
|
|
||||||
colors = GsColors(Color.Transparent, scheme.onSurface),
|
|
||||||
shape = CircleShape,
|
|
||||||
) {
|
|
||||||
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (alert.actions.isNotEmpty()) {
|
|
||||||
Row(
|
|
||||||
Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 16.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
alert.actions.take(2).forEach { action ->
|
|
||||||
val custom = action.backgroundColor?.type?.let { it != androidx.car.app.model.CarColor.TYPE_DEFAULT } == true
|
|
||||||
GsIconBox(
|
|
||||||
onClick = { CarAlerts.tapped(shown, action) },
|
|
||||||
modifier = Modifier.weight(1f).height(64.dp),
|
|
||||||
colors = GsColors(
|
|
||||||
action.backgroundColor.color(dark, scheme.surfaceContainerHighest),
|
|
||||||
if (custom) Color.White else scheme.onSurface,
|
|
||||||
),
|
|
||||||
enabled = action.isEnabled,
|
|
||||||
) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
if (action.icon != null) {
|
|
||||||
CarGlyph(action.icon, Modifier.size(28.dp))
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
}
|
|
||||||
Text(action.title.text(), style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
}
|
|
||||||
if (timed) {
|
|
||||||
Box(Modifier.fillMaxWidth().height(4.dp).background(scheme.surfaceContainerHighest)) {
|
|
||||||
Box(Modifier.fillMaxWidth(left.value).height(4.dp).clip(RoundedCornerShape(2.dp)).background(scheme.primary))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +1,23 @@
|
|||||||
package app.seb3thehacker.gearslip.car
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import app.seb3thehacker.gearslip.R
|
|
||||||
import app.seb3thehacker.gearslip.car.theme.*
|
|
||||||
import android.view.SurfaceHolder
|
import android.view.SurfaceHolder
|
||||||
import android.view.SurfaceView
|
import android.view.SurfaceView
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
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.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -39,11 +28,8 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
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.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.material.icons.filled.Close
|
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import app.seb3thehacker.gearslip.GearslipLog
|
import app.seb3thehacker.gearslip.GearslipLog
|
||||||
import app.seb3thehacker.gearslip.host.CarAppConnection
|
import app.seb3thehacker.gearslip.host.CarAppConnection
|
||||||
@@ -67,44 +53,14 @@ fun CarAppStage(
|
|||||||
) {
|
) {
|
||||||
val template by connection.template.collectAsStateWithLifecycle()
|
val template by connection.template.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val cover = remember { ChromeCover() }
|
|
||||||
val navBar = LocalNavBarInset.current
|
|
||||||
|
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
// The app's own drawing goes underneath; everything else is the host's rendering of
|
// The app's own drawing goes underneath; everything else is the host's rendering of
|
||||||
// the template it described, laid over the top.
|
// the template it described, laid over the top.
|
||||||
androidx.compose.runtime.CompositionLocalProvider(LocalChromeCover provides cover, LocalCarAppConnection provides connection) {
|
if (status.surfaceRequested) {
|
||||||
// A place list's map is the host's to draw; any other map is the app's own surface.
|
AppSurface(connection, frame, template, Modifier.fillMaxSize())
|
||||||
val placeList = template as? androidx.car.app.model.PlaceListMapTemplate
|
|
||||||
if (placeList != null) {
|
|
||||||
HostPlaceMap(placeList, Modifier.fillMaxSize())
|
|
||||||
} else if (status.surfaceRequested) {
|
|
||||||
AppSurface(connection, frame, template, embedded, Modifier.fillMaxSize())
|
|
||||||
}
|
|
||||||
// The docked player floats over one side of the map: the chrome keeps clear of it,
|
|
||||||
// and the app hears that side is covered, so its map centres in the rest.
|
|
||||||
val panel = LocalSidePanelInset.current
|
|
||||||
val panelAtStart = LocalCardsAtEnd.current
|
|
||||||
TemplateChrome(
|
|
||||||
template,
|
|
||||||
Modifier.fillMaxSize().padding(
|
|
||||||
top = if (embedded) 0.dp else CHROME_BAR,
|
|
||||||
bottom = navBar,
|
|
||||||
start = if (panelAtStart) panel else 0.dp,
|
|
||||||
end = if (panelAtStart) 0.dp else panel,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (panel > 0.dp) {
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.align(if (panelAtStart) Alignment.CenterStart else Alignment.CenterEnd)
|
|
||||||
.fillMaxHeight()
|
|
||||||
.width(panel)
|
|
||||||
.covers("side-panel", if (panelAtStart) CoverEdge.START else CoverEdge.END),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
BottomCover(navBar, Modifier.align(Alignment.BottomCenter))
|
|
||||||
}
|
}
|
||||||
|
TemplateChrome(template, Modifier.fillMaxSize().padding(top = if (embedded) 0.dp else CHROME_BAR))
|
||||||
|
if (embedded) CornerMask(EMBEDDED_RADIUS, MaterialTheme.colorScheme.background)
|
||||||
if (!embedded) ChromeBar(status, onDisconnect, Modifier.align(Alignment.TopCenter))
|
if (!embedded) ChromeBar(status, onDisconnect, Modifier.align(Alignment.TopCenter))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,79 +76,20 @@ private fun AppSurface(
|
|||||||
connection: CarAppConnection,
|
connection: CarAppConnection,
|
||||||
frame: CarEnvironment.Frame,
|
frame: CarEnvironment.Frame,
|
||||||
template: Template?,
|
template: Template?,
|
||||||
embedded: Boolean,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
var size by remember { mutableStateOf(0 to 0) }
|
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
|
// The app lays its map out around whatever the host covers, so a template change moves the
|
||||||
// changing size moves the visible area even though the surface itself hasn't changed.
|
// visible area even though the surface itself hasn't changed.
|
||||||
|
LaunchedEffect(template, size) {
|
||||||
val (width, height) = size
|
val (width, height) = size
|
||||||
// A full-screen map takes the knob: turn to zoom, nudge to pan, press for its buttons. The
|
|
||||||
// home screen's map pane leaves it alone, so the knob still moves between the cards there.
|
|
||||||
val focusManager = androidx.compose.ui.platform.LocalFocusManager.current
|
|
||||||
val isMap = !embedded && !isContentTemplate(template)
|
|
||||||
DisposableEffect(connection, isMap, width, height) {
|
|
||||||
if (!isMap || width == 0 || height == 0) return@DisposableEffect onDispose {}
|
|
||||||
val knob = MapKnob(connection, width, height) { focusManager.clearFocus() }
|
|
||||||
CarKeys.knobTaker = knob
|
|
||||||
onDispose {
|
|
||||||
knob.release()
|
|
||||||
if (CarKeys.knobTaker === knob) CarKeys.knobTaker = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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 cardsAtEnd = LocalCardsAtEnd.current
|
|
||||||
val cardEdge = cardEdge
|
|
||||||
val measuredStable = bounds?.takeIf { width > 0 && height > 0 }?.let { cover?.visibleWithin(it, width, height, skip = cardEdge) }
|
|
||||||
// A little room above whatever ends the map at the bottom (the nav bar, or the screen's edge):
|
|
||||||
// apps draw their own corner pieces (Waze's speedometer) right on the area's bottom edge,
|
|
||||||
// which left them sitting flush on the bar.
|
|
||||||
val bottomGap = with(androidx.compose.ui.platform.LocalDensity.current) { MAP_BOTTOM_GAP.roundToPx() }
|
|
||||||
LaunchedEffect(template, size, measured, measuredStable, cardsAtEnd, bottomGap) {
|
|
||||||
if (width > 0 && height > 0) {
|
if (width > 0 && height > 0) {
|
||||||
fun android.graphics.Rect.clearOfBar() = if (isEmpty) this else android.graphics.Rect(left, top, right, maxOf(top, bottom - bottomGap))
|
val visible = visibleAreaFor(template, width, height)
|
||||||
val visible = visibleAreaFor(template, width, height, measured, cardsAtEnd).clearOfBar()
|
connection.reportAreas(visible = visible, stable = visible)
|
||||||
// Waze seems to centre its car in the stable area rather than the visible one, so it
|
|
||||||
// gets the cards taken out of that too. Test: see whether its car moves out from under them.
|
|
||||||
val stable = if (!isContentTemplate(template) && connection.appPackage != "com.waze") {
|
|
||||||
visibleAreaFor(template, width, height, measuredStable, cardsAtEnd).clearOfBar()
|
|
||||||
} 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(
|
AndroidView(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.pointerInput(connection) {
|
.pointerInput(connection) {
|
||||||
@@ -205,30 +102,6 @@ private fun SurfaceHost(
|
|||||||
if (pan != Offset.Zero) connection.surfaceScroll(-pan.x, -pan.y)
|
if (pan != Offset.Zero) connection.surfaceScroll(-pan.x, -pan.y)
|
||||||
if (zoom != 1f) connection.surfaceScale(centroid.x, centroid.y, zoom)
|
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 ->
|
factory = { context ->
|
||||||
SurfaceView(context).apply {
|
SurfaceView(context).apply {
|
||||||
@@ -242,20 +115,20 @@ private fun SurfaceHost(
|
|||||||
height: Int,
|
height: Int,
|
||||||
) {
|
) {
|
||||||
GearslipLog.i("stage: surface ready at ${width}x$height")
|
GearslipLog.i("stage: surface ready at ${width}x$height")
|
||||||
onSize(width to height)
|
size = width to height
|
||||||
connection.attachSurface(holder.surface, width, height, frame.densityDpi)
|
connection.attachSurface(holder.surface, width, height, frame.densityDpi)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||||||
GearslipLog.i("stage: surface destroyed")
|
GearslipLog.i("stage: surface destroyed")
|
||||||
// Only this view's own surface: a replaced view's teardown can land after
|
connection.detachSurface()
|
||||||
// its successor has already lent the app a new one.
|
|
||||||
connection.detachSurface(holder.surface)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
DisposableEffect(connection) { onDispose { connection.detachSurface() } }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -264,43 +137,55 @@ private fun ChromeBar(
|
|||||||
onDisconnect: () -> Unit,
|
onDisconnect: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
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(
|
Surface(
|
||||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
color = Color.Black.copy(alpha = 0.55f),
|
||||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
contentColor = Color.White,
|
||||||
modifier = modifier.fillMaxWidth().height(CHROME_BAR),
|
modifier = modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
Modifier.padding(horizontal = 16.dp),
|
Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
status.app ?: stringResource(R.string.stage_car_app),
|
status.app ?: "Car app",
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
GsIconButton(
|
|
||||||
androidx.compose.material.icons.Icons.Filled.Close,
|
|
||||||
status.app?.let { stringResource(R.string.stage_close, it) } ?: stringResource(R.string.stage_close_app),
|
|
||||||
onDisconnect,
|
|
||||||
size = 36.dp,
|
|
||||||
iconSize = 20.dp,
|
|
||||||
shape = androidx.compose.foundation.shape.CircleShape,
|
|
||||||
)
|
)
|
||||||
|
val detail = buildList {
|
||||||
|
status.template?.let { add(it) }
|
||||||
|
add(if (status.surfaceAttached) "drawing" else "waiting for the app")
|
||||||
|
}.joinToString(" - ")
|
||||||
|
Text(detail, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
TextButton(onClick = onDisconnect) { Text("Stop", color = Color.White) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How far above the bottom of the map the area an app may draw in stops. */
|
/** Corner radius of the home screen's map pane and player. */
|
||||||
private val MAP_BOTTOM_GAP = 12.dp
|
val EMBEDDED_RADIUS = 18.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rounds the corners of a SurfaceView, which Compose cannot clip: the app draws into its own
|
||||||
|
* layer, so the corners are covered from above with the surrounding colour instead.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun CornerMask(radius: androidx.compose.ui.unit.Dp, color: Color) {
|
||||||
|
androidx.compose.foundation.Canvas(Modifier.fillMaxSize()) {
|
||||||
|
val bounds = androidx.compose.ui.geometry.Rect(0f, 0f, size.width, size.height)
|
||||||
|
val cut = androidx.compose.ui.graphics.Path().apply {
|
||||||
|
addRect(bounds)
|
||||||
|
addRoundRect(
|
||||||
|
androidx.compose.ui.geometry.RoundRect(
|
||||||
|
bounds, androidx.compose.ui.geometry.CornerRadius(radius.toPx()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
fillType = androidx.compose.ui.graphics.PathFillType.EvenOdd
|
||||||
|
}
|
||||||
|
drawPath(cut, color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Height of [ChromeBar]; the template's own chrome starts below it. */
|
/** Height of [ChromeBar]; the template's own chrome starts below it. */
|
||||||
private val CHROME_BAR = 44.dp
|
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,10 +1,7 @@
|
|||||||
package app.seb3thehacker.gearslip.car
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import app.seb3thehacker.gearslip.R
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.List
|
import androidx.compose.material.icons.filled.Info
|
||||||
import androidx.compose.material.icons.filled.Call
|
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material.icons.filled.Share
|
import androidx.compose.material.icons.filled.Share
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
@@ -16,9 +13,7 @@ import app.seb3thehacker.gearslip.ProjectedContent
|
|||||||
/** One tile in the launcher and the screen it opens. */
|
/** One tile in the launcher and the screen it opens. */
|
||||||
class CarApp(
|
class CarApp(
|
||||||
val id: String,
|
val id: String,
|
||||||
/** English, for matching what a driver says; [labelRes] is what the screen shows. */
|
|
||||||
val label: String,
|
val label: String,
|
||||||
@param:StringRes val labelRes: Int,
|
|
||||||
val icon: ImageVector,
|
val icon: ImageVector,
|
||||||
val content: @Composable () -> Unit,
|
val content: @Composable () -> Unit,
|
||||||
)
|
)
|
||||||
@@ -27,28 +22,19 @@ class CarApp(
|
|||||||
object CarApps {
|
object CarApps {
|
||||||
|
|
||||||
val all: List<CarApp> = listOf(
|
val all: List<CarApp> = listOf(
|
||||||
CarApp("web", "Browser", R.string.app_browser, Icons.Filled.Search) {
|
CarApp("web", "Web", Icons.Filled.Search) {
|
||||||
WebApp(content = ProjectedContent.resolve(LocalContext.current))
|
WebApp(content = ProjectedContent.resolve(LocalContext.current))
|
||||||
},
|
},
|
||||||
CarApp("phone", "Screen sharing", R.string.app_screen_sharing, Icons.Filled.Share) {
|
CarApp("phone", "Screen sharing", Icons.Filled.Share) {
|
||||||
PhoneAppsScreen()
|
PhoneAppsScreen()
|
||||||
},
|
},
|
||||||
CarApp("dialer", "Phone", R.string.carset_phone, Icons.Filled.Call) {
|
CarApp("calibrate", "Display test", Icons.Filled.Info) {
|
||||||
PhoneDialerScreen()
|
CalibrationApp()
|
||||||
},
|
},
|
||||||
CarApp("settings", "Settings", R.string.settings_title, Icons.Filled.Settings) {
|
CarApp("settings", "Settings", Icons.Filled.Settings) {
|
||||||
CarSettingsScreen()
|
CarSettingsScreen()
|
||||||
},
|
},
|
||||||
CarApp("allapps", "All apps", R.string.app_all_apps, Icons.Filled.List) {
|
|
||||||
AllAppsScreen()
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
fun find(id: String): CarApp? = all.firstOrNull { it.id == id }
|
fun find(id: String): CarApp? = all.firstOrNull { it.id == id }
|
||||||
|
|
||||||
/** The screens [BuiltInApps.entries] offers here, so a choice list never names a hidden one. */
|
|
||||||
fun shown(launcher: Boolean, canCall: Boolean): List<CarApp> {
|
|
||||||
val ids = BuiltInApps.entries(launcher, canCall).mapNotNull { (it.builtIn?.screen as? CarScreen.App)?.id }.toSet()
|
|
||||||
return all.filter { it.id in ids }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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,404 +0,0 @@
|
|||||||
package app.seb3thehacker.gearslip.car
|
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import app.seb3thehacker.gearslip.R
|
|
||||||
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.
|
|
||||||
*
|
|
||||||
* The one exception is a driver who typed the phone's real address into Settings. A VW MIB2 drops
|
|
||||||
* an already paired phone's Bluetooth when it connects over USB, because nothing tells it the two
|
|
||||||
* are the same phone (issue #28). With the address, Gearslip opens the channel on every connection
|
|
||||||
* and sends the same request, which is how Android Auto says so.
|
|
||||||
*/
|
|
||||||
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, how to open and write to it, and who hears its answers. */
|
|
||||||
private class Car(
|
|
||||||
val service: ServiceDiscovery.BluetoothService,
|
|
||||||
val open: () -> Unit,
|
|
||||||
val send: (messageId: Int, body: ByteArray) -> Unit,
|
|
||||||
val answered: () -> 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
|
|
||||||
/** Already paired: the channel is open only to tell the car which phone this is. */
|
|
||||||
@Volatile private var announcing = 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 || announcing }?.service?.serviceId ?: -1
|
|
||||||
|
|
||||||
fun init(context: Context) {
|
|
||||||
appContext = context.applicationContext
|
|
||||||
_addressWanted.value = AppSettings.getString(context, KEY_ADDRESS_ASK, "").ifEmpty { null }
|
|
||||||
}
|
|
||||||
|
|
||||||
private val _addressWanted = MutableStateFlow<String?>(null)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A car paired over Bluetooth connected while Gearslip didn't know the phone's address: the
|
|
||||||
* car's name, for the phone's home screen to ask for it. Null when there's nothing to ask.
|
|
||||||
* Issue #18: a 2021 Bolt hung up 25 seconds in until it was told which phone this is.
|
|
||||||
*/
|
|
||||||
val addressWanted: StateFlow<String?> = _addressWanted
|
|
||||||
|
|
||||||
/** Debug builds: shows the phone's card as if a car had just asked. */
|
|
||||||
fun askForAddressNow(context: Context) {
|
|
||||||
AppSettings.putString(context, KEY_ADDRESS_ASK, "Test car")
|
|
||||||
_addressWanted.value = "Test car"
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The phone's card was answered or turned away: it doesn't come back for this car. */
|
|
||||||
fun addressAsked(context: Context) {
|
|
||||||
AppSettings.putString(context, KEY_ADDRESS_ASK, "")
|
|
||||||
_addressWanted.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Once per car: it waits on the phone, and the car screen says so once. A car that keeps its
|
|
||||||
* screen isn't asked again on every drive: plenty of cars just wait for a tap on their icon.
|
|
||||||
*/
|
|
||||||
@SuppressLint("MissingPermission")
|
|
||||||
private fun askForAddress(device: BluetoothDevice, carAddress: String) {
|
|
||||||
val context = appContext ?: return
|
|
||||||
val asked = AppSettings.getString(context, KEY_ADDRESS_ASKED, "").split(",").filter { it.isNotEmpty() }
|
|
||||||
if (hash(carAddress) in asked) return
|
|
||||||
AppSettings.putString(context, KEY_ADDRESS_ASKED, (asked + hash(carAddress)).distinct().joinToString(","))
|
|
||||||
val name = runCatching { device.alias ?: device.name }.getOrNull()?.takeIf { it.isNotBlank() }
|
|
||||||
?: context.getString(R.string.bt_address_your_car)
|
|
||||||
AppSettings.putString(context, KEY_ADDRESS_ASK, name)
|
|
||||||
_addressWanted.value = name
|
|
||||||
log.i("paired, but the phone's address isn't known: asking on the phone")
|
|
||||||
main.post { CarToasts.show(this, context.getString(R.string.bt_address_car_note), CarToast.LENGTH_LONG) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called once the car lists its services. Asks on the car screen only if there's pairing to do.
|
|
||||||
* [answered] runs when the car answers a pairing request or the pairing finishes.
|
|
||||||
*/
|
|
||||||
fun onCarConnected(
|
|
||||||
service: ServiceDiscovery.BluetoothService,
|
|
||||||
open: () -> Unit,
|
|
||||||
send: (messageId: Int, body: ByteArray) -> Unit,
|
|
||||||
answered: () -> Unit = {},
|
|
||||||
) {
|
|
||||||
car = Car(service, open, send, answered)
|
|
||||||
bondStarted = false
|
|
||||||
announcing = false
|
|
||||||
log.i("the car offers Bluetooth pairing, methods=${service.pairingMethods}")
|
|
||||||
val device = device() ?: return
|
|
||||||
when {
|
|
||||||
device.bondState == BluetoothDevice.BOND_BONDED && typedAddress() != null -> {
|
|
||||||
log.i("already paired with this car - telling it which phone this is")
|
|
||||||
announcing = true
|
|
||||||
open()
|
|
||||||
}
|
|
||||||
device.bondState == BluetoothDevice.BOND_BONDED -> {
|
|
||||||
log.i("already paired with this car")
|
|
||||||
askForAddress(device, service.carAddress)
|
|
||||||
}
|
|
||||||
declined(service.carAddress) -> log.i("not paired, and the driver said not now for this car")
|
|
||||||
else -> _prompt.value = Prompt.ASK
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opens the car's Bluetooth channel though there's no pairing to do, as a real phone does on
|
|
||||||
* every connection. Issue #18: a 2021 Bolt hangs up about 20 seconds in, and this is one of
|
|
||||||
* the two channels it offers that Gearslip never opened. Without an address the driver typed,
|
|
||||||
* it only opens the channel: a made-up address could set the car pairing with the wrong phone.
|
|
||||||
*/
|
|
||||||
fun announce(why: String) {
|
|
||||||
val c = car ?: return
|
|
||||||
if (pairing || announcing) return
|
|
||||||
announcing = true
|
|
||||||
log.i("-> ChannelOpenRequest on channel ${c.service.serviceId} ($why)")
|
|
||||||
c.open()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The car is keeping its own screen: do what Android Auto does on every connection, and tell
|
|
||||||
* it over Bluetooth which phone this is. Every Honda on file keeps its screen (a 2017 Pilot, a
|
|
||||||
* 2017 and a 2020 Civic; issues #43 and #57), and in each log the car's Bluetooth channel was
|
|
||||||
* never opened. Honda starts Android Auto with what its manuals call auto pairing, which runs
|
|
||||||
* on that channel, and a 2021 Bolt kept hanging up until it heard the phone's address (#18).
|
|
||||||
*
|
|
||||||
* - Paired, with the address typed in: the car already heard it at connect.
|
|
||||||
* - Paired, no address: open the channel, and ask for the address on the phone again.
|
|
||||||
* - Not paired: the Pair card waits on the car's screen, which this car won't give up, so
|
|
||||||
* pair as if the driver had tapped it. Android's pairing dialog still asks on the phone.
|
|
||||||
*
|
|
||||||
* True when the car was sent something it may answer.
|
|
||||||
*/
|
|
||||||
fun onScreenWithheld(): Boolean {
|
|
||||||
val c = car ?: return false
|
|
||||||
if (pairing || announcing) return false
|
|
||||||
val device = device() ?: return false
|
|
||||||
return when {
|
|
||||||
device.bondState == BluetoothDevice.BOND_BONDED -> {
|
|
||||||
askForAddress(device, c.service.carAddress)
|
|
||||||
announce("the car is keeping its screen")
|
|
||||||
true
|
|
||||||
}
|
|
||||||
device.bondState != BluetoothDevice.BOND_NONE -> false
|
|
||||||
declined(c.service.carAddress) -> false
|
|
||||||
else -> {
|
|
||||||
log.i("the car is keeping its screen and isn't paired with this phone - pairing, as Android Auto does")
|
|
||||||
main.post { pair() }
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onCarDisconnected() {
|
|
||||||
car = null
|
|
||||||
pairing = false
|
|
||||||
announcing = 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(R.string.bt_pair_unfinished)
|
|
||||||
}, 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) {
|
|
||||||
if (announcing) log.i("the car wouldn't open its Bluetooth channel")
|
|
||||||
else main.post { startBond("the car wouldn't open its Bluetooth channel") }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (announcing && typedAddress() == null) {
|
|
||||||
log.i("Bluetooth channel open; no address typed in Settings, so no PairingRequest")
|
|
||||||
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}")
|
|
||||||
c.answered()
|
|
||||||
if (!pairing) return
|
|
||||||
// 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(R.string.bt_pair_not_started)
|
|
||||||
}
|
|
||||||
|
|
||||||
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(R.string.bt_paired)
|
|
||||||
car?.answered?.invoke()
|
|
||||||
}
|
|
||||||
previous == BluetoothDevice.BOND_BONDING -> finish(R.string.bt_pair_unfinished)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
context.registerReceiver(r, IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED), Context.RECEIVER_EXPORTED)
|
|
||||||
receiver = r
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun finish(@StringRes message: Int) {
|
|
||||||
pairing = false
|
|
||||||
stopWatching()
|
|
||||||
main.removeCallbacksAndMessages(null)
|
|
||||||
_prompt.value = Prompt.NONE
|
|
||||||
CarToasts.show(this, appContext?.getString(message) ?: return, 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 ?: run {
|
|
||||||
log.w("can't check pairing: Bluetooth isn't set up yet")
|
|
||||||
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, so the one the driver typed in wins.
|
|
||||||
* Sent either way, as the field is required. Never logged: it identifies the phone.
|
|
||||||
*/
|
|
||||||
@SuppressLint("MissingPermission", "HardwareIds")
|
|
||||||
private fun phoneAddress(): String =
|
|
||||||
typedAddress()
|
|
||||||
?: runCatching { appContext?.getSystemService(BluetoothManager::class.java)?.adapter?.address }.getOrNull()
|
|
||||||
?: "02:00:00:00:00:00"
|
|
||||||
|
|
||||||
private fun typedAddress(): String? = appContext?.let { typedAddress(it) }
|
|
||||||
|
|
||||||
/** The phone's own address as the driver typed it in Settings, or null. */
|
|
||||||
fun typedAddress(context: Context): String? =
|
|
||||||
AppSettings.getString(context, KEY_PHONE_ADDRESS, "").takeIf { BluetoothAdapter.checkBluetoothAddress(it) }
|
|
||||||
|
|
||||||
/** Saves [address] after tidying it up; false when it isn't one. Empty clears it. */
|
|
||||||
fun setTypedAddress(context: Context, address: String): Boolean {
|
|
||||||
val tidy = address.trim().uppercase().replace('-', ':')
|
|
||||||
if (tidy.isNotEmpty() && !BluetoothAdapter.checkBluetoothAddress(tidy)) return false
|
|
||||||
AppSettings.putString(context, KEY_PHONE_ADDRESS, tidy)
|
|
||||||
if (tidy.isNotEmpty()) addressAsked(context)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
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"
|
|
||||||
/** The car's name while the phone's card asks for the address; empty when it doesn't. */
|
|
||||||
private const val KEY_ADDRESS_ASK = "bluetooth_address_ask"
|
|
||||||
/** Hashes of the cars already asked about, so each asks once. */
|
|
||||||
private const val KEY_ADDRESS_ASKED = "bluetooth_address_asked"
|
|
||||||
private const val KEY_PHONE_ADDRESS = "phone_bluetooth_address"
|
|
||||||
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
|
|
||||||
}
|
|
||||||