mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f9701ccff | ||
|
|
043dc11251 | ||
|
|
48be745659 | ||
|
|
7e4b4d6b99 | ||
|
|
a8b49a3672 | ||
|
|
94f8f23a56 | ||
|
|
e1732f8365 | ||
|
|
cb895f0658 | ||
|
|
5b7b9499ca |
No files matched your search
@@ -0,0 +1,42 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Write signing key and properties
|
||||
run: |
|
||||
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > gearslip-release.jks
|
||||
cat > keystore.properties <<EOF
|
||||
storeFile=gearslip-release.jks
|
||||
storePassword=${{ secrets.KEYSTORE_STORE_PASSWORD }}
|
||||
keyAlias=${{ secrets.KEYSTORE_KEY_ALIAS }}
|
||||
keyPassword=${{ secrets.KEYSTORE_KEY_PASSWORD }}
|
||||
EOF
|
||||
|
||||
- name: Build release APK
|
||||
run: ./gradlew :gearslip:assembleRelease
|
||||
|
||||
- name: Locate APK
|
||||
id: apk
|
||||
run: |
|
||||
apk=$(find gearslip/build/outputs/apk/release -name "*.apk" | head -n1)
|
||||
echo "path=$apk" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: ${{ steps.apk.outputs.path }}
|
||||
generate_release_notes: true
|
||||
+10
-1
@@ -12,7 +12,16 @@ local.properties
|
||||
# The head unit certificate. Never committed or shared beyond who Seb sends it to directly.
|
||||
/cert
|
||||
|
||||
# The release signing key and its passwords. Losing these breaks app updates; leaking them
|
||||
# lets anyone sign an APK as Gearslip.
|
||||
*.jks
|
||||
*.keystore
|
||||
keystore.properties
|
||||
|
||||
# Local investigation script; not meant for the public repo.
|
||||
/docs/cert-sweep.sh
|
||||
archive/
|
||||
CLAUDE.md
|
||||
CLAUDE.md
|
||||
*.pdf
|
||||
extraction/
|
||||
.claude/
|
||||
@@ -4,36 +4,40 @@
|
||||
|
||||
Bring any app to your car screen.
|
||||
|
||||
## What this is
|
||||
## What Gearslip 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.
|
||||
Gearslip is an Android Auto client for the phone. It speaks the Android Auto protocol
|
||||
directly to a car head unit over USB, with no Google software in the path. Gearslip acts
|
||||
as a Car App Library host: an app that ships a car interface hands Gearslip a template,
|
||||
and Gearslip renders that template natively on the car screen. Nothing is mirrored from
|
||||
the phone. For apps without a car interface, Gearslip offers a separate screen-sharing
|
||||
mode.
|
||||
|
||||
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.
|
||||
The author reverse-engineered the protocol for interoperability, so that a client other
|
||||
than Google's own can talk to a car head unit.
|
||||
|
||||
## Status
|
||||
|
||||
Real progress, with one hard limit that's worth knowing before you build on this:
|
||||
|
||||
- **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
|
||||
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
|
||||
doesn't ship one.** A head unit only accepts a phone identity that chains to Google's
|
||||
Automotive Link certificate authority - something only Google issues. Without one, Gearslip
|
||||
still runs and renders everything correctly against a debug harness, but a real car will
|
||||
reject the connection outright. See [SPIKE_FINDINGS.md](SPIKE_FINDINGS.md) for what was
|
||||
tested, including one publicly-known leaked certificate that works on some older head
|
||||
units and is rejected by newer firmware and by Google's own reference tooling.
|
||||
- **No adb, root, or Shizuku is required to run it.** Every feature is gated on an ordinary
|
||||
Android permission or an on-device consent dialog - nothing needs privileged access, on
|
||||
the phone or in the car.
|
||||
- **Templated apps work.** Gearslip renders all 16 template types that the Car App
|
||||
Library defines, and it draws its own keyboard for search and sign-in. It has been
|
||||
tested against several real navigation and media apps.
|
||||
- **Media playback works.** Gearslip lets you browse and control car-enabled media apps
|
||||
from the car screen, with lyrics and playback state, over the same host connection.
|
||||
- **Audio reaches the car by capturing playback, not by mirroring the screen.** Android
|
||||
requires a screen-share style consent dialog and indicator for this, even though
|
||||
Gearslip never reads the screen; that grant is the only way a non-privileged app can
|
||||
tap another app's audio. The consent normally repeats every drive; the app's own setup
|
||||
steps include two optional adb commands that reduce it to a one-time grant.
|
||||
- **A real head unit needs a certificate that Google controls.** A head unit accepts only
|
||||
a phone identity that chains to Google's Automotive Link certificate authority, and
|
||||
Google alone issues those certificates. Gearslip does not ship one. Without it,
|
||||
Gearslip runs and renders correctly against a debug harness, but a real car rejects the
|
||||
connection. A leaked certificate exists that older head units accept; newer firmware
|
||||
and Google's reference tooling reject it.
|
||||
- **Gearslip needs no adb, root, or Shizuku.** Every feature relies on an ordinary
|
||||
Android permission or an on-device consent dialog. The two adb commands mentioned
|
||||
above, for audio, are the sole exception: both are optional, and both only remove a
|
||||
dialog that the driver would otherwise see once per drive.
|
||||
|
||||
## Building
|
||||
|
||||
@@ -43,14 +47,17 @@ Real progress, with one hard limit that's worth knowing before you build on this
|
||||
|
||||
The APK lands at `build/Gearslip-debug.apk`.
|
||||
|
||||
## Further reading
|
||||
A release build needs a signing key. Copy `keystore.properties.example` to
|
||||
`keystore.properties`, fill in your own values, and run
|
||||
`./gradlew :gearslip:assembleRelease`. Git ignores both the properties file and the
|
||||
keystore. Without them, the debug build still works.
|
||||
|
||||
- [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.
|
||||
- [CUSTOM_HEADUNIT_SCOPING.md](CUSTOM_HEADUNIT_SCOPING.md) - the original scoping of what a
|
||||
custom head unit client would need.
|
||||
## Writing apps for Gearslip
|
||||
|
||||
[docs/BUILDING_APPS.md](docs/BUILDING_APPS.md) specifies how to write apps that Gearslip
|
||||
can render: templated apps, media apps, and the Gearslip app protocol.
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0 - see [LICENSE](LICENSE). A separate commercial license is available for private
|
||||
use outside the AGPL's terms; see [NOTICE.md](NOTICE.md).
|
||||
Gearslip is licensed under AGPL-3.0; see [LICENSE](LICENSE). The author offers a separate
|
||||
commercial license for private use outside the AGPL's terms; see [NOTICE.md](NOTICE.md).
|
||||
@@ -0,0 +1,421 @@
|
||||
# Building apps for Gearslip
|
||||
|
||||
Gearslip renders three kinds of app on a car screen. This page specifies all three.
|
||||
|
||||
| Kind | What it is | Status |
|
||||
| --- | --- | --- |
|
||||
| [Templated apps](#templated-apps) | An AndroidX Car App Library app. Gearslip hosts it. | Works today |
|
||||
| [Media apps](#media-apps) | A `MediaBrowserService` that opted in to Android Auto. | Works today |
|
||||
| [Gearslip apps](#gearslip-apps) | A native Gearslip screen, described over binder. | Specified here; not yet implemented |
|
||||
|
||||
Write a templated app when your app fits the Car App Library's templates, because it runs today
|
||||
and it also runs on Android Auto. Write a media app when you ship audio. Write a Gearslip app
|
||||
when you want a screen the Car App Library cannot describe.
|
||||
|
||||
Gearslip's package name is `app.seb3thehacker.gearslip`. It declares
|
||||
`android.car.permission.TEMPLATE_RENDERER`.
|
||||
|
||||
---
|
||||
|
||||
## Templated apps
|
||||
|
||||
A templated app exports a `CarAppService`, describes each screen as a `Template`, and lets the
|
||||
host draw it. Gearslip implements the host half of that protocol, so an existing Android Auto app
|
||||
needs no Gearslip-specific code. Build it against the AndroidX Car App Library as you would for
|
||||
any other host.
|
||||
|
||||
### Letting Gearslip in
|
||||
|
||||
Your app decides which hosts may render it, through the `HostValidator` your `CarAppService`
|
||||
returns.
|
||||
|
||||
Gearslip declares `android.car.permission.TEMPLATE_RENDERER`, and the library's own
|
||||
`HostValidator` accepts any host holding it. If you build your validator with
|
||||
`HostValidator.Builder` and no allowlist, Gearslip already passes, and you need nothing below.
|
||||
Add an allowlist entry when you wrote a custom validator, or when you want to name Gearslip
|
||||
explicitly:
|
||||
|
||||
```xml
|
||||
<!-- res/values/arrays.xml -->
|
||||
<array name="hosts_allowlist">
|
||||
<item>4cc56ebdd13a54d260eca9b7e2b2ae6b30e2f225bfda8f508f0709edf084948b,app.seb3thehacker.gearslip</item>
|
||||
</array>
|
||||
```
|
||||
|
||||
```java
|
||||
@Override
|
||||
public HostValidator createHostValidator() {
|
||||
return new HostValidator.Builder(getApplicationContext())
|
||||
.addAllowedHosts(R.array.hosts_allowlist)
|
||||
.build();
|
||||
}
|
||||
```
|
||||
|
||||
That digest belongs to Gearslip's release signing certificate. A Gearslip you built yourself
|
||||
carries your own debug key and a different digest, so read the fingerprint off the APK you
|
||||
actually installed when an allowlist entry fails to match.
|
||||
|
||||
Write each entry as the digest first, then a comma, then the package name. The digest is the
|
||||
SHA-256 of the host's signing certificate as 64 lowercase hex characters, with no colons and no
|
||||
`0x` prefix. The builder lowercases the entry and strips spaces, but it keeps colons, so convert
|
||||
`keytool` output before pasting it:
|
||||
|
||||
```
|
||||
keytool -list -v -keystore <keystore> -alias <alias> \
|
||||
| grep 'SHA256:' | cut -d' ' -f3 | tr -d ':' | tr 'A-Z' 'a-z'
|
||||
```
|
||||
|
||||
Reading the installed APK is more reliable, because it fingerprints the key that actually signed
|
||||
the build, and `apksigner` already prints the required format:
|
||||
|
||||
```
|
||||
apksigner verify --print-certs Gearslip.apk | grep 'SHA-256 digest'
|
||||
```
|
||||
|
||||
During development, `HostValidator.ALLOW_ALL_HOSTS_VALIDATOR` accepts every host. Ship a real
|
||||
validator instead.
|
||||
|
||||
An app that refuses a host returns no binder at all. Gearslip reports that as
|
||||
`the app refused to bind - host not accepted`. Your own side logs the reason under the tag
|
||||
`CarApp.Val`, including the exact `addAllowedHost` call to add, so read logcat there first.
|
||||
|
||||
### Discovery
|
||||
|
||||
Gearslip finds templated apps by querying `androidx.car.app.CarAppService`, then re-querying with
|
||||
each known category to recover which ones your intent filter matched. Declare the categories you
|
||||
belong to, or Gearslip files you under `app`:
|
||||
|
||||
```xml
|
||||
<service android:name=".MyCarAppService" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="androidx.car.app.CarAppService" />
|
||||
<category android:name="androidx.car.app.category.NAVIGATION" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
```
|
||||
|
||||
Gearslip recognises the `NAVIGATION`, `POI`, `PARKING`, `CHARGING`, `MESSAGING`, `CALLING`,
|
||||
`SETTINGS`, `IOT` and `WEATHER` categories. Navigation apps start automatically on the home
|
||||
screen when the driver used them last.
|
||||
|
||||
### The handshake
|
||||
|
||||
Gearslip offers car API level 8. It negotiates down to
|
||||
`min(8, yourLatestCarAppApiLevel)` and refuses the app when that falls below your
|
||||
`minCarAppApiLevel`, rather than drawing it wrongly. Keep your minimum at 8 or below.
|
||||
|
||||
### Templates Gearslip renders
|
||||
|
||||
Gearslip renders all sixteen templates. Ten describe their own content:
|
||||
|
||||
`ListTemplate`, `GridTemplate`, `PaneTemplate`, `MessageTemplate`, `LongMessageTemplate`,
|
||||
`SearchTemplate`, `SignInTemplate`, `TabTemplate`, `SectionedItemTemplate`,
|
||||
`MediaPlaybackTemplate`.
|
||||
|
||||
Six draw a map underneath and take chrome over the top:
|
||||
|
||||
`NavigationTemplate`, `MapWithContentTemplate`, `MapTemplate`, `PlaceListNavigationTemplate`,
|
||||
`RoutePreviewNavigationTemplate`, `PlaceListMapTemplate`.
|
||||
|
||||
`SearchTemplate` and `SignInTemplate` need text. A phone keyboard cannot appear on the car's
|
||||
virtual display, so Gearslip draws its own and feeds the result back through the template's
|
||||
listener.
|
||||
|
||||
### Content limits
|
||||
|
||||
Gearslip reports these limits through the constraint service, and expects you to honour them:
|
||||
|
||||
| Limit | Value |
|
||||
| --- | --- |
|
||||
| Grid items | 12 |
|
||||
| Pane rows | 4 |
|
||||
| Route list rows | 3 |
|
||||
| Anything else | 12 |
|
||||
|
||||
Gearslip enables app-driven refresh, so you may invalidate your own template without the driver
|
||||
touching the screen.
|
||||
|
||||
### Drawing a map
|
||||
|
||||
A navigation app draws its own pixels. Gearslip lends it a real `Surface` taken from a
|
||||
`SurfaceView` inside the car's virtual display, so your map reaches the head unit through the
|
||||
existing video pipe. Gearslip never reads those pixels back.
|
||||
|
||||
Gearslip then reports which part of that surface the driver can see. Treat the visible area as
|
||||
the region safe for your own controls, and the stable area as the region that never gets
|
||||
covered. Gearslip keeps its chrome to a top bar, so both areas are the surface minus that bar.
|
||||
|
||||
Your app receives four gestures, never raw touches: `onClick`, `onScroll`, `onFling` and
|
||||
`onScale`. Coordinates are surface pixels. Scroll distances follow `GestureDetector`'s
|
||||
convention, which reports the previous position minus the current one.
|
||||
|
||||
### Car hardware
|
||||
|
||||
A phone knows nothing about a car's sensors, so Gearslip answers every car-hardware request with
|
||||
"unsupported". It answers rather than ignores: an app whose sensor manager waits for a reply
|
||||
hangs when the host stays silent. Design for a host that reports no vehicle data at all.
|
||||
|
||||
Gearslip accepts the suggestions an app pushes and discards them, because no screen shows them
|
||||
yet. Your app will not fail for offering them.
|
||||
|
||||
### Day and night
|
||||
|
||||
Gearslip decides whether it is dark outside and pushes that to your app as a configuration
|
||||
change, with `UI_MODE_NIGHT_YES` or `UI_MODE_NIGHT_NO`. Take your map style from the
|
||||
configuration the host reports. The configuration also carries the car's density, screen size and
|
||||
orientation, and always sets `UI_MODE_TYPE_CAR`.
|
||||
|
||||
---
|
||||
|
||||
## Media apps
|
||||
|
||||
Gearslip draws the media UI itself: browsing, artwork, lyrics, transport controls and a
|
||||
now-playing pill in the nav bar. Your app supplies a browse tree and a media session.
|
||||
|
||||
Gearslip lists a media app when it exports a `MediaBrowserService` **and** declares the Android
|
||||
Auto meta-data. Both halves are required, because the service alone also appears on phone-only
|
||||
players that expose a browser to Bluetooth and Wear:
|
||||
|
||||
```xml
|
||||
<application>
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/automotive_app_desc" />
|
||||
</application>
|
||||
```
|
||||
|
||||
```xml
|
||||
<!-- res/xml/automotive_app_desc.xml -->
|
||||
<automotiveApp>
|
||||
<uses name="media" />
|
||||
</automotiveApp>
|
||||
```
|
||||
|
||||
Publish playback state, metadata, duration and artwork through your `MediaSession`, and support
|
||||
play, pause, next, previous and seek. Gearslip reconnects to the app the driver used last and
|
||||
can start it playing on connection.
|
||||
|
||||
---
|
||||
|
||||
## Gearslip apps
|
||||
|
||||
This section specifies protocol version 1. Gearslip does not yet implement it.
|
||||
|
||||
A Gearslip app describes a screen and lets Gearslip render it in Compose, using the car's theme,
|
||||
the driver's chosen scale and the current day or night state. Your app draws nothing and receives
|
||||
no surface. Everything the driver sees comes from the model below, which means your screen
|
||||
matches the rest of the car UI and cannot be made unreadable.
|
||||
|
||||
Write a Gearslip app to reach the parts of the car UI the Car App Library does not cover. Write a
|
||||
templated app if you need to draw a map, because version 1 lends no surface.
|
||||
|
||||
### Discovery
|
||||
|
||||
Export a service with the Gearslip action and declare the protocol version it speaks:
|
||||
|
||||
```xml
|
||||
<service
|
||||
android:name=".GearslipService"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="app.seb3thehacker.gearslip.APP" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="app.seb3thehacker.gearslip.version"
|
||||
android:value="1" />
|
||||
</service>
|
||||
```
|
||||
|
||||
Gearslip scans for these services once per car session and shows each one as a tile in the
|
||||
launcher, under your application's own label and icon. To override either on the car screen, add
|
||||
`app.seb3thehacker.gearslip.label` or `app.seb3thehacker.gearslip.icon`.
|
||||
|
||||
Gearslip binds to any app that declares this service. It asks the driver for nothing, exactly as
|
||||
a head unit asks nothing before running an Android Auto app.
|
||||
|
||||
Gearslip binds your service when the driver opens your tile and unbinds it when they leave. Keep
|
||||
no state that a rebind cannot rebuild.
|
||||
|
||||
### The interface
|
||||
|
||||
Copy these three files into `src/main/aidl/app/seb3thehacker/gearslip/api/`. A published artifact
|
||||
will replace them.
|
||||
|
||||
```aidl
|
||||
// IGearslipApp.aidl
|
||||
package app.seb3thehacker.gearslip.api;
|
||||
|
||||
import app.seb3thehacker.gearslip.api.IGearslipHost;
|
||||
import app.seb3thehacker.gearslip.api.IScreenCallback;
|
||||
|
||||
interface IGearslipApp {
|
||||
/** Return the highest version both sides speak. Gearslip passes the version it offers. */
|
||||
int negotiate(int hostVersion);
|
||||
|
||||
void onCreate(IGearslipHost host);
|
||||
void onStart();
|
||||
void onStop();
|
||||
|
||||
/** Describe the current screen. Answer through the callback, on any thread. */
|
||||
void getScreen(IScreenCallback callback);
|
||||
|
||||
void onAction(String actionId);
|
||||
void onTextInput(String fieldId, String text);
|
||||
}
|
||||
```
|
||||
|
||||
```aidl
|
||||
// IGearslipHost.aidl
|
||||
package app.seb3thehacker.gearslip.api;
|
||||
|
||||
interface IGearslipHost {
|
||||
/** Tell Gearslip the screen changed. It calls getScreen again. */
|
||||
void invalidate();
|
||||
|
||||
/** Open the car keyboard. The result arrives at onTextInput. */
|
||||
void requestTextInput(String fieldId, String prompt, String initialText);
|
||||
|
||||
String getEnvironment();
|
||||
String getNowPlaying();
|
||||
|
||||
/** One of play, pause, toggle, next, previous. */
|
||||
void mediaCommand(String command);
|
||||
|
||||
/** Leave the app and return to the launcher. */
|
||||
void finish();
|
||||
}
|
||||
```
|
||||
|
||||
```aidl
|
||||
// IScreenCallback.aidl
|
||||
package app.seb3thehacker.gearslip.api;
|
||||
|
||||
interface IScreenCallback {
|
||||
void onScreen(String screenJson);
|
||||
void onError(String message);
|
||||
}
|
||||
```
|
||||
|
||||
### Versioning
|
||||
|
||||
Gearslip calls `negotiate` first, passing the version it offers. Return the highest version you
|
||||
also speak. Gearslip uses that version for the rest of the connection and drops the app when the
|
||||
answer exceeds what it offered. This mirrors how Gearslip negotiates car API levels with
|
||||
templated apps, and it lets either side move first.
|
||||
|
||||
Version 1 is the only version. Reject anything you do not recognise by returning 1.
|
||||
|
||||
### Lifecycle
|
||||
|
||||
1. Gearslip binds your service and calls `negotiate`.
|
||||
2. Gearslip calls `onCreate`, handing you the host interface. Keep it.
|
||||
3. Gearslip calls `onStart`, then `getScreen`.
|
||||
4. You answer through `onScreen`. Gearslip renders it.
|
||||
5. The driver taps something. Gearslip calls `onAction` with that element's id.
|
||||
6. Your screen changes, so you call `invalidate`. Gearslip calls `getScreen` again.
|
||||
7. The driver leaves. Gearslip calls `onStop` and unbinds.
|
||||
|
||||
Every call arrives on a binder thread. Answer `getScreen` promptly; Gearslip shows the previous
|
||||
screen until you do. Gearslip coalesces repeated `invalidate` calls.
|
||||
|
||||
### The screen model
|
||||
|
||||
Answer `getScreen` with a JSON object.
|
||||
|
||||
```json
|
||||
{
|
||||
"protocol": 1,
|
||||
"type": "list",
|
||||
"title": "Stations",
|
||||
"items": [
|
||||
{ "id": "r6", "title": "BBC Radio 6", "subtitle": "Music", "icon": "play" },
|
||||
{ "id": "r4", "title": "BBC Radio 4", "subtitle": "Speech", "icon": "play" }
|
||||
],
|
||||
"actions": [
|
||||
{ "id": "refresh", "title": "Refresh", "icon": "refresh", "primary": true }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`type` takes one of four values:
|
||||
|
||||
| Type | Renders as | Uses |
|
||||
| --- | --- | --- |
|
||||
| `list` | A vertical list of rows | `items` |
|
||||
| `grid` | A grid of tiles | `items` |
|
||||
| `pane` | A short detail pane beside its actions | `items`, `actions` |
|
||||
| `message` | A single message and its actions | `text`, `actions` |
|
||||
|
||||
An item takes `id`, `title`, and optionally `subtitle`, `icon` and `enabled`. Gearslip calls
|
||||
`onAction` with the item's `id` when the driver taps it. An item with `"type": "input"` renders
|
||||
as a text field carrying `value`; tapping it opens the car keyboard, and the result arrives at
|
||||
`onTextInput` under the item's id.
|
||||
|
||||
An action takes `id`, `title`, and optionally `icon` and `primary`. Gearslip renders at most two
|
||||
actions and emphasises the primary one.
|
||||
|
||||
`icon` names a built-in glyph: `play`, `pause`, `next`, `previous`, `search`, `settings`, `home`,
|
||||
`apps`, `notifications`, `info`, `share`, `place`, `favorite`, `add`, `remove`, `refresh`,
|
||||
`check`, `error`. Version 1 carries no images. Gearslip drops an unknown name and renders the
|
||||
row without a glyph.
|
||||
|
||||
Gearslip truncates any list past the limits in [Content limits](#content-limits) and shortens
|
||||
long strings. Send a screen that fits, rather than one Gearslip has to cut.
|
||||
|
||||
### Reading the car
|
||||
|
||||
`getEnvironment` returns the car's current state:
|
||||
|
||||
```json
|
||||
{
|
||||
"protocol": 1,
|
||||
"darkOutside": true,
|
||||
"moving": false,
|
||||
"frameWidth": 800,
|
||||
"frameHeight": 480,
|
||||
"densityDpi": 160,
|
||||
"batteryPercent": 82,
|
||||
"charging": true,
|
||||
"vehicle": "Uconnect"
|
||||
}
|
||||
```
|
||||
|
||||
`darkOutside` reports whether Gearslip believes it is dark, from the light sensor or the clock.
|
||||
`moving` reports whether the phone's GPS shows the car moving. Both change during a session, so
|
||||
read them each time you build a screen rather than caching them.
|
||||
|
||||
Treat `moving` as a comfort signal, not a safety guarantee. It reads false whenever the fix is
|
||||
stale or missing, which includes tunnels and car parks.
|
||||
|
||||
### Controlling media
|
||||
|
||||
`getNowPlaying` returns what the car is playing, whichever app owns it:
|
||||
|
||||
```json
|
||||
{ "title": "Sleepwalk", "artist": "Santo & Johnny", "playing": true, "durationMs": 142000 }
|
||||
```
|
||||
|
||||
`mediaCommand` sends one of `play`, `pause`, `toggle`, `next` or `previous` to that app. Both
|
||||
work regardless of which app is playing, so use them to put transport controls on your own
|
||||
screen. Ship audio through a media app instead of driving these.
|
||||
|
||||
### Text input
|
||||
|
||||
A phone keyboard cannot appear on the car's virtual display, so Gearslip draws its own. Call
|
||||
`requestTextInput` with a field id, a prompt and any existing text. Gearslip opens the keyboard
|
||||
and calls `onTextInput` as the driver types, so treat each call as the current contents rather
|
||||
than a final answer.
|
||||
|
||||
### What version 1 leaves out
|
||||
|
||||
Version 1 lends no surface, so no Gearslip app draws its own pixels. It carries no images beyond
|
||||
the built-in glyphs. It places apps only in the launcher; contributing a dashboard card or a
|
||||
home-screen pane comes later. Anything that needs its own rendering belongs in a templated app.
|
||||
|
||||
---
|
||||
|
||||
## Further reading
|
||||
|
||||
- [README.md](../README.md) - what Gearslip is and how to build it.
|
||||
- [Android for Cars App Library](https://developer.android.com/training/cars/apps) - the
|
||||
reference for templated apps.
|
||||
@@ -1,3 +1,4 @@
|
||||
import java.util.Properties
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
|
||||
|
||||
@@ -13,16 +14,45 @@ plugins {
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
}
|
||||
|
||||
// The release signing key, read from an untracked keystore.properties beside this file or at the
|
||||
// repository root. Third-party apps allowlist Gearslip by the SHA-256 of this certificate, so the
|
||||
// key must stay the same for the life of the project - and must never be committed.
|
||||
val keystoreProperties = Properties().apply {
|
||||
listOf(
|
||||
rootProject.file("keystore.properties"),
|
||||
project.file("keystore.properties"),
|
||||
).firstOrNull { it.exists() }?.inputStream()?.use { load(it) }
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "app.seb3thehacker.gearslip"
|
||||
compileSdk = 37
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "app.seb3thehacker.gearslip"
|
||||
minSdk = 35
|
||||
minSdk = 31
|
||||
targetSdk = 37
|
||||
versionCode = 2
|
||||
versionName = "0.0.02-pre"
|
||||
versionCode = 5
|
||||
versionName = "0.0.05-alpha"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
// Declared only when the properties file is present, so a clone without the key still
|
||||
// builds debug; assembleRelease then falls back to unsigned rather than failing here.
|
||||
if (keystoreProperties.getProperty("storeFile") != null) {
|
||||
create("release") {
|
||||
storeFile = rootProject.file(keystoreProperties.getProperty("storeFile"))
|
||||
storePassword = keystoreProperties.getProperty("storePassword")
|
||||
keyAlias = keystoreProperties.getProperty("keyAlias")
|
||||
keyPassword = keystoreProperties.getProperty("keyPassword")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig = signingConfigs.findByName("release")
|
||||
}
|
||||
}
|
||||
|
||||
// Keeps build output out of the module folder: <root>/build/gearslip/... instead of
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import app.seb3thehacker.gearslip.audio.CarAudio
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* Debug-only stand-in for the head unit's media audio sink.
|
||||
*
|
||||
* The car end of the audio path can only be exercised in a car, but everything before it - the
|
||||
* setting, the consent dialog, the capture service, and whether the media app allows itself to be
|
||||
* heard at all - is phone-side and can be tested at a desk. This sink takes the place of
|
||||
* [AudioLink] so that path runs to completion, and reports what arrived.
|
||||
*
|
||||
* adb shell am start -n app.seb3thehacker.gearslip/.CarPreviewActivity \
|
||||
* --ei w 800 --ei h 480 --ez audio true --ei snaps 10
|
||||
*
|
||||
* A healthy run logs a rising byte count and a peak well above zero. A byte count that rises with
|
||||
* a peak of zero means the app refused to be captured.
|
||||
*/
|
||||
class BenchAudioSink(override val sampleRate: Int = 48_000, override val channels: Int = 2) : CarAudio.Link {
|
||||
|
||||
private var chunks = 0
|
||||
private var bytes = 0L
|
||||
private var peak = 0
|
||||
private var reportedAt = 0L
|
||||
|
||||
override fun start() {
|
||||
chunks = 0
|
||||
bytes = 0
|
||||
peak = 0
|
||||
reportedAt = System.currentTimeMillis()
|
||||
GearslipLog.i("bench sink: started - pretending to be a ${sampleRate}Hz x$channels car sink")
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
GearslipLog.i("bench sink: stopped after $chunks chunks / $bytes bytes")
|
||||
}
|
||||
|
||||
override fun write(pcm: ByteArray, size: Int) {
|
||||
chunks++
|
||||
bytes += size
|
||||
for (i in 0 until size - 1 step 2) {
|
||||
val sample = (pcm[i].toInt() and 0xFF) or (pcm[i + 1].toInt() shl 8)
|
||||
peak = maxOf(peak, abs(sample.toShort().toInt()))
|
||||
}
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - reportedAt < REPORT_MS) return
|
||||
reportedAt = now
|
||||
val seconds = bytes / (sampleRate.toDouble() * channels * 2)
|
||||
GearslipLog.i(
|
||||
"bench sink: %d chunks / %d bytes (%.1fs of audio), peak %.3f%s".format(
|
||||
chunks, bytes, seconds, peak / 32768f,
|
||||
if (peak == 0) " - SILENT, the app is not letting itself be captured" else "",
|
||||
),
|
||||
)
|
||||
peak = 0
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val REPORT_MS = 2_000L
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,9 @@ import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.MotionEvent
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import app.seb3thehacker.gearslip.audio.AudioCaptureService
|
||||
import app.seb3thehacker.gearslip.audio.CarAudio
|
||||
import app.seb3thehacker.gearslip.car.CarEnvironment
|
||||
import app.seb3thehacker.gearslip.car.CarSettings
|
||||
import app.seb3thehacker.gearslip.car.CarUi
|
||||
@@ -31,6 +34,28 @@ class CarPreviewActivity : ComponentActivity() {
|
||||
private var reader: ImageReader? = null
|
||||
@Volatile private var latest: Bitmap? = null
|
||||
|
||||
private val audioPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
if (granted) askAudioCapture() else CarAudio.denied()
|
||||
}
|
||||
|
||||
private val audioConsent = registerForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult(),
|
||||
) { result ->
|
||||
val data = result.data
|
||||
if (result.resultCode == RESULT_OK && data != null) {
|
||||
AudioCaptureService.start(this, result.resultCode, data)
|
||||
} else {
|
||||
GearslipLog.w("preview: audio capture consent declined")
|
||||
CarAudio.denied()
|
||||
}
|
||||
}
|
||||
|
||||
private fun askAudioCapture() {
|
||||
val manager = getSystemService(android.media.projection.MediaProjectionManager::class.java)
|
||||
runCatching { audioConsent.launch(manager.createScreenCaptureIntent()) }
|
||||
.onFailure { GearslipLog.e("preview: could not ask for audio capture", it); CarAudio.denied() }
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
GearslipLog.init(this)
|
||||
@@ -58,6 +83,22 @@ class CarPreviewActivity : ComponentActivity() {
|
||||
val p = ScreenProjector(this).also { projector = it }
|
||||
p.start(imageReader.surface, w, h, dpi) { CarUi() }
|
||||
|
||||
// Stand in for the car's media sink, so the phone half of the audio path runs for real.
|
||||
if (intent.getBooleanExtra("audio", false)) {
|
||||
CarAudio.onConsentRequested = {
|
||||
runOnUiThread {
|
||||
if (checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) ==
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
askAudioCapture()
|
||||
} else {
|
||||
audioPermission.launch(android.Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
}
|
||||
}
|
||||
CarAudio.attach(BenchAudioSink())
|
||||
}
|
||||
|
||||
GearslipLog.i("preview: displayId=${p.displayId}")
|
||||
if (intent.getBooleanExtra("hide", false)) p.setOverlayVisible(false)
|
||||
|
||||
@@ -76,6 +117,27 @@ class CarPreviewActivity : ComponentActivity() {
|
||||
GearslipLog.i("preview: wrote ${out.name}")
|
||||
step++
|
||||
}
|
||||
// A two-finger pinch: "cx,cy,startGap,endGap" in projected pixels, after "pinchAfter" ms.
|
||||
intent.getStringExtra("pinch")?.let { spec ->
|
||||
val (cx, cy, gap0, gap1) = spec.split(",").map { it.toFloat() }
|
||||
val pair = { gap: Float -> listOf(TouchPoint(3, cx - gap / 2, cy), TouchPoint(7, cx + gap / 2, cy)) }
|
||||
val after = intent.getIntExtra("pinchAfter", 9000).toLong()
|
||||
main.postDelayed({
|
||||
snap()
|
||||
p.dispatchTouch(MotionEvent.ACTION_DOWN, 0, listOf(pair(gap0)[0]))
|
||||
p.dispatchTouch(MotionEvent.ACTION_POINTER_DOWN, 1, pair(gap0))
|
||||
val steps = 14
|
||||
for (i in 1..steps) main.postDelayed({
|
||||
p.dispatchTouch(MotionEvent.ACTION_MOVE, 0, pair(gap0 + (gap1 - gap0) * i / steps))
|
||||
}, 40L * i)
|
||||
main.postDelayed({
|
||||
p.dispatchTouch(MotionEvent.ACTION_POINTER_UP, 1, pair(gap1))
|
||||
p.dispatchTouch(MotionEvent.ACTION_UP, 0, listOf(pair(gap1)[0]))
|
||||
}, 40L * (steps + 1))
|
||||
main.postDelayed({ snap() }, 40L * (steps + 1) + 2000)
|
||||
}, after)
|
||||
}
|
||||
|
||||
// Free-running mode: keep snapshotting on a timer so the display can be driven from
|
||||
// outside (adb `am start --display` / `input -d`) while the harness records it.
|
||||
val snaps = intent.getIntExtra("snaps", 0)
|
||||
@@ -89,8 +151,9 @@ class CarPreviewActivity : ComponentActivity() {
|
||||
snap()
|
||||
taps.forEachIndexed { i, (x, y) ->
|
||||
main.postDelayed({
|
||||
p.dispatchTouch(MotionEvent.ACTION_DOWN, x, y)
|
||||
main.postDelayed({ p.dispatchTouch(MotionEvent.ACTION_UP, x, y) }, 80)
|
||||
val point = listOf(TouchPoint(0, x, y))
|
||||
p.dispatchTouch(MotionEvent.ACTION_DOWN, 0, point)
|
||||
main.postDelayed({ p.dispatchTouch(MotionEvent.ACTION_UP, 0, point) }, 80)
|
||||
}, 2500L * (i + 1))
|
||||
main.postDelayed({ snap() }, 2500L * (i + 1) + 1500)
|
||||
}
|
||||
@@ -101,6 +164,8 @@ class CarPreviewActivity : ComponentActivity() {
|
||||
super.onDestroy()
|
||||
projector?.stop()
|
||||
reader?.close()
|
||||
CarAudio.onConsentRequested = null
|
||||
CarAudio.release()
|
||||
CarEnvironment.stop()
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
<?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. -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
@@ -23,6 +24,36 @@
|
||||
<!-- Capturing a media app's playback (to send to the car) is gated on this by the platform. -->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
|
||||
<!--
|
||||
Playback capture copies a stream rather than moving it, so the phone keeps playing the
|
||||
music it is sending to the car. Silencing the phone's own speaker for the drive needs this.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
|
||||
<!--
|
||||
Audio capture counts as a screen share, and Android hides notifications during one. The
|
||||
protection can be switched off for the drive, but only with this, granted once over adb
|
||||
(pm grant). Without it Gearslip works the same and notifications stay hidden.
|
||||
-->
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_SECURE_SETTINGS"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
|
||||
<!--
|
||||
Phone calls on the car screen. READ_PHONE_STATE and READ_CALL_LOG together are what a
|
||||
call's own state (ringing, in progress) and the incoming number need; READ_CONTACTS
|
||||
turns that number into a name; ANSWER_PHONE_CALLS lets the driver pick up from here.
|
||||
Declining a call, and the caller ID itself, need Gearslip set as the phone's caller ID
|
||||
and spam app too - an on-device consent, not a permission declared here.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||
<uses-permission android:name="android.permission.READ_CALL_LOG" />
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
|
||||
|
||||
<!-- Placing a call from the car screen's dial pad or contacts list needs this. -->
|
||||
<uses-permission android:name="android.permission.CALL_PHONE" />
|
||||
|
||||
<!--
|
||||
Declared, not just requested. Templated apps decide whether to talk to a host by asking
|
||||
whether it holds this permission (HostValidator.isValidHost checks it before falling back
|
||||
@@ -38,6 +69,10 @@
|
||||
<uses-feature android:name="android.hardware.usb.accessory" android:required="true" />
|
||||
|
||||
<queries>
|
||||
<!-- Named directly: current builds have no launcher-category entry point for the MAIN/
|
||||
LAUNCHER query below to find, so the setup guide's Android Auto check needs this to see
|
||||
whether the package is installed at all. -->
|
||||
<package android:name="com.google.android.projection.gearhead" />
|
||||
<intent>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
@@ -60,6 +95,17 @@
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Gearslip">
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.logshare"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/share_paths" />
|
||||
</provider>
|
||||
|
||||
|
||||
<!--
|
||||
singleTop: the head unit can re-attach while we are already foregrounded, which
|
||||
arrives via onNewIntent rather than a fresh instance.
|
||||
@@ -131,5 +177,21 @@
|
||||
<action android:name="android.service.notification.NotificationListenerService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!--
|
||||
Sees incoming calls before they ring, so the car screen can show the caller and
|
||||
decline it. Inert until the driver sets Gearslip as the phone's caller ID and spam
|
||||
app, under Settings > Apps > Default apps - the same consent a call-blocking app asks
|
||||
for, not a privileged one.
|
||||
-->
|
||||
<service
|
||||
android:name=".call.GearslipCallScreening"
|
||||
android:exported="true"
|
||||
android:label="Gearslip calls"
|
||||
android:permission="android.permission.BIND_SCREENING_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.telecom.CallScreeningService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -8,6 +8,9 @@ object AppSettings {
|
||||
private const val PREFS = "gearslip_settings"
|
||||
private const val KEY_STARTUP_URL = "startup_url"
|
||||
private const val KEY_SEEN_COMPAT_WARNING = "seen_compat_warning"
|
||||
private const val KEY_SKIPPED_CERT_SETUP = "skipped_cert_setup"
|
||||
private const val KEY_DEBUG_MODE = "debug_mode"
|
||||
private const val KEY_SEEN_PERMISSIONS_SETUP = "seen_permissions_setup"
|
||||
|
||||
private fun prefs(context: Context) =
|
||||
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
@@ -34,4 +37,32 @@ object AppSettings {
|
||||
fun setSeenCompatWarning(context: Context) {
|
||||
prefs(context).edit().putBoolean(KEY_SEEN_COMPAT_WARNING, true).apply()
|
||||
}
|
||||
|
||||
/** Whether the certificate setup screen has been deliberately skipped. */
|
||||
fun hasSkippedCertSetup(context: Context): Boolean =
|
||||
prefs(context).getBoolean(KEY_SKIPPED_CERT_SETUP, false)
|
||||
|
||||
fun setSkippedCertSetup(context: Context) {
|
||||
prefs(context).edit().putBoolean(KEY_SKIPPED_CERT_SETUP, true).apply()
|
||||
}
|
||||
|
||||
/** Whether the one-by-one permissions wizard has already run once. */
|
||||
fun hasSeenPermissionsSetup(context: Context): Boolean =
|
||||
prefs(context).getBoolean(KEY_SEEN_PERMISSIONS_SETUP, false)
|
||||
|
||||
fun setSeenPermissionsSetup(context: Context) {
|
||||
prefs(context).edit().putBoolean(KEY_SEEN_PERMISSIONS_SETUP, true).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug mode: full protocol tracing in the log, and the developer screens (live logs, car
|
||||
* preview) on the home screen. On by default while the project is pre-release.
|
||||
*/
|
||||
fun debugMode(context: Context): Boolean =
|
||||
prefs(context).getBoolean(KEY_DEBUG_MODE, true)
|
||||
|
||||
fun setDebugMode(context: Context, value: Boolean) {
|
||||
prefs(context).edit().putBoolean(KEY_DEBUG_MODE, value).apply()
|
||||
GearslipLog.debug = value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
/**
|
||||
* Fetches the publicly available head unit certificate and key from the opencardev/aasdk
|
||||
* repository on GitHub. The app ships no certificates; it downloads them only when the
|
||||
* user chooses to, the same way a browser would.
|
||||
*/
|
||||
object CertDownloader {
|
||||
|
||||
private val log = GearslipLog.tagged("DOWNL")
|
||||
|
||||
private const val BASE = "https://raw.githubusercontent.com/opencardev/aasdk/main/cert"
|
||||
private const val CRT = "$BASE/headunit.crt"
|
||||
private const val KEY = "$BASE/headunit.key"
|
||||
private const val CONNECT_TIMEOUT_MS = 10_000
|
||||
private const val READ_TIMEOUT_MS = 15_000
|
||||
|
||||
class Downloaded(val certificatePem: String, val privateKeyPem: String)
|
||||
|
||||
/** Fetches both files; throws with a readable message on any failure. */
|
||||
fun fetch(): Downloaded {
|
||||
log.i("fetching certificate from $BASE")
|
||||
val crt = fetch(CRT, "certificate")
|
||||
val key = fetch(KEY, "private key")
|
||||
log.i("downloaded certificate (${crt.length} bytes) and key (${key.length} bytes)")
|
||||
return Downloaded(crt, key)
|
||||
}
|
||||
|
||||
private fun fetch(url: String, label: String): String {
|
||||
val body = try {
|
||||
val conn = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = CONNECT_TIMEOUT_MS
|
||||
readTimeout = READ_TIMEOUT_MS
|
||||
}
|
||||
conn.inputStream.use { input ->
|
||||
val out = ByteArrayOutputStream()
|
||||
val buf = ByteArray(4096)
|
||||
var n: Int
|
||||
while (input.read(buf).also { n = it } != -1) out.write(buf, 0, n)
|
||||
out.toString(Charsets.UTF_8)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
throw RuntimeException("could not download the $label: ${e.message}", e)
|
||||
}
|
||||
if (body.isBlank()) throw RuntimeException("the $label file is empty")
|
||||
return body
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.security.KeyStore
|
||||
import java.security.cert.X509Certificate
|
||||
@@ -14,7 +15,9 @@ import java.security.cert.X509Certificate
|
||||
*
|
||||
* 1. **Imported** in the app (Settings > Certificate): a PKCS#12 file plus its password,
|
||||
* copied into the app's private storage.
|
||||
* 2. **Staged over adb** into the app's external files directory, the way the spike has
|
||||
* 2. **Downloaded** from the public opencardev/aasdk repository. Fetched only when the
|
||||
* user asks; the app never ships or hosts the certificate.
|
||||
* 3. **Staged over adb** into the app's external files directory, the way the spike has
|
||||
* always done it. Still honoured, so existing setups keep working untouched:
|
||||
*
|
||||
* openssl pkcs12 -export -in headunit.crt -inkey headunit.key \
|
||||
@@ -23,17 +26,18 @@ import java.security.cert.X509Certificate
|
||||
*
|
||||
* 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.
|
||||
* 3. A freshly generated self-signed cert, which a real head unit rejects (see
|
||||
* 4. A freshly generated self-signed cert, which a real head unit rejects (see
|
||||
* SPIKE_FINDINGS.md).
|
||||
*/
|
||||
object CertProvider {
|
||||
private val log = GearslipLog.tagged("CERT")
|
||||
|
||||
private const val P12_NAME = "phone.p12"
|
||||
private const val PASS_NAME = "phone.pass"
|
||||
private const val DEFAULT_PASSWORD = "aaspike"
|
||||
private const val IMPORT_DIR = "identity"
|
||||
|
||||
enum class Kind { IMPORTED, ADB_STAGED, SELF_SIGNED }
|
||||
enum class Kind { IMPORTED, DOWNLOADED, ADB_STAGED, SELF_SIGNED }
|
||||
|
||||
class Identity(
|
||||
val keyStore: KeyStore,
|
||||
@@ -66,7 +70,14 @@ object CertProvider {
|
||||
if (imported.isFile) {
|
||||
runCatching {
|
||||
return loadP12(imported, importedPassword(context), "imported certificate", Kind.IMPORTED)
|
||||
}.onFailure { GearslipLog.e("could not load the imported certificate - trying the next source", it) }
|
||||
}.onFailure { log.e("could not load the imported certificate - trying the next source", it) }
|
||||
}
|
||||
|
||||
val downloaded = downloadedFile(context, P12_NAME)
|
||||
if (downloaded.isFile) {
|
||||
runCatching {
|
||||
return loadP12(downloaded, DEFAULT_PASSWORD, "downloaded certificate", Kind.DOWNLOADED)
|
||||
}.onFailure { log.e("could not load the downloaded certificate - trying the next source", it) }
|
||||
}
|
||||
|
||||
val dir = context.getExternalFilesDir(null)
|
||||
@@ -80,7 +91,7 @@ object CertProvider {
|
||||
DEFAULT_PASSWORD
|
||||
}
|
||||
return loadP12(staged, password, "$P12_NAME staged over adb", Kind.ADB_STAGED)
|
||||
}.onFailure { GearslipLog.e("could not load $P12_NAME - falling back to self-signed", it) }
|
||||
}.onFailure { log.e("could not load $P12_NAME - falling back to self-signed", it) }
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -103,7 +114,7 @@ object CertProvider {
|
||||
target.parentFile?.mkdirs()
|
||||
staging.copyTo(target, overwrite = true)
|
||||
importedFile(context, PASS_NAME).writeText(password)
|
||||
GearslipLog.i("imported a certificate: ${identity.certificate.subjectX500Principal}")
|
||||
log.i("imported a certificate: ${identity.certificate.subjectX500Principal}")
|
||||
return identity
|
||||
} finally {
|
||||
staging.delete()
|
||||
@@ -112,17 +123,84 @@ object CertProvider {
|
||||
|
||||
fun hasImported(context: Context): Boolean = importedFile(context, P12_NAME).isFile
|
||||
|
||||
/** Drops the imported identity; the next source in line (adb-staged, then self-signed) takes over. */
|
||||
/** Whether a downloaded certificate is stored (may or may not also be imported). */
|
||||
fun hasDownloaded(context: Context): Boolean = downloadedFile(context, P12_NAME).isFile
|
||||
|
||||
/** Drops the imported identity; the next source in line (downloaded, adb-staged, then self-signed) takes over. */
|
||||
fun removeImported(context: Context) {
|
||||
importedFile(context, P12_NAME).delete()
|
||||
importedFile(context, PASS_NAME).delete()
|
||||
GearslipLog.i("removed the imported certificate")
|
||||
log.i("removed the imported certificate")
|
||||
}
|
||||
|
||||
/** Drops the downloaded identity. */
|
||||
fun removeDownloaded(context: Context) {
|
||||
downloadedFile(context, P12_NAME).delete()
|
||||
downloadedFile(context, PASS_NAME).delete()
|
||||
log.i("removed the downloaded certificate")
|
||||
}
|
||||
|
||||
/** Whether any certificate is available (imported, downloaded, or adb-staged). */
|
||||
fun hasAnyCert(context: Context): Boolean =
|
||||
hasImported(context) || hasDownloaded(context) || hasAdbStaged(context)
|
||||
|
||||
private fun hasAdbStaged(context: Context): Boolean =
|
||||
context.getExternalFilesDir(null)?.let { File(it, P12_NAME).isFile } == true
|
||||
|
||||
/**
|
||||
* Validates raw PKCS#12 bytes with [password] and, if good, writes them as the imported
|
||||
* identity. Used when the app assembles a certificate in memory (e.g. from a download)
|
||||
* rather than importing from a file picker.
|
||||
*/
|
||||
fun importFromBytes(context: Context, p12Bytes: ByteArray, password: String): Identity {
|
||||
val staging = File(context.cacheDir, "import-$P12_NAME")
|
||||
try {
|
||||
staging.writeBytes(p12Bytes)
|
||||
val identity = loadP12(staging, password, "imported certificate", Kind.IMPORTED)
|
||||
|
||||
val target = importedFile(context, P12_NAME)
|
||||
target.parentFile?.mkdirs()
|
||||
staging.copyTo(target, overwrite = true)
|
||||
importedFile(context, PASS_NAME).writeText(password)
|
||||
log.i("imported a certificate from bytes: ${identity.certificate.subjectX500Principal}")
|
||||
return identity
|
||||
} finally {
|
||||
staging.delete()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the publicly available head unit certificate from the opencardev/aasdk
|
||||
* repository, converts it from PEM to PKCS#12, and stores it ready for use.
|
||||
* Runs synchronously (caller is responsible for threading). Throws on any failure.
|
||||
*/
|
||||
fun downloadAndImport(context: Context): Identity {
|
||||
val download = CertDownloader.fetch()
|
||||
val result = PemKeyParser.build(download.certificatePem, download.privateKeyPem)
|
||||
|
||||
val target = downloadedFile(context, P12_NAME)
|
||||
target.parentFile?.mkdirs()
|
||||
val p12Password = DEFAULT_PASSWORD
|
||||
val keyStore = result.keyStore
|
||||
val p12Bytes = ByteArrayOutputStream().use { stream ->
|
||||
keyStore.store(stream, p12Password.toCharArray())
|
||||
stream.toByteArray()
|
||||
}
|
||||
target.writeBytes(p12Bytes)
|
||||
downloadedFile(context, PASS_NAME).writeText(p12Password)
|
||||
|
||||
log.i("downloaded and stored certificate: ${result.certificate.subjectX500Principal}")
|
||||
return Identity(keyStore, result.certificate, p12Password.toCharArray(),
|
||||
"downloaded certificate (opencardev/aasdk)", Kind.DOWNLOADED)
|
||||
}
|
||||
|
||||
// The imported files live in the app's private internal storage (not the external files
|
||||
// dir), so other apps cannot read the key, and `adb uninstall` clearing it is expected.
|
||||
private fun importedFile(context: Context, name: String) = File(File(context.filesDir, IMPORT_DIR), name)
|
||||
|
||||
private fun downloadedFile(context: Context, name: String) =
|
||||
File(File(context.filesDir, "$IMPORT_DIR-downloaded"), name)
|
||||
|
||||
private fun importedPassword(context: Context): String =
|
||||
importedFile(context, PASS_NAME).takeIf { it.isFile }?.readText().orEmpty()
|
||||
|
||||
|
||||
@@ -12,9 +12,10 @@ import java.util.concurrent.TimeUnit
|
||||
* the TLS self-test: make our own failures loud on the bench.
|
||||
*/
|
||||
object EncoderSelfTest {
|
||||
private val log = GearslipLog.tagged("VIDEO")
|
||||
|
||||
fun run(width: Int = 800, height: Int = 480, frameRate: Int = 30): Boolean {
|
||||
GearslipLog.i("=== encoder self-test starting (${width}x$height @ ${frameRate}fps) ===")
|
||||
log.i("=== encoder self-test starting (${width}x$height @ ${frameRate}fps) ===")
|
||||
|
||||
var codecConfig: ByteArray? = null
|
||||
var frames = 0
|
||||
@@ -59,37 +60,37 @@ object EncoderSelfTest {
|
||||
val csd = codecConfig
|
||||
when {
|
||||
csd == null -> {
|
||||
GearslipLog.e("encoder self-test FAILED: no CODEC_CONFIG (SPS/PPS) produced")
|
||||
log.e("encoder self-test FAILED: no CODEC_CONFIG (SPS/PPS) produced")
|
||||
false
|
||||
}
|
||||
frames == 0 -> {
|
||||
GearslipLog.e("encoder self-test FAILED: no frames produced")
|
||||
log.e("encoder self-test FAILED: no frames produced")
|
||||
false
|
||||
}
|
||||
!sawKeyFrame -> {
|
||||
GearslipLog.e("encoder self-test FAILED: no keyframe - the car needs one first")
|
||||
log.e("encoder self-test FAILED: no keyframe - the car needs one first")
|
||||
false
|
||||
}
|
||||
!sawResyncKeyFrame -> {
|
||||
GearslipLog.e(
|
||||
log.e(
|
||||
"encoder self-test FAILED: requestSyncFrame() produced no keyframe - " +
|
||||
"the resync-after-drop fix would silently do nothing on this device",
|
||||
)
|
||||
false
|
||||
}
|
||||
else -> {
|
||||
GearslipLog.i(
|
||||
log.i(
|
||||
"SPS/PPS = ${csd.size} bytes, $frames frames, keyframe present, " +
|
||||
"requestSyncFrame() confirmed working",
|
||||
)
|
||||
GearslipLog.hex(" csd", csd, limit = 48)
|
||||
GearslipLog.i("=== encoder self-test PASSED ===")
|
||||
log.hex(" csd", csd, limit = 48)
|
||||
log.i("=== encoder self-test PASSED ===")
|
||||
true
|
||||
}
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
runCatching { source.stop() }
|
||||
GearslipLog.e("encoder self-test FAILED with an exception", t)
|
||||
log.e("encoder self-test FAILED with an exception", t)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import app.seb3thehacker.gearslip.mirror.TouchRelayService
|
||||
import app.seb3thehacker.gearslip.car.CarEnvironment
|
||||
import app.seb3thehacker.gearslip.car.CarSettings
|
||||
import app.seb3thehacker.gearslip.car.CarUi
|
||||
import app.seb3thehacker.gearslip.car.Prefetch
|
||||
import app.seb3thehacker.gearslip.ui.GearslipApp
|
||||
import app.seb3thehacker.gearslip.ui.GearslipTheme
|
||||
import java.io.FileInputStream
|
||||
@@ -93,35 +94,36 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
}
|
||||
}
|
||||
|
||||
/** Hosted map apps need Gearslip to hold location so they can keep it in the background. */
|
||||
private val locationPermission = registerForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
|
||||
) { }
|
||||
|
||||
/** The car dashboard's agenda card; declined just leaves it empty rather than asking again. */
|
||||
private val calendarPermission = registerForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
|
||||
) { }
|
||||
/** Setting Gearslip as the phone's caller ID and spam app - what lets it decline a ringing call. */
|
||||
private val callScreeningRole = registerForActivityResult(StartActivityForResult()) { result ->
|
||||
if (result.resultCode != RESULT_OK) GearslipLog.w("calls: call screening role declined")
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION) !=
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) locationPermission.launch(android.Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
if (checkSelfPermission(android.Manifest.permission.READ_CALENDAR) !=
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) calendarPermission.launch(android.Manifest.permission.READ_CALENDAR)
|
||||
// Location, calendar, the phone/contacts group, and the microphone are asked for one at
|
||||
// a time by PermissionsSetupScreen instead of here, each only after the driver taps
|
||||
// Allow on a screen explaining what it is for. Firing them all from onCreate, unprompted,
|
||||
// is both a bad first impression and unreliable: Android does not consistently show more
|
||||
// than one system permission dialog fired back-to-back, which was silently dropping some.
|
||||
enableEdgeToEdge()
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
|
||||
GearslipLog.init(this)
|
||||
GearslipLog.installCrashHandler()
|
||||
GearslipLog.i("Gearslip ready")
|
||||
CarSettings.init(this)
|
||||
// A capture that died without tidying up would leave notifications exposed to every
|
||||
// later screen share. Nothing is capturing if the service is not running, so undo it.
|
||||
if (!app.seb3thehacker.gearslip.audio.AudioCaptureService.running) {
|
||||
app.seb3thehacker.gearslip.audio.ScreenShareGuard.restore(this)
|
||||
}
|
||||
Prefetch.warm(this)
|
||||
CarEnvironment.setPhoneTheme(resources.configuration)
|
||||
|
||||
setContent {
|
||||
GearslipTheme {
|
||||
GearslipApp(onDisconnect = ::closeAccessory)
|
||||
GearslipApp(onDisconnect = ::closeAccessory, onRequestCallScreening = ::requestCallScreeningRole)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +143,12 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
CarEnvironment.setPhoneTheme(newConfig)
|
||||
}
|
||||
|
||||
// Throttled inside Prefetch, so this only does work if the last run was a while ago.
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
Prefetch.warm(this)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
handleIntent(intent)
|
||||
@@ -159,6 +167,19 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
GearslipLog.flush()
|
||||
}
|
||||
|
||||
/** Launches the system dialog that offers Gearslip the call-screening role, if it isn't held already. */
|
||||
private fun requestCallScreeningRole() {
|
||||
val roleManager = getSystemService(android.app.role.RoleManager::class.java)
|
||||
if (roleManager == null || !roleManager.isRoleAvailable(android.app.role.RoleManager.ROLE_CALL_SCREENING)) {
|
||||
GearslipLog.w("calls: call screening role unavailable on this phone")
|
||||
return
|
||||
}
|
||||
if (roleManager.isRoleHeld(android.app.role.RoleManager.ROLE_CALL_SCREENING)) return
|
||||
runCatching {
|
||||
callScreeningRole.launch(roleManager.createRequestRoleIntent(android.app.role.RoleManager.ROLE_CALL_SCREENING))
|
||||
}.onFailure { GearslipLog.e("could not ask for the call screening role", it) }
|
||||
}
|
||||
|
||||
// --- Media audio --------------------------------------------------------------------
|
||||
|
||||
private fun requestAudioCapture() {
|
||||
@@ -268,6 +289,7 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
|
||||
private fun closeAccessory() {
|
||||
runner?.stop()
|
||||
SessionReport.print()
|
||||
runCatching { descriptor?.close() }
|
||||
descriptor = null
|
||||
runner = null
|
||||
@@ -287,10 +309,11 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTouch(action: Int, x: Float, y: Float) {
|
||||
override fun onTouch(action: Int, actionIndex: Int, points: List<TouchPoint>) {
|
||||
runOnUiThread {
|
||||
if (PhoneMirror.active.value) relayTouch(action, x, y)
|
||||
else screenProjector?.dispatchTouch(action, x, y)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,13 @@ object GearslipLog {
|
||||
|
||||
private var file: File? = null
|
||||
|
||||
/**
|
||||
* Set from [AppSettings.debugMode]. Off drops the noisy trace output (hex dumps, and
|
||||
* logcat copies of info lines); warnings, errors and the file copy are always kept, so a
|
||||
* crash report is never empty.
|
||||
*/
|
||||
@Volatile var debug = true
|
||||
|
||||
// What the live log screen shows. Bounded, and separate from the file, which always keeps
|
||||
// everything. The flow carries only a version number so a burst of lines costs the UI one
|
||||
// snapshot per frame rather than one list copy per line.
|
||||
@@ -40,10 +47,13 @@ object GearslipLog {
|
||||
val version: StateFlow<Int> = liveVersion
|
||||
|
||||
fun init(context: Context) {
|
||||
debug = AppSettings.debugMode(context)
|
||||
val dir = context.getExternalFilesDir(null) ?: return
|
||||
val name = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
|
||||
file = File(dir, "gearslip-$name.log")
|
||||
i("log file: ${file?.absolutePath}")
|
||||
SessionReport.init(context)
|
||||
i("debug mode: $debug")
|
||||
}
|
||||
|
||||
fun snapshot(): List<String> = synchronized(live) { live.toList() }
|
||||
@@ -61,13 +71,81 @@ object GearslipLog {
|
||||
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 w(message: String) = write("W", message)
|
||||
fun i(message: String, tag: String = "APP") = write("I", message, tag)
|
||||
|
||||
fun e(message: String, t: Throwable? = null) {
|
||||
write("E", if (t == null) message else "$message: ${t.javaClass.simpleName}: ${t.message}")
|
||||
if (t != null) Log.e(TAG, message, t)
|
||||
fun w(message: String, tag: String = "APP") = write("W", message, tag)
|
||||
|
||||
/** Logs the message, then the exception with its cause chain and top stack frames. */
|
||||
fun e(message: String, t: Throwable? = null, tag: String = "APP") {
|
||||
write("E", if (t == null) message else "$message: ${rootCause(t)}", tag)
|
||||
if (t != null) {
|
||||
Log.e(TAG, message, t)
|
||||
stackTrace(t).lines().forEach { write("E", " $it", tag) }
|
||||
}
|
||||
}
|
||||
|
||||
/** A logger that stamps every line with [tag], so the log can be scanned by subsystem. */
|
||||
fun tagged(tag: String) = Tagged(tag)
|
||||
|
||||
class Tagged(private val tag: String) {
|
||||
fun i(message: String) = GearslipLog.i(message, tag)
|
||||
fun w(message: String) = GearslipLog.w(message, tag)
|
||||
fun e(message: String, t: Throwable? = null) = GearslipLog.e(message, t, tag)
|
||||
fun hex(label: String, data: ByteArray, limit: Int = 256) = GearslipLog.hex(label, data, limit, tag)
|
||||
fun verdict(headline: String, detail: String) = GearslipLog.verdict(headline, detail)
|
||||
fun flush() = GearslipLog.flush()
|
||||
}
|
||||
|
||||
/** "SSLHandshakeException: Received fatal alert: bad_certificate" - the deepest cause wins. */
|
||||
fun rootCause(t: Throwable): String {
|
||||
var cause: Throwable = t
|
||||
while (cause.cause != null && cause.cause !== cause) cause = cause.cause!!
|
||||
val top = "${t.javaClass.simpleName}: ${t.message}"
|
||||
return if (cause === t) top else "$top <- ${cause.javaClass.simpleName}: ${cause.message}"
|
||||
}
|
||||
|
||||
private fun stackTrace(t: Throwable, frames: Int = 12): String {
|
||||
val out = StringBuilder()
|
||||
var cause: Throwable? = t
|
||||
var depth = 0
|
||||
while (cause != null && depth < 4) {
|
||||
if (depth > 0) out.append("caused by ${cause.javaClass.name}: ${cause.message}\n")
|
||||
cause.stackTrace.take(frames).forEach { out.append("at $it\n") }
|
||||
cause = cause.cause?.takeIf { it !== cause }
|
||||
depth++
|
||||
}
|
||||
return out.toString().trimEnd()
|
||||
}
|
||||
|
||||
/**
|
||||
* Records an uncaught exception in the log file before the process dies, then lets the
|
||||
* system's own handler run. Without it the file ends mid-sentence and the reason is lost.
|
||||
*/
|
||||
fun installCrashHandler() {
|
||||
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
runCatching {
|
||||
SessionReport.fail(SessionReport.Category.CRASH, "uncaught in thread ${thread.name}", throwable)
|
||||
e("FATAL uncaught exception in thread ${thread.name}", throwable)
|
||||
SessionReport.print()
|
||||
flush()
|
||||
}
|
||||
previous?.uncaughtException(thread, throwable)
|
||||
}
|
||||
}
|
||||
|
||||
/** Unmissable in a scrolling logcat - this is what the whole spike exists to print. */
|
||||
@@ -89,7 +167,8 @@ object GearslipLog {
|
||||
runCatching { latch.await(timeoutMs, TimeUnit.MILLISECONDS) }
|
||||
}
|
||||
|
||||
fun hex(label: String, data: ByteArray, limit: Int = 256) {
|
||||
fun hex(label: String, data: ByteArray, limit: Int = 256, tag: String = "APP") {
|
||||
if (!debug) return
|
||||
val shown = minOf(data.size, limit)
|
||||
val sb = StringBuilder()
|
||||
for (idx in 0 until shown) {
|
||||
@@ -97,16 +176,16 @@ object GearslipLog {
|
||||
if (idx % 16 == 15) sb.append('\n') else sb.append(' ')
|
||||
}
|
||||
val suffix = if (data.size > limit) "\n... (${data.size - limit} more bytes)" else ""
|
||||
i("$label (${data.size} bytes)\n$sb$suffix")
|
||||
i("$label (${data.size} bytes)\n$sb$suffix", tag)
|
||||
}
|
||||
|
||||
private fun write(level: String, message: String) {
|
||||
private fun write(level: String, message: String, tag: String = "APP") {
|
||||
when (level) {
|
||||
"W" -> Log.w(TAG, message)
|
||||
"E" -> Log.e(TAG, message)
|
||||
else -> Log.i(TAG, message)
|
||||
else -> if (debug) Log.i(TAG, message)
|
||||
}
|
||||
val line = "${stamp.format(Date())} $level $message"
|
||||
val line = "${stamp.format(Date())} $level ${tag.padEnd(5)} $message"
|
||||
synchronized(live) {
|
||||
live.addLast(line)
|
||||
while (live.size > MAX_LIVE_LINES) live.removeFirst()
|
||||
|
||||
@@ -27,6 +27,7 @@ class GearslipRunner(
|
||||
private val projection: Projection? = null,
|
||||
private val vehicleProfileFor: (ServiceDiscovery.HeadUnitInfo) -> VehicleProfile? = { null },
|
||||
) {
|
||||
private val log = GearslipLog.tagged("PROTO")
|
||||
|
||||
private enum class State { WAIT_VERSION, TLS_HANDSHAKE, WAIT_AUTH, WAIT_SDR, DONE }
|
||||
|
||||
@@ -50,6 +51,7 @@ class GearslipRunner(
|
||||
@Volatile private var dropped = 0
|
||||
@Volatile private var inputChannelId: Int = -1
|
||||
@Volatile private var audioLink: AudioLink? = null
|
||||
private var audioMessagesSeen = 0
|
||||
private var touchWidth = 0
|
||||
private var touchHeight = 0
|
||||
private var touchesSeen = 0
|
||||
@@ -61,16 +63,18 @@ class GearslipRunner(
|
||||
@Volatile private var lastKeyframeSentAt = 0L
|
||||
|
||||
fun run() {
|
||||
GearslipLog.i("--- spike run starting ---")
|
||||
SessionReport.begin()
|
||||
log.i("--- session starting ---")
|
||||
SessionStatus.connecting("Connected to the head unit")
|
||||
GearslipLog.i("device=${Build.MANUFACTURER} ${Build.MODEL} android=${Build.VERSION.RELEASE} (sdk ${Build.VERSION.SDK_INT})")
|
||||
log.i("device=${Build.MANUFACTURER} ${Build.MODEL} android=${Build.VERSION.RELEASE} (sdk ${Build.VERSION.SDK_INT})")
|
||||
|
||||
val identity = identityProvider()
|
||||
val cert = identity.certificate
|
||||
GearslipLog.i("phone certificate source: ${identity.source}")
|
||||
GearslipLog.i(" subject = ${cert.subjectX500Principal}")
|
||||
GearslipLog.i(" issuer = ${cert.issuerX500Principal}")
|
||||
GearslipLog.i(" serial = ${cert.serialNumber} valid ${cert.notBefore}..${cert.notAfter}")
|
||||
log.i("phone certificate source: ${identity.source}")
|
||||
SessionReport.certificate(identity.source.toString(), cert.issuerX500Principal.toString())
|
||||
log.i(" subject = ${cert.subjectX500Principal}")
|
||||
log.i(" issuer = ${cert.issuerX500Principal}")
|
||||
log.i(" serial = ${cert.serialNumber} valid ${cert.notBefore}..${cert.notAfter}")
|
||||
tls = PhoneTls(identity.keyStore, identity.password)
|
||||
|
||||
startWatchdog()
|
||||
@@ -80,7 +84,7 @@ class GearslipRunner(
|
||||
while (running) {
|
||||
val read = input.read(chunk)
|
||||
if (read < 0) {
|
||||
GearslipLog.w("head unit closed the connection (EOF) while in state $state")
|
||||
log.w("head unit closed the connection (EOF) while in state $state")
|
||||
reportDisconnect()
|
||||
return
|
||||
}
|
||||
@@ -92,11 +96,12 @@ class GearslipRunner(
|
||||
}
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
GearslipLog.e("transport failed in state $state", t)
|
||||
log.e("transport failed in state $state", t)
|
||||
SessionReport.fail(SessionReport.Category.USB, "transport failed", t, state.name)
|
||||
reportDisconnect()
|
||||
} finally {
|
||||
running = false
|
||||
GearslipLog.flush()
|
||||
log.flush()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +118,7 @@ class GearslipRunner(
|
||||
private fun handleFrame(frame: Frames.Frame) {
|
||||
val payload = if (frame.encrypted) {
|
||||
if (!tls.handshakeComplete) {
|
||||
GearslipLog.w("encrypted frame arrived before the handshake finished - ignoring")
|
||||
log.w("encrypted frame arrived before the handshake finished - ignoring")
|
||||
return
|
||||
}
|
||||
tls.decrypt(frame.payload)
|
||||
@@ -123,7 +128,7 @@ class GearslipRunner(
|
||||
|
||||
val message = assembler.offer(frame, payload) ?: return
|
||||
if (message.size < 2) {
|
||||
GearslipLog.w("runt message on channel ${frame.channel}")
|
||||
log.w("runt message on channel ${frame.channel}")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -138,9 +143,19 @@ class GearslipRunner(
|
||||
} else if (frame.channel == inputChannelId) {
|
||||
onInputMessage(messageId, body)
|
||||
} else if (frame.channel == audioLink?.channelId) {
|
||||
// The audio channel is the one still being reverse-engineered, and its first few
|
||||
// replies are what tell us why. Raw bytes for those, then quiet.
|
||||
if (audioMessagesSeen < AUDIO_TRACE_MESSAGES) {
|
||||
audioMessagesSeen++
|
||||
log.i(
|
||||
"audio frame #$audioMessagesSeen: channel=${frame.channel} " +
|
||||
"encrypted=${frame.encrypted} raw=${frame.payload.size}B decrypted=${message.size}B",
|
||||
)
|
||||
log.hex(" audio raw", message, limit = 64)
|
||||
}
|
||||
audioLink?.onMessage(messageId, body)
|
||||
} else {
|
||||
GearslipLog.i("ignoring message id $messageId on channel ${frame.channel}")
|
||||
log.i("ignoring message id $messageId on channel ${frame.channel}")
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -152,41 +167,49 @@ class GearslipRunner(
|
||||
MSG_SERVICE_DISCOVERY_RESPONSE -> onServiceDiscoveryResponse(body)
|
||||
MSG_PING_REQUEST -> onPingRequest(body)
|
||||
AudioLink.MSG_FOCUS_RESPONSE -> audioLink?.onFocus(Protobuf.readInt32Field(body, 1) ?: 0)
|
||||
else -> GearslipLog.i("unhandled control message id=$messageId (${body.size} bytes) - continuing")
|
||||
else -> log.i("unhandled control message id=$messageId (${body.size} bytes) - continuing")
|
||||
}
|
||||
}
|
||||
|
||||
private fun onVersionRequest(body: ByteArray) {
|
||||
if (body.size < 4) {
|
||||
GearslipLog.w("VersionRequest too short (${body.size} bytes)")
|
||||
log.w("VersionRequest too short (${body.size} bytes)")
|
||||
return
|
||||
}
|
||||
val major = ((body[0].toInt() and 0xFF) shl 8) or (body[1].toInt() and 0xFF)
|
||||
val minor = ((body[2].toInt() and 0xFF) shl 8) or (body[3].toInt() and 0xFF)
|
||||
GearslipLog.i("<- VersionRequest: head unit speaks $major.$minor")
|
||||
log.i("<- VersionRequest: head unit speaks $major.$minor")
|
||||
SessionReport.protocolVersion(major, minor)
|
||||
|
||||
// Echo the head unit's own version with STATUS_SUCCESS (0). Echoing rather than
|
||||
// asserting 1.6 is the permissive choice: we only need to reach service discovery,
|
||||
// not to honour whatever that version implies.
|
||||
// PROTOCOL-DOWNGRADE EXPERIMENT. The 2018 Uconnect speaks 1.3 and ACCEPTS the JVC cert;
|
||||
// the 2025 unit speaks 4.3 and rejects it at AuthComplete (-3). If the 4.3 identity check
|
||||
// is tied to the *negotiated* protocol version rather than baked into the firmware,
|
||||
// claiming an older version makes the newer unit skip the check entirely. CLAIM_VERSION
|
||||
// = null restores the previous echo-the-head-unit behaviour.
|
||||
val claim = CLAIM_VERSION ?: (major to minor)
|
||||
val response = byteArrayOf(
|
||||
((major shr 8) and 0xFF).toByte(), (major and 0xFF).toByte(),
|
||||
((minor shr 8) and 0xFF).toByte(), (minor and 0xFF).toByte(),
|
||||
((claim.first shr 8) and 0xFF).toByte(), (claim.first and 0xFF).toByte(),
|
||||
((claim.second shr 8) and 0xFF).toByte(), (claim.second and 0xFF).toByte(),
|
||||
0, 0, // STATUS_SUCCESS
|
||||
)
|
||||
send(MSG_VERSION_RESPONSE, response, encrypted = false)
|
||||
GearslipLog.i("-> VersionResponse: $major.$minor status=0 (STATUS_SUCCESS)")
|
||||
log.i(
|
||||
"-> VersionResponse: ${claim.first}.${claim.second} status=0 (STATUS_SUCCESS)" +
|
||||
if (CLAIM_VERSION != null) " [FORCED; head unit offered $major.$minor]" else "",
|
||||
)
|
||||
state = State.TLS_HANDSHAKE
|
||||
SessionStatus.connecting("Securing the link")
|
||||
GearslipLog.i("awaiting ClientHello - the head unit is the TLS client, we are the server")
|
||||
log.i("awaiting ClientHello - the head unit is the TLS client, we are the server")
|
||||
}
|
||||
|
||||
private fun onHandshake(body: ByteArray) {
|
||||
GearslipLog.i("<- EncapsulatedSSL (${body.size} bytes)")
|
||||
log.i("<- EncapsulatedSSL (${body.size} bytes)")
|
||||
val replies = try {
|
||||
tls.pumpHandshake(body)
|
||||
} catch (t: Throwable) {
|
||||
GearslipLog.e("TLS handshake threw", t)
|
||||
GearslipLog.verdict(
|
||||
log.e("TLS handshake threw", t)
|
||||
SessionReport.fail(SessionReport.Category.TLS, "handshake threw", t, state.name)
|
||||
log.verdict(
|
||||
"NOT VIABLE (TLS rejected)",
|
||||
"The head unit aborted the TLS handshake. A bad_certificate / unknown_ca / " +
|
||||
"handshake_failure alert here means it validates the phone's certificate.",
|
||||
@@ -196,31 +219,34 @@ class GearslipRunner(
|
||||
"The head unit aborted the secure handshake. It validates the phone's certificate.",
|
||||
)
|
||||
state = State.DONE
|
||||
SessionReport.print()
|
||||
return
|
||||
}
|
||||
replies.forEach {
|
||||
send(MSG_ENCAPSULATED_SSL, it, encrypted = false)
|
||||
GearslipLog.i("-> EncapsulatedSSL (${it.size} bytes)")
|
||||
log.i("-> EncapsulatedSSL (${it.size} bytes)")
|
||||
}
|
||||
if (tls.handshakeComplete && state == State.TLS_HANDSHAKE) {
|
||||
state = State.WAIT_AUTH
|
||||
GearslipLog.i("awaiting AuthComplete - this carries the certificate verdict")
|
||||
log.i("awaiting AuthComplete - this carries the certificate verdict")
|
||||
}
|
||||
}
|
||||
|
||||
private fun onAuthComplete(body: ByteArray) {
|
||||
val status = Protobuf.readInt32Field(body, 1)
|
||||
GearslipLog.i("<- AuthComplete: status=$status")
|
||||
log.i("<- AuthComplete: status=$status")
|
||||
status?.let { SessionReport.auth(it) }
|
||||
|
||||
when (status) {
|
||||
0 -> {
|
||||
GearslipLog.i("STATUS_SUCCESS - certificate accepted; confirming with service discovery")
|
||||
log.i("STATUS_SUCCESS - certificate accepted; confirming with service discovery")
|
||||
state = State.WAIT_SDR
|
||||
SessionStatus.connecting("Certificate accepted - discovering services")
|
||||
sendServiceDiscoveryRequest()
|
||||
}
|
||||
-2 -> {
|
||||
GearslipLog.verdict(
|
||||
SessionReport.fail(SessionReport.Category.CERTIFICATE, "AuthComplete status -2 (certificate error)", state.name)
|
||||
log.verdict(
|
||||
"NOT VIABLE (STATUS_CERTIFICATE_ERROR)",
|
||||
"The head unit explicitly rejected the phone's self-signed certificate. " +
|
||||
"This is a definitive no - it validates the phone against a trust chain " +
|
||||
@@ -233,7 +259,8 @@ class GearslipRunner(
|
||||
state = State.DONE
|
||||
}
|
||||
-3 -> {
|
||||
GearslipLog.verdict(
|
||||
SessionReport.fail(SessionReport.Category.AUTH, "AuthComplete status -3 (authentication failure)", state.name)
|
||||
log.verdict(
|
||||
"NOT VIABLE (STATUS_AUTHENTICATION_FAILURE)",
|
||||
"Authentication rejected. Broader than a pure certificate error, but the " +
|
||||
"practical answer is the same.",
|
||||
@@ -245,7 +272,8 @@ class GearslipRunner(
|
||||
state = State.DONE
|
||||
}
|
||||
else -> {
|
||||
GearslipLog.verdict(
|
||||
SessionReport.fail(SessionReport.Category.AUTH, "AuthComplete status $status (unexpected)", state.name)
|
||||
log.verdict(
|
||||
"INCONCLUSIVE (AuthComplete status=$status)",
|
||||
"Unexpected status. Check MessageStatus.proto in aasdk for the meaning " +
|
||||
"before drawing any conclusion.",
|
||||
@@ -260,13 +288,13 @@ class GearslipRunner(
|
||||
val body = Protobuf.stringField(4, "Gearslip") + // label_text
|
||||
Protobuf.stringField(5, Build.MODEL) // device_name
|
||||
send(MSG_SERVICE_DISCOVERY_REQUEST, body, encrypted = true)
|
||||
GearslipLog.i("-> ServiceDiscoveryRequest (encrypted)")
|
||||
log.i("-> ServiceDiscoveryRequest (encrypted)")
|
||||
}
|
||||
|
||||
private fun onServiceDiscoveryResponse(body: ByteArray) {
|
||||
GearslipLog.i("<- ServiceDiscoveryResponse (${body.size} bytes, decrypted successfully)")
|
||||
GearslipLog.i("head unit describes itself as:\n" + Protobuf.describe(body))
|
||||
GearslipLog.verdict(
|
||||
log.i("<- ServiceDiscoveryResponse (${body.size} bytes, decrypted successfully)")
|
||||
log.i("head unit describes itself as:\n" + Protobuf.describe(body))
|
||||
log.verdict(
|
||||
"VIABLE",
|
||||
"The head unit accepted the presented phone certificate, completed TLS, and is " +
|
||||
"advertising its channels over the encrypted session. A phone-side client is " +
|
||||
@@ -274,13 +302,15 @@ class GearslipRunner(
|
||||
)
|
||||
state = State.DONE
|
||||
val info = ServiceDiscovery.findHeadUnitInfo(body)
|
||||
GearslipLog.i("head unit: $info")
|
||||
log.i("head unit: $info")
|
||||
SessionReport.headUnit(info.toString())
|
||||
vehicleProfile = runCatching { vehicleProfileFor(info) }.getOrNull()
|
||||
val profile = vehicleProfile
|
||||
SessionReport.profile(profile?.name)
|
||||
if (profile != null) {
|
||||
GearslipLog.i("vehicle profile matched: \"${profile.name}\" resolution=${profile.resolution} insets=${profile.insets}")
|
||||
log.i("vehicle profile matched: \"${profile.name}\" resolution=${profile.resolution} insets=${profile.insets}")
|
||||
} else {
|
||||
GearslipLog.i("no vehicle profile matched - default display settings (add one to vehicles.json)")
|
||||
log.i("no vehicle profile matched - default display settings (add one to vehicles.json)")
|
||||
}
|
||||
CarEnvironment.setVehicle(profile?.name ?: "Unknown vehicle", profile?.insets ?: Insets.NONE)
|
||||
SessionStatus.connecting("Starting video")
|
||||
@@ -303,18 +333,19 @@ class GearslipRunner(
|
||||
private fun startVideoChannel(serviceDiscoveryResponse: ByteArray) {
|
||||
val video = ServiceDiscovery.findVideoService(serviceDiscoveryResponse)
|
||||
if (video == null) {
|
||||
GearslipLog.w("no video service in the discovery response - cannot start Phase A")
|
||||
log.w("no video service in the discovery response - cannot start Phase A")
|
||||
SessionReport.fail(SessionReport.Category.VIDEO, "head unit advertised no video service", state.name)
|
||||
return
|
||||
}
|
||||
videoChannelId = video.serviceId
|
||||
videoConfigs = video.configs
|
||||
|
||||
GearslipLog.i("video service: channel=${video.serviceId} codec=${video.codecName}")
|
||||
log.i("video service: channel=${video.serviceId} codec=${video.codecName}")
|
||||
video.configs.forEachIndexed { index, config ->
|
||||
GearslipLog.i(" config[$index] = $config")
|
||||
log.i(" config[$index] = $config")
|
||||
}
|
||||
if (video.codecType != CODEC_H264_BP) {
|
||||
GearslipLog.w("head unit wants ${video.codecName}, not H264_BP - Phase B must match this")
|
||||
log.w("head unit wants ${video.codecName}, not H264_BP - Phase B must match this")
|
||||
}
|
||||
|
||||
openChannel(video.serviceId)
|
||||
@@ -322,12 +353,12 @@ class GearslipRunner(
|
||||
|
||||
val input = ServiceDiscovery.findInputService(serviceDiscoveryResponse)
|
||||
if (input == null) {
|
||||
GearslipLog.w("no input service advertised - projection will be output-only")
|
||||
log.w("no input service advertised - projection will be output-only")
|
||||
} else {
|
||||
inputChannelId = input.serviceId
|
||||
touchWidth = input.touchWidth
|
||||
touchHeight = input.touchHeight
|
||||
GearslipLog.i(
|
||||
log.i(
|
||||
"input service: channel=${input.serviceId} touchscreen=" +
|
||||
"${input.touchWidth}x${input.touchHeight}",
|
||||
)
|
||||
@@ -339,15 +370,23 @@ class GearslipRunner(
|
||||
private fun startAudioChannel(serviceDiscoveryResponse: ByteArray) {
|
||||
val sinks = ServiceDiscovery.findAudioServices(serviceDiscoveryResponse)
|
||||
sinks.forEach { sink ->
|
||||
GearslipLog.i(
|
||||
log.i(
|
||||
"audio sink: channel=${sink.serviceId} stream=${sink.streamName} codec=${sink.codecName} " +
|
||||
sink.configs.joinToString(prefix = "[", postfix = "]") { "${it.sampleRate}Hz/${it.bits}bit/x${it.channels}" },
|
||||
)
|
||||
}
|
||||
val media = sinks.firstOrNull { it.streamType == STREAM_MEDIA } ?: run {
|
||||
GearslipLog.w("no media audio sink advertised - media apps will play on the phone only")
|
||||
log.w("no media audio sink advertised - media apps will play on the phone only")
|
||||
SessionReport.audio(
|
||||
"no MEDIA sink; head unit offered ${sinks.joinToString { it.streamName }.ifEmpty { "none" }}",
|
||||
)
|
||||
return
|
||||
}
|
||||
val first = media.configs.firstOrNull()
|
||||
SessionReport.audio(
|
||||
"MEDIA sink on channel ${media.serviceId}, ${media.codecName}, " +
|
||||
"${first?.sampleRate ?: 48_000}Hz/${first?.bits ?: 16}bit/x${first?.channels ?: 2}",
|
||||
)
|
||||
audioLink = AudioLink(
|
||||
media,
|
||||
sendOnChannel = { id, body -> send(id, body, encrypted = true, channel = media.serviceId) },
|
||||
@@ -363,7 +402,7 @@ class GearslipRunner(
|
||||
MSG_CHANNEL_OPEN_REQUEST, request, encrypted = true,
|
||||
channel = serviceId, messageType = Frames.MESSAGE_CONTROL,
|
||||
)
|
||||
GearslipLog.i("-> ChannelOpenRequest(service_id=$serviceId) on channel $serviceId")
|
||||
log.i("-> ChannelOpenRequest(service_id=$serviceId) on channel $serviceId")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -382,40 +421,42 @@ class GearslipRunner(
|
||||
private fun onInputMessage(messageId: Int, body: ByteArray) {
|
||||
when (messageId) {
|
||||
MSG_CHANNEL_OPEN_RESPONSE ->
|
||||
GearslipLog.i("<- ChannelOpenResponse (input): status=${Protobuf.readInt32Field(body, 1)}")
|
||||
log.i("<- ChannelOpenResponse (input): status=${Protobuf.readInt32Field(body, 1)}")
|
||||
|
||||
MSG_INPUT_REPORT -> {
|
||||
val report = Wire.fields(body)
|
||||
val touch = Wire.bytes(report, 3) ?: Wire.bytes(report, 7)
|
||||
if (touch == null) {
|
||||
GearslipLog.i("<- InputReport with no touch payload (key or rotary event)")
|
||||
log.i("<- InputReport with no touch payload (key or rotary event)")
|
||||
return
|
||||
}
|
||||
val touchFields = Wire.fields(touch)
|
||||
val action = Wire.varint(touchFields, 3)?.toInt() ?: 0
|
||||
val pointers = Wire.allBytes(touchFields, 1)
|
||||
if (pointers.isEmpty()) return
|
||||
if (pointers.size > 1 && touchesSeen < 3) {
|
||||
GearslipLog.i("(multi-touch: ${pointers.size} pointers; using the first)")
|
||||
}
|
||||
|
||||
val first = Wire.fields(pointers[0])
|
||||
val rawX = Wire.varint(first, 1)?.toInt() ?: return
|
||||
val rawY = Wire.varint(first, 2)?.toInt() ?: return
|
||||
|
||||
val size = videoConfigs.getOrNull(selectedConfigIndex ?: 0)?.pixelSize()
|
||||
val x = clamp(rawX, size?.first)
|
||||
val y = clamp(rawY, size?.second)
|
||||
val points = pointers.mapIndexedNotNull { index, pointer ->
|
||||
val fields = Wire.fields(pointer)
|
||||
val rawX = Wire.varint(fields, 1)?.toInt() ?: return@mapIndexedNotNull null
|
||||
val rawY = Wire.varint(fields, 2)?.toInt() ?: return@mapIndexedNotNull null
|
||||
val id = Wire.varint(fields, 3)?.toInt() ?: index
|
||||
TouchPoint(id, clamp(rawX, size?.first), clamp(rawY, size?.second))
|
||||
}
|
||||
if (points.isEmpty()) return
|
||||
val actionIndex = (Wire.varint(touchFields, 2)?.toInt() ?: 0).coerceIn(0, points.size - 1)
|
||||
if (points.size > 1 && touchesSeen < 3) {
|
||||
log.i("multi-touch: ${points.size} pointers, action=$action index=$actionIndex")
|
||||
}
|
||||
|
||||
touchesSeen++
|
||||
if (touchesSeen <= 10 || action == ACTION_DOWN || action == ACTION_UP) {
|
||||
val note = if (x != rawX.toFloat() || y != rawY.toFloat()) " (clamped)" else ""
|
||||
GearslipLog.i("<- Touch action=$action at ($rawX,$rawY)$note")
|
||||
val at = points[actionIndex]
|
||||
log.i("<- Touch action=$action at (${at.x.toInt()},${at.y.toInt()}) pointers=${points.size}")
|
||||
}
|
||||
projection?.onTouch(action, x, y)
|
||||
projection?.onTouch(action, actionIndex, points)
|
||||
}
|
||||
|
||||
else -> GearslipLog.i("<- unhandled input message id=$messageId (${body.size} bytes)")
|
||||
else -> log.i("<- unhandled input message id=$messageId (${body.size} bytes)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,9 +464,9 @@ class GearslipRunner(
|
||||
when (messageId) {
|
||||
MSG_CHANNEL_OPEN_RESPONSE -> {
|
||||
val status = Protobuf.readInt32Field(body, 1)
|
||||
GearslipLog.i("<- ChannelOpenResponse: status=$status")
|
||||
log.i("<- ChannelOpenResponse: status=$status")
|
||||
if (status != 0) {
|
||||
GearslipLog.verdict(
|
||||
log.verdict(
|
||||
"PHASE A FAILED (channel open rejected)",
|
||||
"The head unit refused to open the video channel (status=$status).",
|
||||
)
|
||||
@@ -435,7 +476,7 @@ class GearslipRunner(
|
||||
// Setup { required MediaCodecType type = 1 }
|
||||
send(MSG_MEDIA_SETUP, Protobuf.varintField(1, CODEC_H264_BP.toLong()),
|
||||
encrypted = true, channel = videoChannelId)
|
||||
GearslipLog.i("-> Setup(codec=VIDEO_H264_BP)")
|
||||
log.i("-> Setup(codec=VIDEO_H264_BP)")
|
||||
}
|
||||
|
||||
MSG_MEDIA_CONFIG -> {
|
||||
@@ -443,7 +484,7 @@ class GearslipRunner(
|
||||
val status = Wire.varint(fields, 1)?.toInt()
|
||||
val maxUnackedFromConfig = Wire.varint(fields, 2)?.toInt()
|
||||
val indices = fields.filter { it.number == 3 && it.wireType == 0 }.map { it.varint }
|
||||
GearslipLog.i(
|
||||
log.i(
|
||||
"<- Config: status=$status (1=WAIT 2=READY) max_unacked=$maxUnackedFromConfig " +
|
||||
"configuration_indices=$indices",
|
||||
)
|
||||
@@ -457,21 +498,25 @@ class GearslipRunner(
|
||||
selectedConfigIndex = preferred ?: offered.minByOrNull { idx ->
|
||||
videoConfigs.getOrNull(idx)?.pixelCount() ?: Int.MAX_VALUE
|
||||
} ?: offered.firstOrNull()
|
||||
GearslipLog.i("selected config index $selectedConfigIndex from offered $offered")
|
||||
log.i("selected config index $selectedConfigIndex from offered $offered")
|
||||
SessionReport.video(
|
||||
"${videoConfigs.getOrNull(selectedConfigIndex ?: -1)?.resolutionName ?: "?"} " +
|
||||
"(head unit offered ${offered.size}), max_unacked=$maxUnacked",
|
||||
)
|
||||
// VideoFocusRequestNotification { mode = 2; reason = 3 }
|
||||
val focus = Protobuf.varintField(2, VIDEO_FOCUS_PROJECTED.toLong()) +
|
||||
Protobuf.varintField(3, 1L)
|
||||
send(MSG_VIDEO_FOCUS_REQUEST, focus, encrypted = true, channel = videoChannelId)
|
||||
GearslipLog.i("-> VideoFocusRequest(mode=PROJECTED)")
|
||||
log.i("-> VideoFocusRequest(mode=PROJECTED)")
|
||||
}
|
||||
|
||||
MSG_VIDEO_FOCUS_NOTIFICATION -> {
|
||||
val fields = Wire.fields(body)
|
||||
GearslipLog.i("<- VideoFocusNotification: mode=${Wire.varint(fields, 1)}")
|
||||
log.i("<- VideoFocusNotification: mode=${Wire.varint(fields, 1)}")
|
||||
// The head unit repeats this while it waits for frames; only act once.
|
||||
if (!videoStarted) {
|
||||
videoStarted = true
|
||||
GearslipLog.i("PHASE A COMPLETE - video focus granted; starting video source")
|
||||
log.i("PHASE A COMPLETE - video focus granted; starting video source")
|
||||
startVideoSource()
|
||||
}
|
||||
}
|
||||
@@ -483,11 +528,11 @@ class GearslipRunner(
|
||||
acksSeen++
|
||||
lastAckAt = System.currentTimeMillis()
|
||||
if (framesSent <= 3 || framesSent % 60 == 0) {
|
||||
GearslipLog.i("<- MediaAck(ack=$acked) inFlight=$inFlight after $framesSent frames")
|
||||
log.i("<- MediaAck(ack=$acked) inFlight=$inFlight after $framesSent frames")
|
||||
}
|
||||
}
|
||||
|
||||
else -> GearslipLog.i("<- unhandled video message id=$messageId (${body.size} bytes)")
|
||||
else -> log.i("<- unhandled video message id=$messageId (${body.size} bytes)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -503,18 +548,18 @@ class GearslipRunner(
|
||||
val index = selectedConfigIndex ?: 0
|
||||
val config = videoConfigs.getOrNull(index)
|
||||
if (config == null) {
|
||||
GearslipLog.e("no video config at index $index - cannot start the encoder")
|
||||
log.e("no video config at index $index - cannot start the encoder")
|
||||
return
|
||||
}
|
||||
val (width, height) = config.pixelSize() ?: run {
|
||||
GearslipLog.e("unsupported resolution ${config.resolutionName}")
|
||||
log.e("unsupported resolution ${config.resolutionName}")
|
||||
return
|
||||
}
|
||||
|
||||
val start = Protobuf.varintField(1, SESSION_ID.toLong()) +
|
||||
Protobuf.varintField(2, index.toLong())
|
||||
send(MSG_MEDIA_START, start, encrypted = true, channel = videoChannelId)
|
||||
GearslipLog.i("-> Start(session_id=$SESSION_ID, configuration_index=$index -> $config)")
|
||||
log.i("-> Start(session_id=$SESSION_ID, configuration_index=$index -> $config)")
|
||||
|
||||
val useProjection = projection != null
|
||||
videoSource = VideoSource(
|
||||
@@ -524,8 +569,8 @@ class GearslipRunner(
|
||||
mode = if (useProjection) VideoSource.Mode.SURFACE else VideoSource.Mode.TEST_CARD,
|
||||
onCodecConfig = { csd ->
|
||||
send(MSG_MEDIA_CODEC_CONFIG, csd, encrypted = true, channel = videoChannelId)
|
||||
GearslipLog.i("-> CodecConfig (${csd.size} bytes SPS/PPS)")
|
||||
GearslipLog.hex(" csd", csd, limit = 64)
|
||||
log.i("-> CodecConfig (${csd.size} bytes SPS/PPS)")
|
||||
log.hex(" csd", csd, limit = 64)
|
||||
},
|
||||
onFrame = { data, presentationTimeUs, keyFrame ->
|
||||
sendVideoFrame(data, presentationTimeUs, keyFrame)
|
||||
@@ -538,7 +583,7 @@ class GearslipRunner(
|
||||
projection.onSurfaceReady(surface, width, height, config.density)
|
||||
SessionStatus.projecting()
|
||||
}
|
||||
}.onFailure { e -> GearslipLog.e("encoder start failed", e) }
|
||||
}.onFailure { e -> log.e("encoder start failed", e) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,12 +594,12 @@ class GearslipRunner(
|
||||
// looks identical to a frozen picture. Assume the window cleared and carry on.
|
||||
val since = System.currentTimeMillis() - lastAckAt
|
||||
if (since > ACK_STALL_MS) {
|
||||
GearslipLog.w("no ack for ${since}ms with $inFlight in flight - resetting the window")
|
||||
log.w("no ack for ${since}ms with $inFlight in flight - resetting the window")
|
||||
inFlight = 0
|
||||
} else {
|
||||
dropped++
|
||||
if (dropped % 30 == 1) {
|
||||
GearslipLog.w("dropping frame - $inFlight in flight (max $maxUnacked)")
|
||||
log.w("dropping frame - $inFlight in flight (max $maxUnacked)")
|
||||
}
|
||||
// A dropped frame breaks the decoder's reference chain on the head unit -
|
||||
// every frame after it is a P-frame assuming a picture that never arrived.
|
||||
@@ -581,7 +626,7 @@ class GearslipRunner(
|
||||
}
|
||||
if (keyFrame) {
|
||||
if (resyncRequested) {
|
||||
GearslipLog.i("resync keyframe delivered after $resyncAttempts attempt(s)")
|
||||
log.i("resync keyframe delivered after $resyncAttempts attempt(s)")
|
||||
}
|
||||
resyncRequested = false
|
||||
resyncAttempts = 0
|
||||
@@ -590,7 +635,7 @@ class GearslipRunner(
|
||||
try {
|
||||
send(MSG_MEDIA_DATA, timestamp + data, encrypted = true, channel = videoChannelId)
|
||||
} catch (e: java.io.IOException) {
|
||||
if (running) GearslipLog.w("video send failed, stopping the encoder: ${e.message}")
|
||||
if (running) log.w("video send failed, stopping the encoder: ${e.message}")
|
||||
running = false
|
||||
videoSource?.stop()
|
||||
return
|
||||
@@ -598,7 +643,7 @@ class GearslipRunner(
|
||||
inFlight++
|
||||
framesSent++
|
||||
if (framesSent <= 3 || keyFrame && framesSent % 30 == 0) {
|
||||
GearslipLog.i(
|
||||
log.i(
|
||||
"-> Data frame #$framesSent (${data.size} bytes, keyFrame=$keyFrame, " +
|
||||
"ptsUs=$presentationTimeUs)",
|
||||
)
|
||||
@@ -615,7 +660,7 @@ class GearslipRunner(
|
||||
private fun onPingRequest(body: ByteArray) {
|
||||
val timestamp = Protobuf.readInt32Field(body, 1)?.toLong() ?: System.nanoTime()
|
||||
send(MSG_PING_RESPONSE, Protobuf.varintField(1, timestamp), encrypted = tls.handshakeComplete)
|
||||
GearslipLog.i("<- PingRequest / -> PingResponse (keeping the session alive)")
|
||||
log.i("<- PingRequest / -> PingResponse (keeping the session alive)")
|
||||
}
|
||||
|
||||
private fun send(
|
||||
@@ -662,6 +707,13 @@ class GearslipRunner(
|
||||
}
|
||||
|
||||
private fun reportDisconnect() {
|
||||
when (state) {
|
||||
State.WAIT_VERSION -> SessionReport.fail(SessionReport.Category.NO_HANDSHAKE, "no VersionRequest arrived", state.name)
|
||||
State.TLS_HANDSHAKE -> SessionReport.fail(SessionReport.Category.TLS, "head unit dropped the link during TLS", state.name)
|
||||
State.WAIT_AUTH -> SessionReport.fail(SessionReport.Category.AUTH, "dropped after TLS, before AuthComplete", state.name)
|
||||
State.WAIT_SDR -> SessionReport.fail(SessionReport.Category.SERVICE_DISCOVERY, "accepted, then dropped before service discovery", state.name)
|
||||
State.DONE -> {}
|
||||
}
|
||||
when (state) {
|
||||
State.DONE -> SessionStatus.disconnected()
|
||||
State.WAIT_VERSION -> SessionStatus.failed(
|
||||
@@ -678,28 +730,29 @@ class GearslipRunner(
|
||||
)
|
||||
}
|
||||
when (state) {
|
||||
State.WAIT_VERSION -> GearslipLog.verdict(
|
||||
State.WAIT_VERSION -> log.verdict(
|
||||
"INCONCLUSIVE (no VersionRequest)",
|
||||
"The head unit never opened the control channel. This is a connection problem, " +
|
||||
"not a certificate verdict - check that the accessory was actually claimed.",
|
||||
)
|
||||
State.TLS_HANDSHAKE -> GearslipLog.verdict(
|
||||
State.TLS_HANDSHAKE -> log.verdict(
|
||||
"LIKELY NOT VIABLE (dropped during TLS)",
|
||||
"The unit disconnected mid-handshake without sending an alert we could read. " +
|
||||
"Rejection is the likeliest reading, but re-run once before trusting it.",
|
||||
)
|
||||
State.WAIT_AUTH -> GearslipLog.verdict(
|
||||
State.WAIT_AUTH -> log.verdict(
|
||||
"INCONCLUSIVE (dropped after TLS, before AuthComplete)",
|
||||
"TLS completed but the unit disconnected before stating a verdict. Re-run.",
|
||||
)
|
||||
State.WAIT_SDR -> GearslipLog.verdict(
|
||||
State.WAIT_SDR -> log.verdict(
|
||||
"INCONCLUSIVE (accepted, then dropped before service discovery)",
|
||||
"AuthComplete said success, so the certificate was accepted - but our " +
|
||||
"ServiceDiscoveryRequest drew no reply. Suspect our own encryption or " +
|
||||
"framing rather than the head unit.",
|
||||
)
|
||||
State.DONE -> GearslipLog.i("connection closed after the run completed")
|
||||
State.DONE -> log.i("connection closed after the run completed")
|
||||
}
|
||||
SessionReport.print()
|
||||
}
|
||||
|
||||
private fun startWatchdog() {
|
||||
@@ -711,7 +764,7 @@ class GearslipRunner(
|
||||
|
||||
if (state != State.DONE) {
|
||||
val idle = System.currentTimeMillis() - lastProgress
|
||||
if (idle > 10_000) GearslipLog.w("stalled in state $state for ${idle / 1000}s")
|
||||
if (idle > 10_000) log.w("stalled in state $state for ${idle / 1000}s")
|
||||
}
|
||||
|
||||
// Stream health. Without this a drive cannot tell "streaming fine" from
|
||||
@@ -720,7 +773,8 @@ class GearslipRunner(
|
||||
val sent = framesSent
|
||||
val fps = (sent - lastFrames) / 5.0
|
||||
lastFrames = sent
|
||||
GearslipLog.i(
|
||||
SessionReport.stream("${"%.1f".format(fps)} fps, sent=$sent dropped=$dropped acked=$acksSeen")
|
||||
log.i(
|
||||
"video: ${"%.1f".format(fps)} fps over 5s | sent=$sent dropped=$dropped " +
|
||||
"acked=$acksSeen inFlight=$inFlight split=$splitMessages",
|
||||
)
|
||||
@@ -739,6 +793,13 @@ class GearslipRunner(
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
* Force the protocol version Gearslip claims in VersionResponse, as (major, minor), or
|
||||
* null to echo the head unit. 1.3 is what the 2018 Uconnect (which accepts the JVC cert)
|
||||
* speaks. This is the 2025 identity-check downgrade experiment: see onVersionRequest.
|
||||
*/
|
||||
val CLAIM_VERSION: Pair<Int, Int>? = 1 to 3
|
||||
|
||||
// protobuf/aap_protobuf/service/control/ControlMessageType.proto:7-17
|
||||
const val MSG_VERSION_REQUEST = 1
|
||||
const val MSG_VERSION_RESPONSE = 2
|
||||
@@ -770,6 +831,7 @@ class GearslipRunner(
|
||||
const val ACTION_DOWN = 0
|
||||
const val ACTION_UP = 1
|
||||
const val ACK_STALL_MS = 2_000L
|
||||
const val AUDIO_TRACE_MESSAGES = 6
|
||||
const val PERIODIC_KEYFRAME_MS = 5_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.math.BigInteger
|
||||
import java.security.KeyFactory
|
||||
import java.security.KeyStore
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.security.cert.X509Certificate
|
||||
import java.security.spec.RSAPrivateCrtKeySpec
|
||||
import java.security.spec.RSAPublicKeySpec
|
||||
|
||||
/**
|
||||
* Converts a PEM-encoded X.509 certificate and PEM-encoded RSA PKCS#1 private key into a
|
||||
* PKCS#12 [KeyStore], using only standard JCA APIs — no BouncyCastle.
|
||||
*
|
||||
* The DER parsing of the PKCS#1 key is done by hand (matching the same approach used in
|
||||
* [SelfSignedCert] for writing). Android's Conscrypt [KeyFactory] only accepts PKCS#8,
|
||||
* so we translate PKCS#1 → [RSAPrivateCrtKeySpec] ourselves.
|
||||
*/
|
||||
object PemKeyParser {
|
||||
|
||||
private const val CERT_HEADER = "-----BEGIN CERTIFICATE-----"
|
||||
private const val CERT_FOOTER = "-----END CERTIFICATE-----"
|
||||
private const val KEY_HEADER = "-----BEGIN RSA PRIVATE KEY-----"
|
||||
private const val KEY_FOOTER = "-----END RSA PRIVATE KEY-----"
|
||||
|
||||
class Result(val keyStore: KeyStore, val certificate: X509Certificate)
|
||||
|
||||
/**
|
||||
* Parses PEM strings for the certificate and private key, verifies they match, and
|
||||
* returns a PKCS#12 [KeyStore] ready for [CertProvider] to consume.
|
||||
*/
|
||||
fun build(certificatePem: String, privateKeyPem: String, alias: String = "phone"): Result {
|
||||
val cert = parseCertificate(certificatePem)
|
||||
val keySpec = parsePrivateKey(privateKeyPem)
|
||||
|
||||
val kf = KeyFactory.getInstance("RSA")
|
||||
val pubSpec = RSAPublicKeySpec(keySpec.spec.modulus, keySpec.spec.publicExponent)
|
||||
require(kf.generatePublic(pubSpec) == cert.publicKey) {
|
||||
"the certificate's public key does not match the private key"
|
||||
}
|
||||
|
||||
val keyStore = KeyStore.getInstance("PKCS12").apply {
|
||||
load(null, null)
|
||||
setKeyEntry(alias, keySpec.getPrivate(), CharArray(0), arrayOf(cert))
|
||||
}
|
||||
return Result(keyStore, cert)
|
||||
}
|
||||
|
||||
/** Strips PEM headers/footers and decodes the base64 body. */
|
||||
fun parseCertificate(pem: String): X509Certificate {
|
||||
val der = pemToDer(pem, CERT_HEADER, CERT_FOOTER)
|
||||
return CertificateFactory.getInstance("X.509")
|
||||
.generateCertificate(ByteArrayInputStream(der)) as X509Certificate
|
||||
}
|
||||
|
||||
/** Parses an RSA PKCS#1 private key (ASN.1 DER) into a key spec. */
|
||||
fun parsePrivateKey(pem: String): KeySpec {
|
||||
val der = pemToDer(pem, KEY_HEADER, KEY_FOOTER)
|
||||
val integers = readPkcs1Sequence(der)
|
||||
require(integers.size == 9) { "PKCS#1 RSA key needs 9 integers, got ${integers.size}" }
|
||||
val spec = RSAPrivateCrtKeySpec(integers[1], integers[2], integers[3], integers[4], integers[5], integers[6], integers[7], integers[8])
|
||||
val privateKey = KeyFactory.getInstance("RSA").generatePrivate(spec)
|
||||
return KeySpec(spec, privateKey)
|
||||
}
|
||||
|
||||
/** Holds both the spec and the constructed key for matching against a certificate's public key. */
|
||||
class KeySpec(val spec: RSAPrivateCrtKeySpec, private val privateKey: java.security.PrivateKey) {
|
||||
fun getPrivate() = privateKey
|
||||
}
|
||||
|
||||
/** Strips headers and decodes base64. */
|
||||
private fun pemToDer(pem: String, header: String, footer: String): ByteArray {
|
||||
val start = pem.indexOf(header)
|
||||
val end = pem.indexOf(footer)
|
||||
if (start == -1 || end == -1 || end <= start) {
|
||||
throw IllegalArgumentException("not a valid PEM $header block")
|
||||
}
|
||||
val b64 = pem.substring(start + header.length, end)
|
||||
.replace(Regex("\\s+"), "")
|
||||
return java.util.Base64.getDecoder().decode(b64)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the inner SEQUENCE of an RSA PKCS#1 DER blob into a list of [BigInteger]s.
|
||||
* Throws if the ASN.1 structure is wrong, so caller gets a clear error rather than a
|
||||
* silent wrong key.
|
||||
*/
|
||||
private fun readPkcs1Sequence(der: ByteArray): List<BigInteger> {
|
||||
var pos = 0
|
||||
fun readTag(): Int {
|
||||
require(pos < der.size) { "unexpected end of DER at tag" }
|
||||
return der[pos++].toInt() and 0xFF
|
||||
}
|
||||
fun readLength(): Int {
|
||||
require(pos < der.size) { "unexpected end of DER at length" }
|
||||
val b0 = der[pos++].toInt() and 0xFF
|
||||
if (b0 < 0x80) return b0
|
||||
val numBytes = b0 and 0x7F
|
||||
require(pos + numBytes <= der.size) { "truncated long-form length" }
|
||||
var result = 0
|
||||
repeat(numBytes) {
|
||||
result = (result shl 8) or (der[pos++].toInt() and 0xFF)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
val outerTag = readTag()
|
||||
require(outerTag == 0x30) { "expected SEQUENCE (0x30), got 0x${outerTag.toString(16)}" }
|
||||
val outerLen = readLength()
|
||||
require(pos + outerLen <= der.size) { "SEQUENCE length $outerLen exceeds remaining data" }
|
||||
|
||||
val integers = mutableListOf<BigInteger>()
|
||||
while (pos < der.size) {
|
||||
val tag = readTag()
|
||||
require(tag == 0x02) { "expected INTEGER (0x02), got 0x${tag.toString(16)} at offset ${pos - 1}" }
|
||||
val len = readLength()
|
||||
require(pos + len <= der.size) { "INTEGER length $len exceeds remaining data" }
|
||||
integers.add(BigInteger(der.copyOfRange(pos, pos + len)))
|
||||
pos += len
|
||||
}
|
||||
require(integers.size == 9) { "PKCS#1 RSA key needs 9 integers, got ${integers.size}" }
|
||||
return integers
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import javax.net.ssl.X509ExtendedTrustManager
|
||||
* the right tool.
|
||||
*/
|
||||
class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
||||
private val log = GearslipLog.tagged("TLS")
|
||||
|
||||
private val engine: SSLEngine
|
||||
private var netIn = ByteArray(0)
|
||||
@@ -156,7 +157,7 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
||||
continue
|
||||
}
|
||||
SSLEngineResult.Status.BUFFER_UNDERFLOW -> {
|
||||
GearslipLog.w("decrypt: record split across frames (${source.remaining()} bytes left over)")
|
||||
log.w("decrypt: record split across frames (${source.remaining()} bytes left over)")
|
||||
break
|
||||
}
|
||||
SSLEngineResult.Status.CLOSED -> break
|
||||
@@ -173,26 +174,29 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
||||
|
||||
private fun describeSession() {
|
||||
val session = engine.session
|
||||
GearslipLog.i("TLS handshake COMPLETE - protocol=${session.protocol} cipher=${session.cipherSuite}")
|
||||
log.i("TLS handshake COMPLETE - protocol=${session.protocol} cipher=${session.cipherSuite}")
|
||||
SessionReport.tls(session.protocol, session.cipherSuite)
|
||||
try {
|
||||
val peers = session.peerCertificates
|
||||
GearslipLog.i("head unit presented ${peers.size} certificate(s):")
|
||||
log.i("head unit presented ${peers.size} certificate(s):")
|
||||
peers.forEachIndexed { index, certificate ->
|
||||
if (certificate is X509Certificate) {
|
||||
GearslipLog.i(" [$index] subject=${certificate.subjectX500Principal}")
|
||||
GearslipLog.i(" issuer =${certificate.issuerX500Principal}")
|
||||
GearslipLog.i(" serial =${certificate.serialNumber} valid=${certificate.notBefore}..${certificate.notAfter}")
|
||||
log.i(" [$index] subject=${certificate.subjectX500Principal}")
|
||||
log.i(" issuer =${certificate.issuerX500Principal}")
|
||||
log.i(" serial =${certificate.serialNumber} valid=${certificate.notBefore}..${certificate.notAfter}")
|
||||
}
|
||||
}
|
||||
} catch (_: SSLPeerUnverifiedException) {
|
||||
GearslipLog.i("head unit presented NO certificate (we asked but did not require one)")
|
||||
log.i("head unit presented NO certificate (we asked but did not require one)")
|
||||
}
|
||||
}
|
||||
|
||||
/** Accepts everything and reports what it saw. We want the data, not a verdict. */
|
||||
private class LoggingTrustManager : X509ExtendedTrustManager() {
|
||||
private val log = GearslipLog.tagged("TLS")
|
||||
|
||||
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {
|
||||
GearslipLog.i("peer certificate offered (authType=$authType, chain=${chain?.size ?: 0}) - accepting")
|
||||
log.i("peer certificate offered (authType=$authType, chain=${chain?.size ?: 0}) - accepting")
|
||||
}
|
||||
|
||||
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?, socket: Socket?) =
|
||||
|
||||
@@ -8,8 +8,7 @@ object ProjectedContent {
|
||||
|
||||
/**
|
||||
* Precedence: the URL saved in Settings, then a `url.txt` in the external files dir (the
|
||||
* pre-Settings way, still honoured so existing setups keep working), then the built-in
|
||||
* test page.
|
||||
* pre-Settings way, still honoured so existing setups keep working), then [DEFAULT_URL].
|
||||
*/
|
||||
fun resolve(context: Context): String {
|
||||
val saved = AppSettings.startupUrl(context)
|
||||
@@ -26,9 +25,12 @@ object ProjectedContent {
|
||||
return url
|
||||
}
|
||||
}
|
||||
return TEST_PAGE
|
||||
return DEFAULT_URL
|
||||
}
|
||||
|
||||
/** What the Web app opens until a page is chosen in Settings. */
|
||||
const val DEFAULT_URL = "https://duckduckgo.com"
|
||||
|
||||
/** A bare "example.com" is a URL the user meant; raw HTML is passed through untouched. */
|
||||
private fun normalize(value: String): String =
|
||||
if (value.startsWith("<") || "://" in value) value else "https://$value"
|
||||
|
||||
@@ -17,9 +17,14 @@ interface Projection {
|
||||
/**
|
||||
* A touch from the head unit, already scaled into projected-video coordinates.
|
||||
* [action] is an Android MotionEvent action - Android Auto's PointerAction enum uses
|
||||
* identical values (DOWN=0, UP=1, MOVED=2, POINTER_DOWN=5, POINTER_UP=6).
|
||||
* identical values (DOWN=0, UP=1, MOVED=2, POINTER_DOWN=5, POINTER_UP=6). [points] is every
|
||||
* finger currently down, and [actionIndex] says which of them [action] is about, so a pinch
|
||||
* arrives as it would on the phone's own screen.
|
||||
*/
|
||||
fun onTouch(action: Int, x: Float, y: Float)
|
||||
fun onTouch(action: Int, actionIndex: Int, points: List<TouchPoint>)
|
||||
|
||||
fun onProjectionStopped()
|
||||
}
|
||||
|
||||
/** One finger on the car's screen: a stable [id] for as long as it stays down, and where it is. */
|
||||
class TouchPoint(val id: Int, val x: Float, val y: Float)
|
||||
@@ -136,13 +136,41 @@ class ScreenProjector(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/** [action] is already an Android MotionEvent action; x/y are in projected coordinates. */
|
||||
fun dispatchTouch(action: Int, x: Float, y: Float) {
|
||||
/**
|
||||
* [action] is already an Android MotionEvent action; [points] are in projected coordinates and
|
||||
* hold every finger down, with [actionIndex] naming the one the action is about. Two fingers
|
||||
* arrive as one gesture, so pinch and two-finger drags work as they do on the phone itself.
|
||||
*/
|
||||
fun dispatchTouch(action: Int, actionIndex: Int, points: List<TouchPoint>) {
|
||||
val target = root ?: return
|
||||
if (points.isEmpty()) return
|
||||
val now = SystemClock.uptimeMillis()
|
||||
if (action == MotionEvent.ACTION_DOWN || downTime == 0L) downTime = now
|
||||
|
||||
val event = MotionEvent.obtain(downTime, now, action, x, y, 0)
|
||||
val properties = Array(points.size) { i ->
|
||||
MotionEvent.PointerProperties().apply {
|
||||
id = points[i].id
|
||||
toolType = MotionEvent.TOOL_TYPE_FINGER
|
||||
}
|
||||
}
|
||||
val coords = Array(points.size) { i ->
|
||||
MotionEvent.PointerCoords().apply {
|
||||
x = points[i].x
|
||||
y = points[i].y
|
||||
pressure = 1f
|
||||
size = 1f
|
||||
}
|
||||
}
|
||||
// A second finger going down or up carries which finger it was in the action itself.
|
||||
val encoded = when (action) {
|
||||
MotionEvent.ACTION_POINTER_DOWN, MotionEvent.ACTION_POINTER_UP ->
|
||||
action or (actionIndex shl MotionEvent.ACTION_POINTER_INDEX_SHIFT)
|
||||
else -> action
|
||||
}
|
||||
val event = MotionEvent.obtain(
|
||||
downTime, now, encoded, points.size, properties, coords,
|
||||
0, 0, 1f, 1f, 0, 0, android.view.InputDevice.SOURCE_TOUCHSCREEN, 0,
|
||||
)
|
||||
try {
|
||||
target.dispatchTouchEvent(event)
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
|
||||
/**
|
||||
* The facts a bug report needs, gathered as a session runs and printed as one readable block
|
||||
* when it ends. The log holds the full trace; this holds the answer to "what went wrong,
|
||||
* on what car, on what phone" so nobody has to read the trace to find out.
|
||||
*
|
||||
* Nothing here identifies a person: no serial numbers, accounts, locations or accessory
|
||||
* serials. The head unit and vehicle strings are what the head unit itself announces.
|
||||
*/
|
||||
object SessionReport {
|
||||
|
||||
/** Where a session failed, coarse enough to count and group reports by. */
|
||||
enum class Category(val label: String) {
|
||||
NONE("none"),
|
||||
USB("USB transport"),
|
||||
NO_HANDSHAKE("head unit never started talking"),
|
||||
TLS("TLS handshake failed"),
|
||||
CERTIFICATE("certificate rejected by the head unit"),
|
||||
AUTH("authentication failed"),
|
||||
SERVICE_DISCOVERY("service discovery failed"),
|
||||
VIDEO("video stream failed"),
|
||||
CRASH("app crash"),
|
||||
UNKNOWN("unclassified"),
|
||||
}
|
||||
|
||||
private var appVersion = "?"
|
||||
private var appCode = 0L
|
||||
|
||||
@Volatile private var startedAt = 0L
|
||||
@Volatile var category = Category.NONE; private set
|
||||
@Volatile private var failure = ""
|
||||
@Volatile private var protocolVersion = ""
|
||||
@Volatile private var certSource = ""
|
||||
@Volatile private var certIssuer = ""
|
||||
@Volatile private var tlsProtocol = ""
|
||||
@Volatile private var tlsCipher = ""
|
||||
@Volatile private var authStatus: Int? = null
|
||||
@Volatile private var headUnit = ""
|
||||
@Volatile private var profile = ""
|
||||
@Volatile private var video = ""
|
||||
@Volatile private var audio = ""
|
||||
@Volatile private var lastStream = ""
|
||||
@Volatile private var lastState = ""
|
||||
private var printed = false
|
||||
|
||||
fun init(context: Context) {
|
||||
val info = runCatching { context.packageManager.getPackageInfo(context.packageName, 0) }.getOrNull()
|
||||
appVersion = info?.versionName ?: "?"
|
||||
appCode = info?.longVersionCode ?: 0
|
||||
}
|
||||
|
||||
fun begin() {
|
||||
startedAt = System.currentTimeMillis()
|
||||
category = Category.NONE
|
||||
failure = ""; protocolVersion = ""; certSource = ""; certIssuer = ""
|
||||
tlsProtocol = ""; tlsCipher = ""; authStatus = null
|
||||
headUnit = ""; profile = ""; video = ""; lastStream = ""; lastState = ""
|
||||
printed = false
|
||||
}
|
||||
|
||||
fun protocolVersion(major: Int, minor: Int) { protocolVersion = "$major.$minor" }
|
||||
fun certificate(source: String, issuer: String) { certSource = source; certIssuer = issuer }
|
||||
fun tls(protocol: String, cipher: String) { tlsProtocol = protocol; tlsCipher = cipher }
|
||||
fun auth(status: Int) { authStatus = status }
|
||||
fun headUnit(info: String) { headUnit = info }
|
||||
fun profile(name: String?) { profile = name ?: "none matched" }
|
||||
fun video(summary: String) { video = summary }
|
||||
fun audio(summary: String) { audio = summary }
|
||||
fun stream(summary: String) { lastStream = summary }
|
||||
|
||||
/** Records the first failure only: later ones are usually fallout from it. */
|
||||
fun fail(category: Category, detail: String, state: String = "") {
|
||||
if (this.category != Category.NONE) return
|
||||
this.category = category
|
||||
failure = detail
|
||||
lastState = state
|
||||
}
|
||||
|
||||
fun fail(category: Category, detail: String, t: Throwable, state: String = "") =
|
||||
fail(category, "$detail (${GearslipLog.rootCause(t)})", state)
|
||||
|
||||
/** The report as plain text, for the log file and for anything that gets sent. */
|
||||
fun render(): String {
|
||||
val seconds = if (startedAt == 0L) 0 else (System.currentTimeMillis() - startedAt) / 1000
|
||||
fun row(name: String, value: String) = if (value.isEmpty()) "" else " ${name.padEnd(16)}$value\n"
|
||||
return buildString {
|
||||
append("=".repeat(60)).append('\n')
|
||||
append("SESSION REPORT\n")
|
||||
append(row("result", if (category == Category.NONE) "ok / no failure recorded" else "FAILED - ${category.label}"))
|
||||
append(row("detail", failure))
|
||||
append(row("failed in state", lastState))
|
||||
append(row("duration", "${seconds}s"))
|
||||
append("\n")
|
||||
append(row("gearslip", "$appVersion (code $appCode)"))
|
||||
append(row("phone", "${Build.MANUFACTURER} ${Build.MODEL}"))
|
||||
append(row("android", "${Build.VERSION.RELEASE} (sdk ${Build.VERSION.SDK_INT}, patch ${Build.VERSION.SECURITY_PATCH})"))
|
||||
append("\n")
|
||||
append(row("head unit", headUnit))
|
||||
append(row("AA protocol", protocolVersion))
|
||||
append(row("profile", profile))
|
||||
append("\n")
|
||||
append(row("phone cert", certSource))
|
||||
append(row("cert issuer", certIssuer))
|
||||
append(row("TLS", listOf(tlsProtocol, tlsCipher).filter { it.isNotEmpty() }.joinToString(" / ")))
|
||||
append(row("auth status", authStatus?.let { "$it (${authName(it)})" } ?: ""))
|
||||
append("\n")
|
||||
append(row("video", video))
|
||||
append(row("audio", audio))
|
||||
append(row("last stream stat", lastStream))
|
||||
append("=".repeat(60))
|
||||
}
|
||||
}
|
||||
|
||||
/** Prints the report into the log, once per session. */
|
||||
fun print() {
|
||||
if (printed || startedAt == 0L) return
|
||||
printed = true
|
||||
render().lines().forEach { GearslipLog.i(it) }
|
||||
GearslipLog.flush()
|
||||
}
|
||||
|
||||
private fun authName(status: Int) = when (status) {
|
||||
0 -> "success"
|
||||
-2 -> "certificate error"
|
||||
-3 -> "authentication failure"
|
||||
else -> "unknown"
|
||||
}
|
||||
}
|
||||
@@ -21,12 +21,13 @@ import javax.net.ssl.X509ExtendedTrustManager
|
||||
* unit, not about this code.
|
||||
*/
|
||||
object TlsSelfTest {
|
||||
private val log = GearslipLog.tagged("TLS")
|
||||
|
||||
fun run(identity: CertProvider.Identity): Boolean {
|
||||
GearslipLog.i("=== TLS self-test starting ===")
|
||||
GearslipLog.i("cert source: ${identity.source}")
|
||||
GearslipLog.i(" subject = ${identity.certificate.subjectX500Principal}")
|
||||
GearslipLog.i(" issuer = ${identity.certificate.issuerX500Principal}")
|
||||
log.i("=== TLS self-test starting ===")
|
||||
log.i("cert source: ${identity.source}")
|
||||
log.i(" subject = ${identity.certificate.subjectX500Principal}")
|
||||
log.i(" issuer = ${identity.certificate.issuerX500Principal}")
|
||||
return try {
|
||||
val server = PhoneTls(identity.keyStore, identity.password)
|
||||
val client = Driver(clientEngine())
|
||||
@@ -44,7 +45,7 @@ object TlsSelfTest {
|
||||
}
|
||||
|
||||
if (!server.handshakeComplete) {
|
||||
GearslipLog.e("self-test FAILED: handshake did not complete after $rounds rounds")
|
||||
log.e("self-test FAILED: handshake did not complete after $rounds rounds")
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -53,14 +54,14 @@ object TlsSelfTest {
|
||||
val plaintext = "gearslip round trip".toByteArray()
|
||||
val decrypted = client.decrypt(server.encrypt(plaintext))
|
||||
if (!decrypted.contentEquals(plaintext)) {
|
||||
GearslipLog.e("self-test FAILED: round trip mismatch (${decrypted.size} bytes back)")
|
||||
log.e("self-test FAILED: round trip mismatch (${decrypted.size} bytes back)")
|
||||
return false
|
||||
}
|
||||
|
||||
GearslipLog.i("=== TLS self-test PASSED - certificate, key manager and engine are sound ===")
|
||||
log.i("=== TLS self-test PASSED - certificate, key manager and engine are sound ===")
|
||||
true
|
||||
} catch (t: Throwable) {
|
||||
GearslipLog.e("self-test FAILED with an exception", t)
|
||||
log.e("self-test FAILED with an exception", t)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ class VideoSource(
|
||||
private val onCodecConfig: (ByteArray) -> Unit,
|
||||
private val onFrame: (data: ByteArray, presentationTimeUs: Long, keyFrame: Boolean) -> Unit,
|
||||
) {
|
||||
private val log = GearslipLog.tagged("VIDEO")
|
||||
|
||||
/**
|
||||
* TEST_CARD feeds generated YUV buffers; SURFACE exposes a MediaCodec input Surface for
|
||||
@@ -67,11 +68,11 @@ class VideoSource(
|
||||
codec = encoder
|
||||
running = true
|
||||
|
||||
GearslipLog.i("encoder started: ${width}x$height @ ${frameRate}fps, H.264 Baseline, mode=$mode")
|
||||
log.i("encoder started: ${width}x$height @ ${frameRate}fps, H.264 Baseline, mode=$mode")
|
||||
|
||||
worker = Thread {
|
||||
runCatching { pump(encoder) }
|
||||
.onFailure { if (running) GearslipLog.e("encoder loop failed", it) }
|
||||
.onFailure { if (running) log.e("encoder loop failed", it) }
|
||||
}.apply { name = "gearslip-encoder"; start() }
|
||||
}
|
||||
|
||||
@@ -89,7 +90,7 @@ class VideoSource(
|
||||
codec?.setParameters(Bundle().apply {
|
||||
putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0)
|
||||
})
|
||||
}.onFailure { GearslipLog.w("requestSyncFrame failed: ${it.message}") }
|
||||
}.onFailure { log.w("requestSyncFrame failed: ${it.message}") }
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
|
||||
@@ -24,16 +24,51 @@ import kotlin.math.abs
|
||||
* Playback capture is the one public route to another app's sound, and it hangs off a
|
||||
* MediaProjection - so, as with [app.seb3thehacker.gearslip.mirror.MirrorService], the consent
|
||||
* result is carried into a foreground service of type mediaProjection before it can be redeemed.
|
||||
* Capture is scoped to the media app's uid: notification pings and other apps stay on the phone.
|
||||
* Capture is scoped to media playback: notification pings, calls and alarms stay on the phone.
|
||||
* The grant lasts as long as this service does, which is as long as the car is connected, so the
|
||||
* driver answers the consent dialog once per drive rather than once per app.
|
||||
*/
|
||||
class AudioCaptureService : Service() {
|
||||
|
||||
@Volatile private var reading = false
|
||||
private var projection: MediaProjection? = null
|
||||
private var thread: Thread? = null
|
||||
private var mutedPhone = false
|
||||
|
||||
/**
|
||||
* Silence the phone's own speaker while the car is being fed.
|
||||
*
|
||||
* Playback capture copies the stream instead of moving it, so without this the music plays in
|
||||
* two places at once. Muting the stream does not affect what is captured - the tap sits before
|
||||
* the volume the speaker gets - so the car still receives full-level audio.
|
||||
*/
|
||||
private fun mutePhone() {
|
||||
if (mutedPhone) return
|
||||
runCatching {
|
||||
getSystemService(android.media.AudioManager::class.java)
|
||||
.adjustStreamVolume(android.media.AudioManager.STREAM_MUSIC, android.media.AudioManager.ADJUST_MUTE, 0)
|
||||
mutedPhone = true
|
||||
GearslipLog.i("audio: muted the phone's speaker for the drive")
|
||||
}.onFailure { GearslipLog.w("audio: could not mute the phone: ${it.message}") }
|
||||
}
|
||||
|
||||
private fun unmutePhone() {
|
||||
if (!mutedPhone) return
|
||||
mutedPhone = false
|
||||
runCatching {
|
||||
getSystemService(android.media.AudioManager::class.java)
|
||||
.adjustStreamVolume(android.media.AudioManager.STREAM_MUSIC, android.media.AudioManager.ADJUST_UNMUTE, 0)
|
||||
GearslipLog.i("audio: gave the phone's speaker back")
|
||||
}.onFailure { GearslipLog.w("audio: could not unmute the phone: ${it.message}") }
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
running = true
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent?.action == ACTION_STOP) {
|
||||
stopSelf()
|
||||
@@ -49,6 +84,7 @@ class AudioCaptureService : Service() {
|
||||
}
|
||||
|
||||
startForeground(NOTIFICATION_ID, notification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION)
|
||||
ScreenShareGuard.lift(this)
|
||||
projection = try {
|
||||
getSystemService(MediaProjectionManager::class.java).getMediaProjection(resultCode, data)
|
||||
} catch (t: Throwable) {
|
||||
@@ -74,13 +110,14 @@ class AudioCaptureService : Service() {
|
||||
private fun beginCapture(token: MediaProjection) {
|
||||
val rate = CarAudio.sampleRate
|
||||
val channels = CarAudio.channels
|
||||
val uid = CarAudio.uid
|
||||
val record = try {
|
||||
// Matched by usage, not by app. A uid filter would have to be rebuilt every time the
|
||||
// driver changed media app, and rebuilding means a fresh consent dialog - so the
|
||||
// filter is what the sound is for, and any app playing media reaches the car.
|
||||
val config = AudioPlaybackCaptureConfiguration.Builder(token)
|
||||
.addMatchingUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.addMatchingUsage(AudioAttributes.USAGE_GAME)
|
||||
.addMatchingUsage(AudioAttributes.USAGE_UNKNOWN)
|
||||
.apply { if (uid >= 0) addMatchingUid(uid) }
|
||||
.build()
|
||||
val mask = if (channels == 1) AudioFormat.CHANNEL_IN_MONO else AudioFormat.CHANNEL_IN_STEREO
|
||||
val format = AudioFormat.Builder()
|
||||
@@ -106,17 +143,25 @@ class AudioCaptureService : Service() {
|
||||
val buffer = ByteArray(CHUNK_BYTES)
|
||||
var peak = 0
|
||||
var heardAt = System.currentTimeMillis()
|
||||
val startedAt = heardAt
|
||||
var everHeard = false
|
||||
var warnedSilent = false
|
||||
try {
|
||||
record.startRecording()
|
||||
GearslipLog.i("audio: capturing uid=$uid at ${rate}Hz x$channels")
|
||||
GearslipLog.i("audio: capturing media playback at ${rate}Hz x$channels")
|
||||
mutePhone()
|
||||
CarAudio.captureStarted()
|
||||
while (reading) {
|
||||
val n = record.read(buffer, 0, buffer.size)
|
||||
if (n <= 0) break
|
||||
if (n <= 0) {
|
||||
GearslipLog.e("audio: AudioRecord.read returned $n - capture has stopped")
|
||||
break
|
||||
}
|
||||
for (i in 0 until n - 1 step 2) {
|
||||
val sample = (buffer[i].toInt() and 0xFF) or (buffer[i + 1].toInt() shl 8)
|
||||
peak = maxOf(peak, abs(sample.toShort().toInt()))
|
||||
}
|
||||
if (peak > 0) everHeard = true
|
||||
val now = System.currentTimeMillis()
|
||||
val level = peak / 32768f
|
||||
if (now - heardAt >= LEVEL_LOG_MS) {
|
||||
@@ -124,6 +169,16 @@ class AudioCaptureService : Service() {
|
||||
heardAt = now
|
||||
peak = 0
|
||||
}
|
||||
// Capture never fails for an app that opts out; it just hands back digital
|
||||
// silence. Say so, because the symptom is identical to a broken car link.
|
||||
if (!everHeard && !warnedSilent && now - startedAt >= SILENCE_MS) {
|
||||
warnedSilent = true
|
||||
GearslipLog.w(
|
||||
"audio: ${SILENCE_MS / 1000}s of capture and every sample is zero. " +
|
||||
"Either nothing is playing, or the app does not allow its audio " +
|
||||
"to be captured.",
|
||||
)
|
||||
}
|
||||
CarAudio.deliver(buffer, n, level)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
@@ -131,6 +186,7 @@ class AudioCaptureService : Service() {
|
||||
} finally {
|
||||
runCatching { record.stop() }
|
||||
record.release()
|
||||
unmutePhone()
|
||||
CarAudio.captureStopped()
|
||||
}
|
||||
}, "gearslip-audio").apply { isDaemon = true; start() }
|
||||
@@ -140,8 +196,13 @@ class AudioCaptureService : Service() {
|
||||
super.onDestroy()
|
||||
reading = false
|
||||
runCatching { thread?.join(500) }
|
||||
// Belt and braces: a phone left muted after the drive is a bug the driver blames on their
|
||||
// phone, not on Gearslip, so unmute on every way out of here.
|
||||
unmutePhone()
|
||||
runCatching { projection?.stop() }
|
||||
projection = null
|
||||
ScreenShareGuard.restore(this)
|
||||
running = false
|
||||
}
|
||||
|
||||
private fun notification(): Notification {
|
||||
@@ -173,9 +234,14 @@ class AudioCaptureService : Service() {
|
||||
/** About 21 ms of 48 kHz stereo: small enough for lip-sync, big enough not to flood the link. */
|
||||
private const val CHUNK_BYTES = 4096
|
||||
private const val LEVEL_LOG_MS = 3_000L
|
||||
private const val SILENCE_MS = 5_000L
|
||||
|
||||
private var appContext: Context? = null
|
||||
|
||||
/** True between onCreate and onDestroy; lets the activity tell a live capture from a crashed one. */
|
||||
@Volatile var running = false
|
||||
private set
|
||||
|
||||
fun start(context: Context, resultCode: Int, data: Intent) {
|
||||
appContext = context.applicationContext
|
||||
context.startForegroundService(
|
||||
|
||||
@@ -27,10 +27,16 @@ class AudioLink(
|
||||
private var configIndex = 0
|
||||
@Volatile private var ready = false
|
||||
@Volatile private var started = false
|
||||
@Volatile private var focusGranted = false
|
||||
@Volatile private var focusRequested = false
|
||||
@Volatile private var inFlight = 0
|
||||
@Volatile private var maxUnacked = 8
|
||||
// The Uconnect asks for 4 on its video channel; assume the same until its Config says otherwise.
|
||||
@Volatile private var maxUnacked = 4
|
||||
@Volatile private var lastAckAt = 0L
|
||||
private var chunks = 0
|
||||
private var bytes = 0L
|
||||
private var dropped = 0
|
||||
@Volatile private var configTimer: Thread? = null
|
||||
|
||||
private val config get() = service.configs.getOrNull(configIndex) ?: service.configs.firstOrNull()
|
||||
override val sampleRate get() = config?.sampleRate ?: 48_000
|
||||
@@ -39,12 +45,47 @@ class AudioLink(
|
||||
val channelId get() = service.serviceId
|
||||
|
||||
fun opened(status: Int) {
|
||||
GearslipLog.i("audio: <- ChannelOpenResponse (media audio): status=$status")
|
||||
if (status != 0) {
|
||||
GearslipLog.w("audio: the head unit refused the media audio channel (status $status)")
|
||||
return
|
||||
}
|
||||
sendOnChannel(MSG_SETUP, Protobuf.varintField(1, service.codecType.toLong()))
|
||||
GearslipLog.i("audio: -> Setup(codec=${service.codecName})")
|
||||
// AVChannelSetupRequest { required uint32 config_index = 1 }. This field is an index into
|
||||
// the sink's own audio_configs, not a codec id - sending the codec type asks for a config
|
||||
// the sink does not have, and it answers with a status that never reaches READY.
|
||||
sendOnChannel(MSG_SETUP, Protobuf.varintField(1, 0L))
|
||||
GearslipLog.i("audio: -> Setup(config_index=0) on channel ${service.serviceId}, codec ${service.codecName}")
|
||||
armConfigFallback()
|
||||
}
|
||||
|
||||
/**
|
||||
* The 2018 Uconnect answers our Setup with a two-byte message we cannot identify (id 255, no
|
||||
* body) rather than the AVChannelSetupResponse the video channel gets, so the sink never
|
||||
* reaches READY and no audio is ever sent.
|
||||
*
|
||||
* Service discovery already told us everything that message would have: the sink is PCM at
|
||||
* 48000Hz stereo and it has exactly one configuration. So rather than wait forever for a
|
||||
* message that is not coming, assume the advertised configuration and carry on. If that guess
|
||||
* is wrong the head unit simply will not ack, which the write path already reports.
|
||||
*/
|
||||
private fun armConfigFallback() {
|
||||
val timer = Thread({
|
||||
try {
|
||||
Thread.sleep(CONFIG_WAIT_MS)
|
||||
} catch (e: InterruptedException) {
|
||||
return@Thread
|
||||
}
|
||||
if (ready) return@Thread
|
||||
GearslipLog.w(
|
||||
"audio: no usable Config after ${CONFIG_WAIT_MS}ms - falling back to the sink the " +
|
||||
"head unit advertised (${sampleRate}Hz x$channels, config_index=0, max_unacked=$maxUnacked)",
|
||||
)
|
||||
configIndex = 0
|
||||
ready = true
|
||||
CarAudio.attach(this)
|
||||
}, "gearslip-audio-config").apply { isDaemon = true }
|
||||
configTimer = timer
|
||||
timer.start()
|
||||
}
|
||||
|
||||
fun onMessage(messageId: Int, body: ByteArray) {
|
||||
@@ -60,9 +101,15 @@ class AudioLink(
|
||||
if (window != null) maxUnacked = maxOf(1, window)
|
||||
configIndex = indices.firstOrNull() ?: 0
|
||||
if (status == STATUS_READY) {
|
||||
configTimer?.interrupt()
|
||||
ready = true
|
||||
GearslipLog.i("audio: ready - ${sampleRate}Hz x$channels ${config?.bits}bit")
|
||||
GearslipLog.i("audio: sink ready - ${sampleRate}Hz x$channels ${config?.bits}bit")
|
||||
CarAudio.attach(this)
|
||||
} else {
|
||||
GearslipLog.w(
|
||||
"audio: the sink is not ready (status=$status, 1=WAIT 2=READY) - " +
|
||||
"nothing will be sent until it is",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,17 +119,40 @@ class AudioLink(
|
||||
lastAckAt = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
else -> GearslipLog.i("audio: <- unhandled message id=$messageId (${body.size} bytes)")
|
||||
// Whatever the head unit answers Setup with, we need its bytes to identify it.
|
||||
else -> {
|
||||
GearslipLog.i("audio: <- unhandled message id=$messageId (${body.size} bytes)")
|
||||
GearslipLog.hex(" audio msg", body, limit = 64)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The head unit's answer to our AudioFocusRequest. Until it grants focus the sink is wired to
|
||||
* whatever else the car is playing - the radio, usually - and anything we send is discarded,
|
||||
* so [start] waits here rather than streaming into nothing.
|
||||
*/
|
||||
fun onFocus(state: Int) {
|
||||
GearslipLog.i("audio: <- AudioFocus state=$state (1=GAIN, 2=GAIN_TRANSIENT, 3=LOSS, 4=LOSS_TRANSIENT)")
|
||||
val name = FOCUS_STATES[state] ?: "unknown($state)"
|
||||
GearslipLog.i("audio: <- AudioFocusResponse state=$name")
|
||||
focusGranted = state == FOCUS_STATE_GAIN || state == FOCUS_STATE_GAIN_TRANSIENT
|
||||
if (focusGranted) startStream() else stop()
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
if (!ready || started) return
|
||||
sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_GAIN.toLong()))
|
||||
if (!focusRequested) {
|
||||
focusRequested = true
|
||||
sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_GAIN.toLong()))
|
||||
GearslipLog.i("audio: -> AudioFocusRequest(GAIN); waiting for the head unit to hand the sink over")
|
||||
}
|
||||
// Some head units grant the sink silently and never answer. Do not let that be the end of
|
||||
// it: start anyway, and let the acks say whether anything is being taken.
|
||||
startStream()
|
||||
}
|
||||
|
||||
private fun startStream() {
|
||||
if (!ready || started) return
|
||||
sendOnChannel(
|
||||
MSG_START,
|
||||
Protobuf.varintField(1, SESSION_ID.toLong()) + Protobuf.varintField(2, configIndex.toLong()),
|
||||
@@ -90,7 +160,7 @@ class AudioLink(
|
||||
inFlight = 0
|
||||
lastAckAt = System.currentTimeMillis()
|
||||
started = true
|
||||
GearslipLog.i("audio: -> AudioFocusRequest(GAIN), Start(config=$configIndex)")
|
||||
GearslipLog.i("audio: -> Start(session=$SESSION_ID, config=$configIndex) - streaming ${sampleRate}Hz x$channels")
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
@@ -98,17 +168,26 @@ class AudioLink(
|
||||
started = false
|
||||
runCatching {
|
||||
sendOnChannel(MSG_STOP, Protobuf.varintField(1, SESSION_ID.toLong()))
|
||||
sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_RELEASE.toLong()))
|
||||
if (focusRequested) sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_RELEASE.toLong()))
|
||||
}
|
||||
GearslipLog.i("audio: -> Stop, AudioFocusRequest(RELEASE) after $chunks chunks")
|
||||
focusRequested = false
|
||||
focusGranted = false
|
||||
GearslipLog.i("audio: -> Stop, AudioFocusRequest(RELEASE) after $chunks chunks / $bytes bytes")
|
||||
chunks = 0
|
||||
bytes = 0
|
||||
}
|
||||
|
||||
override fun write(pcm: ByteArray, size: Int) {
|
||||
if (!started) return
|
||||
if (inFlight >= maxUnacked) {
|
||||
// A lost ack must not silence the stream for good; after a stall assume the window cleared.
|
||||
if (System.currentTimeMillis() - lastAckAt > STALL_MS) inFlight = 0 else return
|
||||
if (System.currentTimeMillis() - lastAckAt > STALL_MS) {
|
||||
GearslipLog.w("audio: no ack for ${STALL_MS}ms with $inFlight in flight - reopening the window")
|
||||
inFlight = 0
|
||||
} else {
|
||||
dropped++
|
||||
return
|
||||
}
|
||||
}
|
||||
val timestamp = ByteArray(8)
|
||||
val micros = System.nanoTime() / 1_000
|
||||
@@ -116,14 +195,26 @@ class AudioLink(
|
||||
try {
|
||||
sendOnChannel(MSG_DATA, timestamp + pcm.copyOf(size))
|
||||
} catch (e: java.io.IOException) {
|
||||
GearslipLog.e("audio: the link died mid-stream", e)
|
||||
started = false
|
||||
return
|
||||
}
|
||||
inFlight++
|
||||
chunks++
|
||||
bytes += size
|
||||
// The first chunk proves the whole path; after that, an occasional line is enough to show
|
||||
// the car is still taking audio and to catch a window that has quietly closed.
|
||||
if (chunks == 1 || chunks % CHUNK_LOG_EVERY == 0) {
|
||||
GearslipLog.i(
|
||||
"audio: -> $chunks chunks / $bytes bytes, inFlight=$inFlight/$maxUnacked, " +
|
||||
"dropped=$dropped, focus=${if (focusGranted) "granted" else "not granted"}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
configTimer?.interrupt()
|
||||
configTimer = null
|
||||
stop()
|
||||
ready = false
|
||||
CarAudio.detach(this)
|
||||
@@ -142,7 +233,15 @@ class AudioLink(
|
||||
private const val STATUS_READY = 2
|
||||
private const val FOCUS_GAIN = 1
|
||||
private const val FOCUS_RELEASE = 4
|
||||
private const val FOCUS_STATE_GAIN = 1
|
||||
private const val FOCUS_STATE_GAIN_TRANSIENT = 2
|
||||
private const val SESSION_ID = 2
|
||||
private const val STALL_MS = 2_000L
|
||||
private const val CHUNK_LOG_EVERY = 200 // about every four seconds
|
||||
private const val CONFIG_WAIT_MS = 2_500L
|
||||
|
||||
private val FOCUS_STATES = mapOf(
|
||||
1 to "GAIN", 2 to "GAIN_TRANSIENT", 3 to "LOSS", 4 to "LOSS_TRANSIENT",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,15 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* Joins the two ends of the audio path: [AudioCaptureService] on the phone, which hears what a
|
||||
* media app plays, and the car's audio channel, which is where it has to end up.
|
||||
* Joins the two ends of the audio path: [AudioCaptureService] on the phone, which hears what the
|
||||
* phone is playing, and the car's audio channel, which is where it has to end up.
|
||||
*
|
||||
* They come and go independently. The phone side needs on-device consent and can be running on
|
||||
* the bench with no car attached; the car side exists only while a head unit is connected and
|
||||
* offers a media audio sink. Whichever pair is present, samples flow.
|
||||
* Capture belongs to the car session, not to any one app. Android grants it through a consent
|
||||
* dialog that cannot be answered in advance or remembered, so Gearslip asks once, when a head unit
|
||||
* first offers a sink to play through, and then holds that grant for as long as the car is
|
||||
* connected. Switching between media apps, or starting playback from the phone, costs nothing
|
||||
* further: the capture is by audio usage rather than by app, so whatever the phone plays as media
|
||||
* goes to the car.
|
||||
*/
|
||||
object CarAudio {
|
||||
|
||||
@@ -36,9 +39,8 @@ object CarAudio {
|
||||
/** Set by the activity: shows the permission and consent dialogs, which only it can. */
|
||||
var onConsentRequested: (() -> Unit)? = null
|
||||
|
||||
/** The uid being captured (or waited on). */
|
||||
@Volatile var uid: Int = -1
|
||||
private set
|
||||
/** Whether the driver has asked for the car to be fed at all; see CarSettings.pipeAudio. */
|
||||
@Volatile private var wanted = false
|
||||
|
||||
@Volatile private var link: Link? = null
|
||||
|
||||
@@ -48,30 +50,48 @@ object CarAudio {
|
||||
|
||||
fun attach(link: Link) {
|
||||
this.link = link
|
||||
if (_capture.value == Capture.ON) link.start()
|
||||
GearslipLog.i("audio: the car offered a sink at ${link.sampleRate}Hz x${link.channels}")
|
||||
when {
|
||||
_capture.value == Capture.ON -> link.start()
|
||||
wanted -> ask()
|
||||
}
|
||||
}
|
||||
|
||||
fun detach(link: Link) {
|
||||
if (this.link === link) this.link = null
|
||||
}
|
||||
|
||||
/** A media app was opened: start hearing it. */
|
||||
fun request(uid: Int) {
|
||||
if (this.uid == uid && _capture.value != Capture.OFF && _capture.value != Capture.DENIED) return
|
||||
if (this.uid != uid) AudioCaptureService.stop()
|
||||
this.uid = uid
|
||||
_capture.value = Capture.ASKING
|
||||
val ask = onConsentRequested
|
||||
if (ask == null) {
|
||||
GearslipLog.w("audio: nothing on the phone can ask for capture consent")
|
||||
_capture.value = Capture.DENIED
|
||||
} else {
|
||||
ask()
|
||||
/**
|
||||
* The car wants sound. Safe to call as often as the session likes: the consent dialog appears
|
||||
* at most once, and only once there is a sink to play through - asking on the bench, where the
|
||||
* sound has nowhere to go, would put a system dialog in front of the driver for nothing.
|
||||
*/
|
||||
fun request() {
|
||||
wanted = true
|
||||
when (_capture.value) {
|
||||
Capture.ON, Capture.ASKING, Capture.DENIED -> return
|
||||
Capture.OFF -> if (link == null) {
|
||||
GearslipLog.i("audio: waiting for the car to offer an audio sink")
|
||||
} else {
|
||||
ask()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ask() {
|
||||
_capture.value = Capture.ASKING
|
||||
val request = onConsentRequested
|
||||
if (request == null) {
|
||||
GearslipLog.w("audio: nothing on the phone can ask for capture consent")
|
||||
_capture.value = Capture.DENIED
|
||||
} else {
|
||||
request()
|
||||
}
|
||||
}
|
||||
|
||||
/** The car went away. Everything is torn down; the next connection asks again. */
|
||||
fun release() {
|
||||
uid = -1
|
||||
wanted = false
|
||||
AudioCaptureService.stop()
|
||||
_capture.value = Capture.OFF
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package app.seb3thehacker.gearslip.audio
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.provider.Settings
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
|
||||
/**
|
||||
* Keeps the phone's notifications readable while audio is being sent to the car.
|
||||
*
|
||||
* Playback capture hangs off a MediaProjection, and since Android 15 any full-screen projection
|
||||
* makes SystemUI hide notification contents, on the assumption that someone is watching the
|
||||
* screen. Nobody is: the audio projection never makes a display. The one exemption an ordinary app
|
||||
* can reach is the developer setting that switches the protection off, so Gearslip turns it off for
|
||||
* the length of the capture and back on afterwards.
|
||||
*
|
||||
* Writing it needs WRITE_SECURE_SETTINGS, granted once over adb. Without the grant this does
|
||||
* nothing and notifications stay hidden while music plays, which is the platform's default.
|
||||
*
|
||||
* Phone mirroring deliberately does not use this: there the screen really is being shown.
|
||||
*/
|
||||
internal object ScreenShareGuard {
|
||||
|
||||
/** Settings.Global.DISABLE_SCREEN_SHARE_PROTECTIONS_FOR_APPS_AND_NOTIFICATIONS, hidden API. */
|
||||
private const val SETTING = "disable_screen_share_protections_for_apps_and_notifications"
|
||||
private const val PREFS = "gearslip_screen_share_guard"
|
||||
|
||||
/** Set while Gearslip has the protection switched off, so a crash can be undone at next launch. */
|
||||
private const val KEY_LIFTED = "lifted"
|
||||
|
||||
fun canLift(context: Context): Boolean =
|
||||
context.checkSelfPermission(Manifest.permission.WRITE_SECURE_SETTINGS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
/** Call before the projection starts: the system decides what to hide at that moment. */
|
||||
fun lift(context: Context) {
|
||||
if (!canLift(context)) {
|
||||
GearslipLog.i("audio: notifications will be hidden while capturing (no WRITE_SECURE_SETTINGS)")
|
||||
return
|
||||
}
|
||||
val resolver = context.contentResolver
|
||||
// Already off, whether by the driver in Developer options or by us: leave it as found.
|
||||
if (Settings.Global.getInt(resolver, SETTING, 0) != 0) return
|
||||
runCatching {
|
||||
prefs(context).edit().putBoolean(KEY_LIFTED, true).commit()
|
||||
Settings.Global.putInt(resolver, SETTING, 1)
|
||||
GearslipLog.i("audio: screen share protection off for the drive, notifications stay visible")
|
||||
}.onFailure { GearslipLog.w("audio: could not lift screen share protection: ${it.message}") }
|
||||
}
|
||||
|
||||
/** Put the protection back, if Gearslip is the one that took it away. */
|
||||
fun restore(context: Context) {
|
||||
if (!prefs(context).getBoolean(KEY_LIFTED, false)) return
|
||||
runCatching {
|
||||
Settings.Global.putInt(context.contentResolver, SETTING, 0)
|
||||
prefs(context).edit().remove(KEY_LIFTED).commit()
|
||||
GearslipLog.i("audio: screen share protection back on")
|
||||
}.onFailure { GearslipLog.w("audio: could not restore screen share protection: ${it.message}") }
|
||||
}
|
||||
|
||||
private fun prefs(context: Context) =
|
||||
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package app.seb3thehacker.gearslip.call
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.ContactsContract
|
||||
import android.telecom.TelecomManager
|
||||
import android.telephony.TelephonyCallback
|
||||
import android.telephony.TelephonyManager
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/** A caller as the car shows it: whatever the contacts app knows, or just the number. */
|
||||
data class Caller(val number: String, val name: String?) {
|
||||
val label: String get() = name ?: number.ifBlank { "Unknown number" }
|
||||
}
|
||||
|
||||
sealed interface CallUiState {
|
||||
data object None : CallUiState
|
||||
data class Ringing(val caller: Caller, val canDecline: Boolean) : CallUiState
|
||||
data class Active(val caller: Caller?) : CallUiState
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks the phone's call state for the car screen, and answers or declines through the two
|
||||
* doors a sideloaded app can use: [android.telecom.TelecomManager.acceptRingingCall] to answer,
|
||||
* and [GearslipCallScreening]'s response to decline one that is still ringing.
|
||||
*
|
||||
* Ending a call already answered needs MODIFY_PHONE_STATE, a privileged permission Gearslip
|
||||
* cannot hold, so a call taken here is finished the way it would be without Gearslip at all -
|
||||
* from the phone itself, a paired headset, or the car's own Bluetooth if it has any. Call audio
|
||||
* stays on the phone's normal voice path too; nothing here sends it over the USB link, which
|
||||
* only ever carries media playback.
|
||||
*/
|
||||
object CarCalls {
|
||||
|
||||
private val _state = MutableStateFlow<CallUiState>(CallUiState.None)
|
||||
val state: StateFlow<CallUiState> = _state.asStateFlow()
|
||||
|
||||
private var telephony: TelephonyManager? = null
|
||||
private var watcher: TelephonyCallback? = null
|
||||
private var pendingRespond: ((allow: Boolean) -> Unit)? = null
|
||||
private var ringingCaller: Caller? = null
|
||||
|
||||
fun start(context: Context) {
|
||||
if (watcher != null) return
|
||||
if (context.checkSelfPermission(Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) return
|
||||
val manager = context.getSystemService(TelephonyManager::class.java) ?: return
|
||||
val callback = object : TelephonyCallback(), TelephonyCallback.CallStateListener {
|
||||
override fun onCallStateChanged(state: Int) = onPhoneState(state)
|
||||
}
|
||||
runCatching { manager.registerTelephonyCallback(context.mainExecutor, callback) }
|
||||
.onSuccess { telephony = manager; watcher = callback }
|
||||
.onFailure { GearslipLog.w("calls: could not watch call state: ${it.message}") }
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
val callback = watcher ?: return
|
||||
runCatching { telephony?.unregisterTelephonyCallback(callback) }
|
||||
telephony = null
|
||||
watcher = null
|
||||
pendingRespond = null
|
||||
ringingCaller = null
|
||||
_state.value = CallUiState.None
|
||||
}
|
||||
|
||||
private fun onPhoneState(state: Int) {
|
||||
when (state) {
|
||||
TelephonyManager.CALL_STATE_IDLE -> {
|
||||
pendingRespond = null
|
||||
ringingCaller = null
|
||||
_state.value = CallUiState.None
|
||||
}
|
||||
TelephonyManager.CALL_STATE_OFFHOOK -> {
|
||||
pendingRespond = null
|
||||
_state.value = CallUiState.Active(ringingCaller)
|
||||
}
|
||||
TelephonyManager.CALL_STATE_RINGING -> {
|
||||
// The number itself only ever arrives through the screening service; if this
|
||||
// fires first, the ringing card appears once incomingCall() catches up.
|
||||
ringingCaller?.let { _state.value = CallUiState.Ringing(it, pendingRespond != null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Called by [GearslipCallScreening] the moment a call comes in - the only place with a number. */
|
||||
fun incomingCall(context: Context, number: String?, respond: (allow: Boolean) -> Unit) {
|
||||
val caller = Caller(number.orEmpty(), number?.let { lookUpName(context, it) })
|
||||
ringingCaller = caller
|
||||
pendingRespond = respond
|
||||
_state.value = CallUiState.Ringing(caller, canDecline = true)
|
||||
}
|
||||
|
||||
/** The screening window closed by itself - the call rings normally now, and can't be declined from here. */
|
||||
fun screeningExpired() {
|
||||
pendingRespond = null
|
||||
val caller = ringingCaller ?: return
|
||||
if (_state.value is CallUiState.Ringing) _state.value = CallUiState.Ringing(caller, canDecline = false)
|
||||
}
|
||||
|
||||
fun decline() {
|
||||
val respond = pendingRespond ?: return
|
||||
pendingRespond = null
|
||||
runCatching { respond(false) }.onFailure { GearslipLog.w("calls: decline failed: ${it.message}") }
|
||||
}
|
||||
|
||||
fun answer(context: Context) {
|
||||
if (context.checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) != PackageManager.PERMISSION_GRANTED) {
|
||||
GearslipLog.w("calls: answer needs ANSWER_PHONE_CALLS, which was never granted")
|
||||
return
|
||||
}
|
||||
val telecom = context.getSystemService(TelecomManager::class.java) ?: return
|
||||
runCatching { telecom.acceptRingingCall() }
|
||||
.onFailure { GearslipLog.w("calls: answer failed: ${it.message}") }
|
||||
}
|
||||
|
||||
private fun lookUpName(context: Context, number: String): String? {
|
||||
if (context.checkSelfPermission(Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) return null
|
||||
return runCatching {
|
||||
val uri = Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI, Uri.encode(number))
|
||||
context.contentResolver.query(uri, arrayOf(ContactsContract.PhoneLookup.DISPLAY_NAME), null, null, null)
|
||||
?.use { c -> if (c.moveToFirst()) c.getString(0) else null }
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
/** Posts to the main thread after a delay - used to give up on a screening response Gearslip never sent. */
|
||||
internal fun mainThreadDelay(ms: Long, action: () -> Unit) {
|
||||
Handler(Looper.getMainLooper()).postDelayed(action, ms)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package app.seb3thehacker.gearslip.call
|
||||
|
||||
import android.telecom.Call
|
||||
import android.telecom.CallScreeningService
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
|
||||
/**
|
||||
* Sees every incoming call before it rings, so the car screen can show who is calling and
|
||||
* decline it in the same window a spam-blocker would use. Inert until the driver sets Gearslip
|
||||
* as the phone's caller ID and spam app - the same on-device consent Truecaller-style apps ask
|
||||
* for, not a privileged permission.
|
||||
*
|
||||
* The system gives an app a few seconds to answer before it lets the call ring on its own, so a
|
||||
* response is sent either when the driver taps a button or when that window is about to close,
|
||||
* whichever comes first. Answering a call that has already passed this point still works, since
|
||||
* [CarCalls.answer] goes through the ordinary ringing-call API rather than through screening.
|
||||
*/
|
||||
class GearslipCallScreening : CallScreeningService() {
|
||||
|
||||
override fun onScreenCall(callDetails: Call.Details) {
|
||||
val number = callDetails.handle?.schemeSpecificPart
|
||||
var responded = false
|
||||
|
||||
val respond: (Boolean) -> Unit = { allow ->
|
||||
if (!responded) {
|
||||
responded = true
|
||||
runCatching {
|
||||
respondToCall(
|
||||
callDetails,
|
||||
CallResponse.Builder()
|
||||
.setDisallowCall(!allow)
|
||||
.setRejectCall(!allow)
|
||||
.setSkipNotification(false)
|
||||
.build(),
|
||||
)
|
||||
}.onFailure { GearslipLog.w("calls: screening response failed: ${it.message}") }
|
||||
}
|
||||
}
|
||||
|
||||
CarCalls.incomingCall(applicationContext, number, respond)
|
||||
|
||||
// Answer for the system if nobody has by the time its own patience runs out, so a call
|
||||
// Gearslip is slow to show never gets silently blocked.
|
||||
mainThreadDelay(SCREENING_WINDOW_MS) {
|
||||
respond(true)
|
||||
CarCalls.screeningExpired()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SCREENING_WINDOW_MS = 3_500L
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Call
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.call.CallUiState
|
||||
import app.seb3thehacker.gearslip.call.CarCalls
|
||||
|
||||
/**
|
||||
* Sits above whatever the car is showing while a call is ringing or under way. Ringing takes the
|
||||
* whole screen, because nothing else matters more right now; an active call is a thin band along
|
||||
* the top, since from here the driver can only see who it is, not end it - see [CarCalls].
|
||||
*/
|
||||
@Composable
|
||||
fun CallOverlay(modifier: Modifier = Modifier) {
|
||||
val state by CarCalls.state.collectAsState()
|
||||
val context = LocalContext.current.applicationContext
|
||||
|
||||
when (val s = state) {
|
||||
// Covers the box regardless of where the caller aligned it, so the alignment below is
|
||||
// only ever felt by the active-call banner, which is the one that isn't full-screen.
|
||||
is CallUiState.Ringing -> IncomingCall(s, onAnswer = { CarCalls.answer(context) }, onDecline = CarCalls::decline)
|
||||
is CallUiState.Active -> ActiveCallBanner(s, modifier)
|
||||
CallUiState.None -> {}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IncomingCall(s: CallUiState.Ringing, onAnswer: () -> Unit, onDecline: () -> Unit) {
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surfaceContainerLowest) {
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Call,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
"Incoming call",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
s.caller.label,
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(56.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(48.dp)) {
|
||||
CallButton(Icons.Filled.Close, "Decline", MaterialTheme.colorScheme.error, enabled = s.canDecline, onClick = onDecline)
|
||||
CallButton(Icons.Filled.Call, "Answer", MaterialTheme.colorScheme.primary, enabled = true, onClick = onAnswer)
|
||||
}
|
||||
if (!s.canDecline) {
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Text(
|
||||
"Already ringing out loud - answer here, or let it ring.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CallButton(icon: ImageVector, label: String, color: Color, enabled: Boolean, onClick: () -> Unit) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
FilledIconButton(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = Modifier.size(72.dp),
|
||||
colors = IconButtonDefaults.filledIconButtonColors(containerColor = color, contentColor = Color.White),
|
||||
) { Icon(icon, contentDescription = label, modifier = Modifier.size(30.dp)) }
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(label, style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActiveCallBanner(s: CallUiState.Active, modifier: Modifier = Modifier) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 20.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(Icons.Filled.Call, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.height(0.dp))
|
||||
Text(
|
||||
" On a call" + (s.caller?.let { " with ${it.label}" } ?: ""),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
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.Share
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
@@ -28,8 +28,8 @@ object CarApps {
|
||||
CarApp("phone", "Screen sharing", Icons.Filled.Share) {
|
||||
PhoneAppsScreen()
|
||||
},
|
||||
CarApp("calibrate", "Display test", Icons.Filled.Info) {
|
||||
CalibrationApp()
|
||||
CarApp("dialer", "Phone", Icons.Filled.Call) {
|
||||
PhoneDialerScreen()
|
||||
},
|
||||
CarApp("settings", "Settings", Icons.Filled.Settings) {
|
||||
CarSettingsScreen()
|
||||
|
||||
@@ -19,7 +19,10 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -63,25 +66,37 @@ fun CarDashboardScreen() {
|
||||
|
||||
@Composable
|
||||
private fun WeatherCard(context: Context) {
|
||||
val weather by produceState<Weather?>(Weather.LOADING) {
|
||||
value = withContext(Dispatchers.IO) { fetchWeather(context) }
|
||||
}
|
||||
val state by Weather.state.collectAsState()
|
||||
val navigator = LocalCarNavigator.current
|
||||
LaunchedEffect(Unit) { Weather.refresh(context) }
|
||||
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.extraLarge) {
|
||||
Row(Modifier.fillMaxWidth().padding(20.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
val w = weather
|
||||
when {
|
||||
w === Weather.LOADING -> Text("Getting weather…", style = MaterialTheme.typography.bodyLarge)
|
||||
w == null -> Text(
|
||||
"Weather unavailable - no location fix yet.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
modifier = Modifier.clickable { navigator.weather() },
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(20.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
when (val s = state) {
|
||||
WeatherState.Loading -> Text("Getting weather…", style = MaterialTheme.typography.bodyLarge)
|
||||
is WeatherState.Unavailable -> Text(
|
||||
s.reason, style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
else -> Text(
|
||||
"${w.tempC.toInt()}°C · ${w.description}",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
is WeatherState.Ready -> {
|
||||
val w = s.data
|
||||
WeatherIcon(w.code, w.isDay, size = 44.dp)
|
||||
Column {
|
||||
Text(
|
||||
"${w.temp}${w.tempUnit} · ${Weather.describe(w.code)}",
|
||||
style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(w.place, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,8 +104,8 @@ private fun WeatherCard(context: Context) {
|
||||
|
||||
@Composable
|
||||
private fun AgendaCard(context: Context) {
|
||||
val events by produceState<List<AgendaEvent>?>(null) {
|
||||
value = withContext(Dispatchers.IO) { fetchAgenda(context) }
|
||||
val events by produceState<List<AgendaEvent>?>(cachedAgenda) {
|
||||
value = withContext(Dispatchers.IO) { fetchAgenda(context) }.also { cachedAgenda = it }
|
||||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
@@ -143,63 +158,14 @@ private fun eventWhen(event: AgendaEvent): String {
|
||||
return SimpleDateFormat("EEE", Locale.getDefault()).apply { timeZone = zone }.format(Date(event.startMillis)) + " " + time
|
||||
}
|
||||
|
||||
private data class Weather(val tempC: Double, val description: String) {
|
||||
companion object { val LOADING = Weather(Double.NaN, "") }
|
||||
}
|
||||
|
||||
private data class AgendaEvent(val title: String, val startMillis: Long, val allDay: Boolean)
|
||||
|
||||
private const val WEATHER_TTL_MS = 60 * 60 * 1000L
|
||||
private var cachedWeather: Weather? = null
|
||||
private var cachedWeatherAt = 0L
|
||||
@Volatile private var cachedAgenda: List<AgendaEvent>? = null
|
||||
|
||||
/** Served from memory for up to an hour: weather barely changes, and each call is a network request. */
|
||||
private fun fetchWeather(context: Context): Weather? {
|
||||
val now = System.currentTimeMillis()
|
||||
cachedWeather?.let { if (now - cachedWeatherAt < WEATHER_TTL_MS) return it }
|
||||
return loadWeather(context)?.also { cachedWeather = it; cachedWeatherAt = now }
|
||||
}
|
||||
|
||||
/** Null means no fix or no permission; the card shows a plain empty state either way. */
|
||||
private fun loadWeather(context: Context): Weather? {
|
||||
if (context.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
|
||||
return null
|
||||
}
|
||||
val manager = context.getSystemService(LocationManager::class.java) ?: return null
|
||||
val location = manager.allProviders
|
||||
.mapNotNull { runCatching { manager.getLastKnownLocation(it) }.getOrNull() }
|
||||
.maxByOrNull { it.time }
|
||||
?: return null
|
||||
|
||||
return runCatching {
|
||||
// Open-Meteo: no API key, no account - the simplest thing that gives a real reading.
|
||||
val url = URL(
|
||||
"https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}" +
|
||||
"&longitude=${location.longitude}¤t_weather=true",
|
||||
)
|
||||
val body = (url.openConnection() as HttpURLConnection).run {
|
||||
connectTimeout = 5_000
|
||||
readTimeout = 5_000
|
||||
inputStream.bufferedReader().use { it.readText() }
|
||||
}
|
||||
val current = JSONObject(body).getJSONObject("current_weather")
|
||||
Weather(current.getDouble("temperature"), weatherDescription(current.getInt("weathercode")))
|
||||
}.onFailure { GearslipLog.w("weather fetch failed: ${it.message}") }.getOrNull()
|
||||
}
|
||||
|
||||
/** Open-Meteo's WMO weather codes, collapsed to what's worth a glance from the driver's seat. */
|
||||
private fun weatherDescription(code: Int): String = when (code) {
|
||||
0 -> "Clear"
|
||||
1, 2 -> "Partly cloudy"
|
||||
3 -> "Overcast"
|
||||
45, 48 -> "Fog"
|
||||
in 51..57 -> "Drizzle"
|
||||
in 61..67 -> "Rain"
|
||||
in 71..77 -> "Snow"
|
||||
in 80..82 -> "Rain showers"
|
||||
in 85..86 -> "Snow showers"
|
||||
in 95..99 -> "Thunderstorm"
|
||||
else -> "-"
|
||||
/** Loads the weather and the agenda ahead of time, so the dashboard opens with both in place. */
|
||||
fun warmDashboard(context: Context) {
|
||||
Weather.refresh(context, force = true)
|
||||
cachedAgenda = fetchAgenda(context)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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
|
||||
@@ -13,8 +12,7 @@ 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.PlayArrow
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -24,32 +22,26 @@ import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.draw.clip
|
||||
import android.media.session.PlaybackState
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import app.seb3thehacker.gearslip.host.CarAppConnection
|
||||
import app.seb3thehacker.gearslip.media.CarMedia
|
||||
import app.seb3thehacker.gearslip.media.MediaArt
|
||||
import kotlinx.coroutines.delay
|
||||
import app.seb3thehacker.gearslip.media.NowPlaying
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* The home screen: the map on the left, what is playing on the right.
|
||||
* The home screen: the map on the left, lyrics on the right when asked for.
|
||||
*
|
||||
* The map is whatever navigation app was used last, brought back on its own. The player only
|
||||
* takes its share of the screen while something is loaded; otherwise the map has all of it.
|
||||
* The map is whatever navigation app was used last, brought back on its own. Playback itself is
|
||||
* controlled from the nav bar's pill everywhere, including here - this column only opens when the
|
||||
* pill's lyrics button is on, and closes itself back to a full-width map once nothing is playing.
|
||||
*/
|
||||
@Composable
|
||||
fun CarHome() {
|
||||
@@ -61,19 +53,20 @@ fun CarHome() {
|
||||
LaunchedEffect(Unit) { CarServices.autostart(frame) }
|
||||
LaunchedEffect(phase) { if (phase == CarMedia.Phase.READY) CarServices.autoplayIfDue() }
|
||||
|
||||
val showPlayer = phase == CarMedia.Phase.READY && now.hasTrack
|
||||
val navigator = LocalCarNavigator.current
|
||||
val showLyrics = phase == CarMedia.Phase.READY && now.isActive && navigator.lyricsOpen
|
||||
|
||||
Row(Modifier.fillMaxSize().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Box(
|
||||
Modifier
|
||||
.weight(if (showPlayer) 2.1f else 1f)
|
||||
.weight(if (showLyrics) 2.1f else 1f)
|
||||
.fillMaxHeight()
|
||||
.clip(RoundedCornerShape(EMBEDDED_RADIUS)),
|
||||
) {
|
||||
MapPane(navStatus, frame)
|
||||
}
|
||||
if (showPlayer) {
|
||||
MiniPlayer(Modifier.weight(1f).fillMaxHeight())
|
||||
if (showLyrics) {
|
||||
LyricsColumn(now, Modifier.weight(1f).fillMaxHeight())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,6 +85,7 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
val hasLast = CarSettings.lastNav.value != null
|
||||
val scope = androidx.compose.runtime.rememberCoroutineScope()
|
||||
when (status.phase) {
|
||||
CarAppConnection.Phase.BINDING, CarAppConnection.Phase.HANDSHAKE ->
|
||||
Text("Starting ${status.app ?: "the map"}…", style = MaterialTheme.typography.titleMedium)
|
||||
@@ -101,95 +95,56 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame
|
||||
TextButton(onClick = navigator::apps) { Text("Choose another app") }
|
||||
}
|
||||
else -> {
|
||||
// IDLE with a last app on record means the driver stopped it themselves - it will
|
||||
// not come back on its own, so the screen must offer a way to bring it back rather
|
||||
// than sit on a "starting" message that never resolves.
|
||||
Text(
|
||||
if (hasLast) "Starting the map…" else "No map app chosen yet.",
|
||||
if (hasLast) "Map stopped." else "No map app chosen yet.",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
if (!hasLast) {
|
||||
if (hasLast) {
|
||||
TextButton(onClick = { scope.launch { CarServices.reconnectNav(frame) } }) { Text("Reopen") }
|
||||
} else {
|
||||
Text(
|
||||
"Open Car apps and connect a navigation app; it will start here next time.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
TextButton(onClick = { navigator.apps() }) { Text("Car apps") }
|
||||
}
|
||||
TextButton(onClick = { navigator.apps() }) { Text("Car apps") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Previous, play/pause, next and a seek bar, and nothing else: the full controls are in the media screen. */
|
||||
/**
|
||||
* Lyrics for whatever the nav bar's pill is playing, beside the map. Transport controls live in
|
||||
* the pill itself now, not here - this column has exactly one job.
|
||||
*/
|
||||
@Composable
|
||||
private fun MiniPlayer(modifier: Modifier) {
|
||||
val media = CarServices.media
|
||||
val now by media.now.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
|
||||
|
||||
var position by remember { mutableStateOf(0L) }
|
||||
LaunchedEffect(now) {
|
||||
while (true) {
|
||||
position = now.currentPosition()
|
||||
delay(500)
|
||||
}
|
||||
}
|
||||
|
||||
private fun LyricsColumn(now: NowPlaying, modifier: Modifier) {
|
||||
val navigator = LocalCarNavigator.current
|
||||
Surface(
|
||||
onClick = navigator::media,
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(EMBEDDED_RADIUS),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(18.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Box(Modifier.size(140.dp).clip(RoundedCornerShape(EMBEDDED_RADIUS))) {
|
||||
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
now.title.ifEmpty { "Nothing playing" },
|
||||
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
now.artist,
|
||||
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
if (now.durationMs > 0) {
|
||||
WavyProgress(
|
||||
fraction = position.toFloat() / now.durationMs,
|
||||
playing = now.state == PlaybackState.STATE_PLAYING,
|
||||
onSeek = { media.seek((it * now.durationMs).toLong()) },
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
now.title.ifEmpty { "Lyrics" },
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
} else {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = { media.previous() }, modifier = Modifier.size(48.dp)) {
|
||||
Icon(MediaIcons.Previous, "Previous", Modifier.size(30.dp))
|
||||
}
|
||||
FilledIconButton(onClick = media::togglePlay, modifier = Modifier.size(60.dp)) {
|
||||
Icon(
|
||||
if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow,
|
||||
if (now.playing) "Pause" else "Play",
|
||||
Modifier.size(36.dp),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { media.next() }, modifier = Modifier.size(48.dp)) {
|
||||
Icon(MediaIcons.Next, "Next", Modifier.size(30.dp))
|
||||
IconButton(onClick = navigator::toggleLyrics, modifier = Modifier.size(36.dp)) {
|
||||
Icon(Icons.Filled.Close, "Hide lyrics", Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
LyricsPanel(now, Modifier.weight(1f).fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
@@ -13,6 +16,7 @@ import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Call
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
@@ -36,6 +40,17 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import app.seb3thehacker.gearslip.host.KnownApps
|
||||
import app.seb3thehacker.gearslip.host.CarAppCatalog
|
||||
import app.seb3thehacker.gearslip.host.TemplateApp
|
||||
import app.seb3thehacker.gearslip.media.MediaApp
|
||||
@@ -48,28 +63,39 @@ private class Tile(
|
||||
val label: String,
|
||||
val icon: ImageBitmap? = null,
|
||||
val glyph: ImageVector? = null,
|
||||
/** Tested from start to finish with Gearslip; drawn with a check mark. */
|
||||
val verified: Boolean = false,
|
||||
/** Known not to work; drawn with a red X instead of being left off the launcher. */
|
||||
val broken: Boolean = false,
|
||||
val pinned: Boolean = false,
|
||||
/** Null for the built-in tiles (Web, Screen sharing, Settings) - nothing to pin them as. */
|
||||
val onLongClick: (() -> Unit)? = null,
|
||||
val onClick: () -> Unit,
|
||||
)
|
||||
|
||||
/** A launcher app's data, kept apart from [Tile] so it can be cached without capturing click state. */
|
||||
private class Entry(
|
||||
internal class Entry(
|
||||
val label: String,
|
||||
val icon: ImageBitmap?,
|
||||
val template: TemplateApp? = null,
|
||||
val media: MediaApp? = null,
|
||||
)
|
||||
) {
|
||||
/** What a pin or a nav-bar shortcut remembers this entry as; null for nothing pinnable. */
|
||||
val componentId: String? get() = (template?.component ?: media?.component)?.flattenToString()
|
||||
}
|
||||
|
||||
/**
|
||||
* The installed car and media apps with their icons. Scanning the package manager and decoding
|
||||
* icons is slow, so it happens once per car session; [invalidate] is called when one starts, which
|
||||
* is the only time new installs are picked up.
|
||||
* icons is slow, so [Prefetch] does it ahead of time and again at the start of each car session,
|
||||
* which is when new installs are picked up.
|
||||
*/
|
||||
private object LauncherCache {
|
||||
internal object LauncherCache {
|
||||
@Volatile var entries: List<Entry>? = null
|
||||
|
||||
fun invalidate() { entries = null }
|
||||
|
||||
fun load(context: Context): List<Entry> {
|
||||
/** Returns the scan if there is one; [force] rescans. Synchronized so two callers share one scan. */
|
||||
@Synchronized
|
||||
fun load(context: Context, force: Boolean = false): List<Entry> {
|
||||
if (!force) entries?.let { return it }
|
||||
fun iconOf(pkg: String) = runCatching {
|
||||
context.packageManager.getApplicationIcon(pkg).toBitmap(128, 128).asImageBitmap()
|
||||
}.getOrNull()
|
||||
@@ -80,8 +106,18 @@ private object LauncherCache {
|
||||
}
|
||||
}
|
||||
|
||||
/** Forgets the scanned apps so the next launcher open rescans; call at the start of a car session. */
|
||||
fun invalidateLauncherApps() = LauncherCache.invalidate()
|
||||
/** Rescans the car and media apps now, replacing the cached list; the launcher keeps showing the old one meanwhile. */
|
||||
fun warmLauncherApps(context: Context) { LauncherCache.load(context, force = true) }
|
||||
|
||||
/**
|
||||
* Connects or opens [entry] - what tapping its tile does, shared with the nav bar's pinned
|
||||
* shortcuts and its open-app icon so there is exactly one place that knows how to launch either
|
||||
* kind of app.
|
||||
*/
|
||||
internal fun launchEntry(entry: Entry, navigator: CarNavigator, frame: CarEnvironment.Frame) {
|
||||
entry.template?.let { CarServices.connectNav(it, frame); navigator.home() }
|
||||
entry.media?.let { CarServices.openMedia(it); navigator.media() }
|
||||
}
|
||||
|
||||
/** The app launcher: Web and Screen sharing, every car app the phone has, then Settings, as an icon grid. */
|
||||
@Composable
|
||||
@@ -90,19 +126,27 @@ fun CarLauncher() {
|
||||
val navigator = LocalCarNavigator.current
|
||||
val frame by CarEnvironment.frame.collectAsState()
|
||||
|
||||
// Scanned once per car session (see LauncherCache), not every time the launcher opens.
|
||||
// Painted from the cache at once; scanned only if [Prefetch] has not got there first.
|
||||
val installed by produceState(initialValue = LauncherCache.entries ?: emptyList(), context) {
|
||||
value = LauncherCache.entries ?: withContext(Dispatchers.IO) { LauncherCache.load(context) }
|
||||
}
|
||||
|
||||
val pinned by CarSettings.pinnedApps.collectAsState()
|
||||
|
||||
val tiles = listOf(
|
||||
Tile("Web", glyph = Icons.Filled.Search) { navigator.open("web") },
|
||||
Tile("Screen sharing", glyph = Icons.Filled.Share) { navigator.open("phone") },
|
||||
Tile("Phone", glyph = Icons.Filled.Call) { navigator.open("dialer") },
|
||||
) + installed.map { entry ->
|
||||
Tile(entry.label, entry.icon) {
|
||||
entry.template?.let { CarServices.connectNav(it, frame); navigator.home() }
|
||||
entry.media?.let { CarServices.openMedia(it); navigator.media() }
|
||||
}
|
||||
val pkg = entry.template?.component?.packageName ?: entry.media?.component?.packageName
|
||||
val id = entry.componentId
|
||||
Tile(
|
||||
entry.label, entry.icon,
|
||||
verified = pkg != null && KnownApps.works(pkg),
|
||||
broken = pkg != null && KnownApps.isBroken(pkg),
|
||||
pinned = id != null && id in pinned,
|
||||
onLongClick = id?.let { { CarSettings.togglePin(it) } },
|
||||
) { launchEntry(entry, navigator, frame) }
|
||||
} + listOf(
|
||||
Tile("Settings", glyph = Icons.Filled.Settings) { navigator.settings() },
|
||||
)
|
||||
@@ -115,36 +159,94 @@ fun CarLauncher() {
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(tiles) { AppTile(it) }
|
||||
if (tiles.any { it.verified }) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) { VerifiedLegend() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Says what the check mark on a tile means, once, under the grid. */
|
||||
@Composable
|
||||
private fun VerifiedLegend() {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
VerifiedBadge(16.dp)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
"Tested and working with Gearslip",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A green disc with a white check: the same mark on every tile and in the legend. */
|
||||
@Composable
|
||||
private fun VerifiedBadge(size: androidx.compose.ui.unit.Dp, modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier.size(size).background(Color(0xFF43A047), CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(Icons.Filled.Check, contentDescription = "Tested and working", tint = Color.White, modifier = Modifier.size(size * 0.66f))
|
||||
}
|
||||
}
|
||||
|
||||
/** A red disc with a white X: marks a tile known not to work with Gearslip yet. */
|
||||
@Composable
|
||||
private fun BrokenBadge(size: androidx.compose.ui.unit.Dp, modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier.size(size).background(Color(0xFFD32F2F), CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(Icons.Filled.Close, contentDescription = "Doesn't work with Gearslip", tint = Color.White, modifier = Modifier.size(size * 0.66f))
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun AppTile(tile: Tile) {
|
||||
val shape = MaterialTheme.shapes.extraLarge
|
||||
Surface(
|
||||
onClick = tile.onClick,
|
||||
modifier = Modifier.height(120.dp),
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
modifier = Modifier
|
||||
.height(120.dp)
|
||||
.let { if (tile.pinned) it.border(2.dp, MaterialTheme.colorScheme.primary, shape) else it }
|
||||
// combinedClickable, not Surface's own onClick, so a long press can mean something
|
||||
// different from a tap - pinning is deliberately the same gesture everywhere apps
|
||||
// are shown (here and, once pinned, the shortcut itself on the nav bar).
|
||||
.combinedClickable(onClick = tile.onClick, onLongClick = tile.onLongClick),
|
||||
shape = shape,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
when {
|
||||
tile.icon != null -> Image(tile.icon, contentDescription = null, modifier = Modifier.size(52.dp))
|
||||
tile.glyph != null -> Icon(tile.glyph, contentDescription = null, modifier = Modifier.size(52.dp))
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
when {
|
||||
tile.icon != null -> Image(tile.icon, contentDescription = null, modifier = Modifier.size(52.dp))
|
||||
tile.glyph != null -> Icon(tile.glyph, contentDescription = null, modifier = Modifier.size(52.dp))
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
tile.label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (tile.verified) {
|
||||
VerifiedBadge(24.dp, Modifier.align(Alignment.TopEnd).padding(8.dp))
|
||||
}
|
||||
if (tile.broken) {
|
||||
BrokenBadge(24.dp, Modifier.align(Alignment.TopEnd).padding(8.dp))
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
tile.label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
@@ -13,6 +14,9 @@ sealed interface CarScreen {
|
||||
/** Opened from the clock: the day's calendar and the weather, nothing that needs a keyboard. */
|
||||
data object Dashboard : CarScreen
|
||||
|
||||
/** Opened from the weather chip or the dashboard: now, the next hours and the week. */
|
||||
data object Weather : CarScreen
|
||||
|
||||
/** The current media app, full size: browse, lyrics and controls. */
|
||||
data object Media : CarScreen
|
||||
|
||||
@@ -21,37 +25,81 @@ sealed interface CarScreen {
|
||||
data class App(val id: String) : CarScreen
|
||||
}
|
||||
|
||||
/** One step on the breadcrumb trail: the screen it shows and the word for it in the trail. */
|
||||
data class Crumb(val screen: CarScreen, val label: String)
|
||||
|
||||
/**
|
||||
* Where the car UI is. A car touchscreen has no hardware back button, so [back] is driven by
|
||||
* the on-screen one; an open app can claim it first (a browser going back a page) through
|
||||
* [backInterceptor].
|
||||
* Where the car UI is, and how it got there. A car touchscreen has no hardware back button, so
|
||||
* every screen that isn't reached from [home] is pushed onto a trail (like a browser's history,
|
||||
* or a file manager's breadcrumb bar) rather than replacing the last one outright - that's what
|
||||
* lets the breadcrumb bar show "Home > Apps > Settings" and jump straight back to any of them,
|
||||
* and lets [back] undo exactly one step instead of always dumping you back at Home. An open app
|
||||
* can claim a back step first (a browser going back a page) through [backInterceptor].
|
||||
*/
|
||||
class CarNavigator(startId: String) {
|
||||
|
||||
var current: CarScreen by mutableStateOf(
|
||||
if (CarApps.find(startId) != null) CarScreen.App(startId) else CarScreen.Home,
|
||||
private val stack = mutableStateListOf(
|
||||
Crumb(
|
||||
if (CarApps.find(startId) != null) CarScreen.App(startId) else CarScreen.Home,
|
||||
"Home",
|
||||
),
|
||||
)
|
||||
private set
|
||||
|
||||
/** The whole trail, root first. Size 1 means "at Home" - the breadcrumb bar hides then. */
|
||||
val trail: List<Crumb> get() = stack
|
||||
|
||||
val current: CarScreen get() = stack.last().screen
|
||||
|
||||
var backInterceptor: (() -> Boolean)? = null
|
||||
|
||||
fun home() { current = CarScreen.Home }
|
||||
/** Pushes a new step, unless it's the same screen already on top - tapping the button for
|
||||
* where you already are shouldn't grow the trail. */
|
||||
private fun push(screen: CarScreen, label: String) {
|
||||
if (stack.last().screen == screen) return
|
||||
stack.add(Crumb(screen, label))
|
||||
}
|
||||
|
||||
fun media() { current = CarScreen.Media }
|
||||
/** The one way back to a clean slate: resets the whole trail to just Home. */
|
||||
fun home() {
|
||||
stack.clear()
|
||||
stack.add(Crumb(CarScreen.Home, "Home"))
|
||||
}
|
||||
|
||||
fun apps() { current = CarScreen.Apps }
|
||||
/**
|
||||
* The nav bar's now-playing pill is the player everywhere; this is only whether the home
|
||||
* screen is additionally showing lyrics for it in the column beside the map. Toggled by the
|
||||
* pill's own lyrics button, so it means the same thing wherever the pill is visible, even
|
||||
* though only the home screen actually has the room to show it.
|
||||
*/
|
||||
var lyricsOpen by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
fun settings() { current = CarScreen.Settings }
|
||||
fun toggleLyrics() { lyricsOpen = !lyricsOpen }
|
||||
|
||||
fun dashboard() { current = CarScreen.Dashboard }
|
||||
fun media() { push(CarScreen.Media, "Media") }
|
||||
|
||||
fun notifications(replyTo: String? = null) { current = CarScreen.Notifications(replyTo) }
|
||||
fun apps() { push(CarScreen.Apps, "Apps") }
|
||||
|
||||
fun open(id: String) { current = CarScreen.App(id) }
|
||||
fun settings() { push(CarScreen.Settings, "Settings") }
|
||||
|
||||
fun dashboard() { push(CarScreen.Dashboard, "Dashboard") }
|
||||
|
||||
fun weather() { push(CarScreen.Weather, "Weather") }
|
||||
|
||||
fun notifications(replyTo: String? = null) { push(CarScreen.Notifications(replyTo), "Notifications") }
|
||||
|
||||
fun open(id: String) { push(CarScreen.App(id), CarApps.find(id)?.label ?: id) }
|
||||
|
||||
/** Jumps straight to a step already on the trail, dropping everything after it - what
|
||||
* tapping a breadcrumb does. Index 0 (Home) is always on the trail, so this always succeeds. */
|
||||
fun jumpTo(index: Int) {
|
||||
while (stack.size > index + 1) stack.removeAt(stack.lastIndex)
|
||||
}
|
||||
|
||||
/** One step back, same as tapping the crumb before the current one. */
|
||||
fun back() {
|
||||
if (backInterceptor?.invoke() == true) return
|
||||
current = CarScreen.Home
|
||||
if (stack.size > 1) stack.removeAt(stack.lastIndex)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ object CarServices {
|
||||
fun init(appContext: Context) {
|
||||
context = appContext.applicationContext
|
||||
app.seb3thehacker.gearslip.notify.CarMotion.start(appContext)
|
||||
app.seb3thehacker.gearslip.call.CarCalls.start(appContext)
|
||||
}
|
||||
|
||||
// --- map -----------------------------------------------------------------------------
|
||||
@@ -54,6 +55,12 @@ object CarServices {
|
||||
nav.disconnect()
|
||||
}
|
||||
|
||||
/** Undoes [stopNav]'s hold so the last map app comes back, same as it would after a restart. */
|
||||
suspend fun reconnectNav(frame: CarEnvironment.Frame) {
|
||||
navStopped = false
|
||||
autostart(frame)
|
||||
}
|
||||
|
||||
// --- media ---------------------------------------------------------------------------
|
||||
|
||||
fun openMedia(app: MediaApp, autoplay: Boolean = false) {
|
||||
@@ -62,11 +69,12 @@ object CarServices {
|
||||
val live = phase == CarMedia.Phase.READY || phase == CarMedia.Phase.CONNECTING
|
||||
if (_mediaApp.value?.component == app.component && live) return
|
||||
|
||||
CarAudio.release()
|
||||
_mediaApp.value = app
|
||||
autoplayPending = autoplay
|
||||
media.connect(app)
|
||||
if (CarSettings.pipeAudio.value) CarAudio.request(app.uid)
|
||||
// Not torn down and rebuilt per app: the capture covers whatever the phone plays, so
|
||||
// switching media apps never costs the driver another consent dialog.
|
||||
if (CarSettings.pipeAudio.value) CarAudio.request()
|
||||
}
|
||||
|
||||
/** Called once the media app is connected: starts playback if that was asked for. */
|
||||
@@ -104,6 +112,7 @@ object CarServices {
|
||||
/** The car went away: nothing should keep running for a screen that no longer exists. */
|
||||
fun shutdown() {
|
||||
context?.let { app.seb3thehacker.gearslip.notify.CarMotion.stop(it) }
|
||||
app.seb3thehacker.gearslip.call.CarCalls.stop()
|
||||
runCatching { nav.disconnect() }
|
||||
runCatching { media.disconnect() }
|
||||
CarAudio.release()
|
||||
|
||||
@@ -24,6 +24,11 @@ object CarSettings {
|
||||
private const val KEY_LAST_MEDIA = "car_last_media"
|
||||
private const val KEY_AUTOPLAY = "car_autoplay"
|
||||
private const val KEY_THEME = "car_app_theme"
|
||||
private const val KEY_PINNED = "car_pinned_apps"
|
||||
|
||||
/** How many shortcuts the nav bar makes room for; a driver reaching for one needs it to
|
||||
* still be on the bar, not off the edge of a long list. */
|
||||
const val MAX_PINNED = 3
|
||||
|
||||
/** Id of the app to open on connect; [HOME] opens the launcher. */
|
||||
const val HOME = "home"
|
||||
@@ -33,12 +38,16 @@ object CarSettings {
|
||||
private val scaleFlow = MutableStateFlow(1f)
|
||||
private val openFlow = MutableStateFlow(HOME)
|
||||
private val nightFlow = MutableStateFlow(NightMode.AUTO)
|
||||
private val pipeAudioFlow = MutableStateFlow(false)
|
||||
private val pipeAudioFlow = MutableStateFlow(true)
|
||||
private val lastNavFlow = MutableStateFlow<String?>(null)
|
||||
private val lastMediaFlow = MutableStateFlow<String?>(null)
|
||||
private val autoplayFlow = MutableStateFlow(false)
|
||||
private val themeFlow = MutableStateFlow(AppTheme.PHONE)
|
||||
val appTheme: StateFlow<AppTheme> = themeFlow
|
||||
private val pinnedFlow = MutableStateFlow<Set<String>>(emptySet())
|
||||
|
||||
/** Flattened component names of the apps long-pressed onto the nav bar as shortcuts. */
|
||||
val pinnedApps: StateFlow<Set<String>> = pinnedFlow
|
||||
|
||||
/** Flattened component names of the map and media apps used last, brought back on connect. */
|
||||
val lastNav: StateFlow<String?> = lastNavFlow
|
||||
@@ -52,9 +61,10 @@ object CarSettings {
|
||||
val nightMode: StateFlow<NightMode> = nightFlow
|
||||
|
||||
/**
|
||||
* Whether a media app's sound is captured and sent over the car link. Off by default: the
|
||||
* platform only allows that behind a screen-capture consent dialog, and without it the app
|
||||
* simply plays through the phone's normal output (e.g. Bluetooth to the car).
|
||||
* Whether a media app's sound is captured and sent over the car link. On by default, since
|
||||
* sound in the car is the point of the exercise; the platform allows the capture only behind a
|
||||
* consent dialog, which [app.seb3thehacker.gearslip.audio.CarAudio] raises once the car has
|
||||
* somewhere to play it. Turned off, the app plays through the phone's normal output instead.
|
||||
*/
|
||||
val pipeAudio: StateFlow<Boolean> = pipeAudioFlow
|
||||
|
||||
@@ -68,10 +78,12 @@ object CarSettings {
|
||||
AppTheme.valueOf(AppSettings.getString(app, KEY_THEME, AppTheme.PHONE.name))
|
||||
}.getOrDefault(AppTheme.PHONE)
|
||||
autoplayFlow.value = AppSettings.getString(app, KEY_AUTOPLAY, "false") == "true"
|
||||
pipeAudioFlow.value = AppSettings.getString(app, KEY_PIPE_AUDIO, "false") == "true"
|
||||
pipeAudioFlow.value = AppSettings.getString(app, KEY_PIPE_AUDIO, "true") == "true"
|
||||
nightFlow.value = runCatching {
|
||||
NightMode.valueOf(AppSettings.getString(app, KEY_NIGHT, NightMode.AUTO.name))
|
||||
}.getOrDefault(NightMode.AUTO)
|
||||
pinnedFlow.value = AppSettings.getString(app, KEY_PINNED, "")
|
||||
.split(",").filter { it.isNotEmpty() }.toSet()
|
||||
}
|
||||
|
||||
fun setScale(value: Float) {
|
||||
@@ -114,4 +126,17 @@ object CarSettings {
|
||||
AppSettings.putString(app, KEY_NIGHT, mode.name)
|
||||
CarEnvironment.refresh()
|
||||
}
|
||||
|
||||
/** Long-pressed onto or off the nav bar. Silently does nothing past [MAX_PINNED] rather than
|
||||
* bumping the oldest pin - a driver who filled the bar on purpose shouldn't lose one by accident. */
|
||||
fun togglePin(component: String) {
|
||||
val current = pinnedFlow.value
|
||||
val next = when {
|
||||
component in current -> current - component
|
||||
current.size >= MAX_PINNED -> return
|
||||
else -> current + component
|
||||
}
|
||||
pinnedFlow.value = next
|
||||
AppSettings.putString(app, KEY_PINNED, next.joinToString(","))
|
||||
}
|
||||
}
|
||||
@@ -98,9 +98,10 @@ fun CarSettingsScreen() {
|
||||
pipeAudio,
|
||||
) { CarSettings.setPipeAudio(it) }
|
||||
Text(
|
||||
"Phone output needs no permission: the app plays as it normally would, e.g. over " +
|
||||
"Bluetooth to the car. Through Gearslip sends the sound over the car link, and " +
|
||||
"Android asks for screen-capture consent each time.",
|
||||
"Through Gearslip sends the sound over the USB link. Android asks for consent the " +
|
||||
"first time a media app plays in the car, and some apps refuse to be captured at " +
|
||||
"all. Phone output needs no permission: the app plays as it normally would, for " +
|
||||
"example over Bluetooth to the car.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -116,9 +117,6 @@ fun CarSettingsScreen() {
|
||||
Info("Video", display.ifEmpty { "-" })
|
||||
Info("Certificate", cert?.headline ?: "Checking…")
|
||||
}
|
||||
val navigator = LocalCarNavigator.current
|
||||
Button(onClick = { navigator.open("calibrate") }) { Text("Display test") }
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,7 +161,7 @@ private fun WebPageSetting() {
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
url.ifEmpty { "Built-in test page - tap to set a URL" },
|
||||
url.ifEmpty { "DuckDuckGo (default) - tap to set a different page" },
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (url.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -19,9 +20,10 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -42,6 +44,9 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
@@ -53,8 +58,12 @@ import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.host.CarAppConnection
|
||||
import app.seb3thehacker.gearslip.notify.CarNotifications
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
|
||||
@@ -81,7 +90,14 @@ fun CarUi() {
|
||||
val base = LocalDensity.current
|
||||
val insets by CarEnvironment.insets.collectAsState()
|
||||
val navigator = remember { CarNavigator(CarSettings.openOnConnect.value) }
|
||||
remember(appContext) { invalidateLauncherApps(); CarServices.init(appContext) }
|
||||
val controlsWake = remember { MapControlsWake() }
|
||||
LaunchedEffect(controlsWake) {
|
||||
while (true) {
|
||||
delay(250)
|
||||
controlsWake.tick()
|
||||
}
|
||||
}
|
||||
remember(appContext) { Prefetch.warm(appContext, force = true); CarServices.init(appContext) }
|
||||
DisposableEffect(Unit) { onDispose { CarServices.shutdown() } }
|
||||
// The map follows the light outside, whatever the app's own theme is set to.
|
||||
LaunchedEffect(darkOutside) { CarServices.nav.pushConfiguration() }
|
||||
@@ -90,9 +106,23 @@ fun CarUi() {
|
||||
LocalDensity provides Density(base.density * scale, base.fontScale),
|
||||
LocalCarNavigator provides navigator,
|
||||
LocalDarkOutside provides darkOutside,
|
||||
LocalMapControlsWake provides controlsWake,
|
||||
) {
|
||||
MaterialTheme(colorScheme = colors) {
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
Surface(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
// Watches every touch without taking any: first in line, consumes nothing.
|
||||
.pointerInput(controlsWake) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
awaitPointerEvent(PointerEventPass.Initial)
|
||||
controlsWake.touch()
|
||||
}
|
||||
}
|
||||
},
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
// Insets are in video-frame pixels, so convert with the un-scaled density.
|
||||
val pad = with(base) {
|
||||
PaddingValues(
|
||||
@@ -100,29 +130,29 @@ fun CarUi() {
|
||||
end = insets.right.toDp(), bottom = insets.bottom.toDp(),
|
||||
)
|
||||
}
|
||||
// The calibration screen is the raw frame: no insets, no nav bar.
|
||||
val screen = navigator.current
|
||||
if (screen == CarScreen.App("calibrate")) {
|
||||
CarApps.find("calibrate")?.content?.invoke()
|
||||
} else {
|
||||
Column(Modifier.fillMaxSize().padding(pad)) {
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||
when (screen) {
|
||||
CarScreen.Home -> CarHome()
|
||||
CarScreen.Apps -> CarLauncher()
|
||||
CarScreen.Media -> mediaApp?.let { MediaScreen(it) { navigator.home() } }
|
||||
CarScreen.Settings -> CarSettingsScreen()
|
||||
CarScreen.Dashboard -> CarDashboardScreen()
|
||||
is CarScreen.Notifications -> NotificationsScreen(screen.replyTo)
|
||||
is CarScreen.App -> CarApps.find(screen.id)?.content?.invoke()
|
||||
}
|
||||
// The history screen is already showing them, so it needs no popup.
|
||||
if (screen !is CarScreen.Notifications) {
|
||||
NotificationPopup(Modifier.align(Alignment.TopCenter))
|
||||
}
|
||||
Column(Modifier.fillMaxSize().padding(pad)) {
|
||||
// Header navigation (breadcrumb trail) - off for now, kept to tweak later.
|
||||
// BreadcrumbBar(navigator)
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||
when (screen) {
|
||||
CarScreen.Home -> CarHome()
|
||||
CarScreen.Apps -> CarLauncher()
|
||||
CarScreen.Media -> mediaApp?.let { MediaScreen(it) { navigator.back() } }
|
||||
CarScreen.Settings -> CarSettingsScreen()
|
||||
CarScreen.Dashboard -> CarDashboardScreen()
|
||||
CarScreen.Weather -> WeatherScreen()
|
||||
is CarScreen.Notifications -> NotificationsScreen(screen.replyTo)
|
||||
is CarScreen.App -> CarApps.find(screen.id)?.content?.invoke()
|
||||
}
|
||||
CarNavBar(navigator)
|
||||
// The history screen is already showing them, so it needs no popup.
|
||||
if (screen !is CarScreen.Notifications) {
|
||||
NotificationPopup(Modifier.align(Alignment.TopCenter))
|
||||
}
|
||||
// Above the popup: a call is more urgent than any notification.
|
||||
CallOverlay(Modifier.align(Alignment.TopCenter))
|
||||
}
|
||||
CarNavBar(navigator)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,71 +160,189 @@ fun CarUi() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Home and Apps on the left; now-playing pill in the middle; clock (tap for calendar and weather), phone battery and the
|
||||
* darkness signal on the right. Icon-only: a driver reads a glyph faster than a label, and it
|
||||
* keeps the bar short enough to leave the app underneath more room.
|
||||
* "Home > Apps > Settings", like a file manager's address bar: hidden at Home itself (nothing to
|
||||
* show yet), and grows by one crumb for every screen pushed from there. The back arrow undoes one
|
||||
* step; a crumb jumps straight to it, dropping everything after - this is the only on-screen way
|
||||
* back at all, since a car touchscreen has no hardware button for it.
|
||||
*/
|
||||
@Composable
|
||||
private fun CarNavBar(navigator: CarNavigator) {
|
||||
val battery by CarEnvironment.battery.collectAsState()
|
||||
val darkOutside by CarEnvironment.darkOutside.collectAsState()
|
||||
val now by rememberNow()
|
||||
|
||||
// The home and media screens already show what's playing; everywhere else it lives in the
|
||||
// middle of this bar so it stays one tap away.
|
||||
val showNowPlaying = navigator.current != CarScreen.Home && navigator.current != CarScreen.Media
|
||||
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceContainer) {
|
||||
Box(Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 10.dp)) {
|
||||
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
|
||||
NavItem(Icons.Filled.Home, navigator.current == CarScreen.Home) { navigator.home() }
|
||||
Spacer(Modifier.width(6.dp))
|
||||
NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() }
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.End,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.clickable { navigator.dashboard() }
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
) {
|
||||
Text(
|
||||
DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(now)),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
val phone = if (battery.percent >= 0) "${battery.percent}%${if (battery.charging) " charging" else ""}" else ""
|
||||
val light = if (darkOutside) "Night" else "Day"
|
||||
Text(
|
||||
listOf(phone, light).filter { it.isNotEmpty() }.joinToString(" · "),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(10.dp))
|
||||
NavItem(
|
||||
Icons.Filled.Notifications, navigator.current is CarScreen.Notifications,
|
||||
) { navigator.notifications() }
|
||||
}
|
||||
|
||||
if (showNowPlaying) {
|
||||
NavNowPlaying(navigator, Modifier.align(Alignment.Center))
|
||||
private fun BreadcrumbBar(navigator: CarNavigator) {
|
||||
val trail = navigator.trail
|
||||
if (trail.size <= 1) return
|
||||
Row(
|
||||
Modifier.fillMaxWidth().height(40.dp).padding(start = 2.dp, end = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = navigator::back, modifier = Modifier.size(36.dp)) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back", Modifier.size(20.dp))
|
||||
}
|
||||
Spacer(Modifier.width(2.dp))
|
||||
trail.forEachIndexed { index, crumb ->
|
||||
val isLast = index == trail.lastIndex
|
||||
Text(
|
||||
crumb.label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = if (isLast) FontWeight.SemiBold else FontWeight.Normal,
|
||||
color = if (isLast) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = if (isLast) Modifier else Modifier
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.clickable { navigator.jumpTo(index) }
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
)
|
||||
if (!isLast) {
|
||||
Text(
|
||||
"›",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apps and open/pinned shortcuts on the left; now-playing pill against them; clock (tap for
|
||||
* calendar and weather), phone battery and the darkness signal on the right. Icon-only: a driver
|
||||
* reads a glyph faster than a label, and it keeps the bar short enough to leave the app underneath
|
||||
* more room.
|
||||
*/
|
||||
@Composable
|
||||
private fun NavItem(icon: ImageVector, selected: Boolean, onClick: () -> Unit) {
|
||||
private fun CarNavBar(navigator: CarNavigator) {
|
||||
val battery by CarEnvironment.battery.collectAsState()
|
||||
val now by rememberNow()
|
||||
val weather by Weather.state.collectAsState()
|
||||
val weatherData = (weather as? WeatherState.Ready)?.data
|
||||
|
||||
// The one app that's actually open and not otherwise reachable from this bar: media has its
|
||||
// own pill already, so the only "open app" icon worth showing is whichever map is connected.
|
||||
val navStatus by CarServices.nav.status.collectAsState()
|
||||
val lastNav by CarSettings.lastNav.collectAsState()
|
||||
val openMapComponent = lastNav?.takeIf { navStatus.phase == CarAppConnection.Phase.RUNNING }
|
||||
|
||||
val context = LocalContext.current
|
||||
val pinnedIds by CarSettings.pinnedApps.collectAsState()
|
||||
val entries by produceState(LauncherCache.entries ?: emptyList(), context) {
|
||||
value = LauncherCache.entries ?: withContext(Dispatchers.IO) { LauncherCache.load(context) }
|
||||
}
|
||||
val pinnedEntries = remember(entries, pinnedIds) {
|
||||
pinnedIds.mapNotNull { id -> entries.find { it.componentId == id } }
|
||||
}
|
||||
val frame by CarEnvironment.frame.collectAsState()
|
||||
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceContainer) {
|
||||
BoxWithConstraints(Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 10.dp)) {
|
||||
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
|
||||
// Home and Apps are two different places now, not a toggle on one button - the
|
||||
// breadcrumb trail is how you retrace your steps, but reaching for either of
|
||||
// these two from three screens deep in a media app shouldn't mean reading it
|
||||
// first.
|
||||
NavItem(Icons.Filled.Home, navigator.current == CarScreen.Home) { navigator.home() }
|
||||
Spacer(Modifier.width(6.dp))
|
||||
NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() }
|
||||
|
||||
openMapComponent?.let { component ->
|
||||
Spacer(Modifier.width(6.dp))
|
||||
NavAppIcon(component, contentDescription = "Map", onClick = navigator::home)
|
||||
}
|
||||
pinnedEntries.forEach { entry ->
|
||||
Spacer(Modifier.width(6.dp))
|
||||
entry.componentId?.let { id ->
|
||||
NavAppIcon(id, contentDescription = entry.label) { launchEntry(entry, navigator, frame) }
|
||||
}
|
||||
}
|
||||
|
||||
Box(Modifier.weight(1f).padding(horizontal = 10.dp), contentAlignment = Alignment.CenterStart) {
|
||||
NavNowPlaying(navigator, selected = navigator.current == CarScreen.Media)
|
||||
}
|
||||
|
||||
// Weather no longer has its own chip - the temperature alone (no icon, no room
|
||||
// for one) sits where "Night"/"Day" used to, freeing a whole chip's width for the
|
||||
// Home button above.
|
||||
NavChip(navigator.current == CarScreen.Dashboard, { navigator.dashboard() }) {
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(
|
||||
DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(now)),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
if (battery.percent >= 0) {
|
||||
Text(
|
||||
"${battery.percent}%",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (battery.charging) {
|
||||
ChargingGlyph(Modifier.size(13.dp))
|
||||
}
|
||||
}
|
||||
weatherData?.let { w ->
|
||||
Text(
|
||||
" · ${w.temp}°",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(10.dp))
|
||||
NavItem(
|
||||
Icons.Filled.Notifications, navigator.current is CarScreen.Notifications, padding = 8.dp,
|
||||
) { navigator.notifications() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One open or pinned app's icon on the nav bar - the same fixed size and shape either way. */
|
||||
@Composable
|
||||
private fun NavAppIcon(component: String, contentDescription: String?, onClick: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val pkg = remember(component) { android.content.ComponentName.unflattenFromString(component)?.packageName }
|
||||
val icon by produceState<androidx.compose.ui.graphics.ImageBitmap?>(null, pkg) {
|
||||
value = pkg?.let {
|
||||
runCatching { context.packageManager.getApplicationIcon(it).toBitmap(96, 96).asImageBitmap() }.getOrNull()
|
||||
}
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.size(44.dp)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
icon?.let { Image(it, contentDescription, Modifier.size(30.dp).clip(RoundedCornerShape(8.dp))) }
|
||||
}
|
||||
}
|
||||
|
||||
/** A tappable group in the bar that stays highlighted while its screen is open, like [NavItem]. */
|
||||
@Composable
|
||||
private fun NavChip(selected: Boolean, onClick: () -> Unit, content: @Composable () -> Unit) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Box(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(if (selected) scheme.secondaryContainer else scheme.surfaceContainer)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(12.dp),
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { content() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NavItem(icon: ImageVector, selected: Boolean, padding: androidx.compose.ui.unit.Dp = 12.dp, onClick: () -> Unit) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Box(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(if (selected) scheme.secondaryContainer else scheme.surfaceContainer)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(icon, contentDescription = null, modifier = Modifier.size(28.dp))
|
||||
@@ -210,21 +358,25 @@ internal fun rememberNow(): State<Long> = produceState(System.currentTimeMillis(
|
||||
}
|
||||
}
|
||||
|
||||
/** Art, title and play/pause in the nav bar; tapping the art or title opens the full media screen. */
|
||||
/**
|
||||
* The one persistent player, everywhere - not just a Home-screen extra. Art and title open the
|
||||
* full media screen; skip and play/pause work right here; the last button toggles the lyrics
|
||||
* panel beside the map on Home (see [CarHome]), the pill's own lyrics affordance wherever it sits.
|
||||
*/
|
||||
@Composable
|
||||
private fun NavNowPlaying(navigator: CarNavigator, modifier: Modifier) {
|
||||
private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier: Modifier = Modifier) {
|
||||
val media = CarServices.media
|
||||
val phase by media.phase.collectAsState()
|
||||
val now by media.now.collectAsState()
|
||||
if (phase != CarMedia.Phase.READY || !now.hasTrack) return
|
||||
if (phase != CarMedia.Phase.READY || !now.isActive) return
|
||||
val context = LocalContext.current
|
||||
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
|
||||
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(50),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(start = 6.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
|
||||
@@ -233,6 +385,7 @@ private fun NavNowPlaying(navigator: CarNavigator, modifier: Modifier) {
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.weight(1f, fill = false)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.clickable { navigator.media() },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -242,7 +395,8 @@ private fun NavNowPlaying(navigator: CarNavigator, modifier: Modifier) {
|
||||
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
||||
}
|
||||
Text(
|
||||
now.title.ifEmpty { "Nothing playing" }.let { if (it.length > 15) it.take(15) + "…" else it },
|
||||
now.title.ifEmpty { "Nothing playing" },
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
@@ -256,9 +410,65 @@ private fun NavNowPlaying(navigator: CarNavigator, modifier: Modifier) {
|
||||
Modifier.size(26.dp),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { media.next() }, modifier = Modifier.size(44.dp)) {
|
||||
Icon(MediaIcons.Next, "Next", Modifier.size(28.dp))
|
||||
FilledIconButton(
|
||||
onClick = navigator::toggleLyrics,
|
||||
modifier = Modifier.size(44.dp),
|
||||
colors = androidx.compose.material3.IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = if (navigator.lyricsOpen) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
contentColor = if (navigator.lyricsOpen) MaterialTheme.colorScheme.onPrimary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
) {
|
||||
LyricsGlyph(Modifier.size(22.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A card with two lines of text on it - read as "lyrics"/"captions" at a glance, which no icon in
|
||||
* the core Material set does (the closest, a bullet list, reads as a menu, not song text). Drawn
|
||||
* rather than a vector asset since the whole glyph is two shapes.
|
||||
*/
|
||||
@Composable
|
||||
private fun LyricsGlyph(modifier: Modifier = Modifier) {
|
||||
val tint = androidx.compose.material3.LocalContentColor.current
|
||||
androidx.compose.foundation.Canvas(modifier) {
|
||||
val stroke = size.minDimension * 0.11f
|
||||
drawRoundRect(
|
||||
color = tint,
|
||||
cornerRadius = androidx.compose.ui.geometry.CornerRadius(size.minDimension * 0.22f),
|
||||
style = androidx.compose.ui.graphics.drawscope.Stroke(width = stroke),
|
||||
)
|
||||
val lineStyle = androidx.compose.ui.graphics.drawscope.Stroke(width = stroke * 0.85f, cap = androidx.compose.ui.graphics.StrokeCap.Round)
|
||||
val startX = size.width * 0.24f
|
||||
listOf(0.40f, 0.64f).forEach { fy ->
|
||||
val endX = size.width * (if (fy < 0.5f) 0.76f else 0.62f)
|
||||
drawLine(tint, androidx.compose.ui.geometry.Offset(startX, size.height * fy), androidx.compose.ui.geometry.Offset(endX, size.height * fy), strokeWidth = lineStyle.width, cap = lineStyle.cap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A filled lightning bolt, next to the battery percentage instead of the word "charging" - the
|
||||
* core Material icon set this project ships (no extended pack, to keep the app small) has no
|
||||
* bolt, so this is drawn the same way [LyricsGlyph] is.
|
||||
*/
|
||||
@Composable
|
||||
private fun ChargingGlyph(modifier: Modifier = Modifier, tint: Color = MaterialTheme.colorScheme.onSurfaceVariant) {
|
||||
androidx.compose.foundation.Canvas(modifier) {
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
val bolt = androidx.compose.ui.graphics.Path().apply {
|
||||
moveTo(w * 0.58f, 0f)
|
||||
lineTo(w * 0.12f, h * 0.58f)
|
||||
lineTo(w * 0.46f, h * 0.58f)
|
||||
lineTo(w * 0.42f, h)
|
||||
lineTo(w * 0.88f, h * 0.40f)
|
||||
lineTo(w * 0.54f, h * 0.40f)
|
||||
close()
|
||||
}
|
||||
drawPath(bolt, tint)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import android.os.SystemClock
|
||||
|
||||
/**
|
||||
* Whether the map's buttons (settings, pan, zoom, locate) are showing.
|
||||
*
|
||||
* They fade out after [IDLE_MS] with no touch anywhere on the car screen, to leave the map clear,
|
||||
* and any touch brings them back. [touch] is called for every pointer event and is cheap: it only
|
||||
* writes state when the buttons were actually hidden.
|
||||
*/
|
||||
class MapControlsWake {
|
||||
|
||||
var awake by mutableStateOf(true)
|
||||
private set
|
||||
|
||||
private var lastTouch = SystemClock.uptimeMillis()
|
||||
|
||||
fun touch() {
|
||||
lastTouch = SystemClock.uptimeMillis()
|
||||
if (!awake) awake = true
|
||||
}
|
||||
|
||||
/** Called on a timer; hides the buttons once nothing has touched the screen for [IDLE_MS]. */
|
||||
fun tick() {
|
||||
if (awake && SystemClock.uptimeMillis() - lastTouch >= IDLE_MS) awake = false
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val IDLE_MS = 4_000L
|
||||
}
|
||||
}
|
||||
|
||||
val LocalMapControlsWake = staticCompositionLocalOf { MapControlsWake() }
|
||||
@@ -2,8 +2,13 @@ package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import android.provider.Settings
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -60,6 +65,7 @@ import app.seb3thehacker.gearslip.media.LyricsResult
|
||||
import app.seb3thehacker.gearslip.media.Lyrics
|
||||
import app.seb3thehacker.gearslip.media.MediaEntry
|
||||
import app.seb3thehacker.gearslip.media.NowPlaying
|
||||
import app.seb3thehacker.gearslip.media.QueueTrack
|
||||
import android.media.session.PlaybackState
|
||||
|
||||
/**
|
||||
@@ -72,9 +78,11 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
|
||||
val media = CarServices.media
|
||||
val phase by media.phase.collectAsStateWithLifecycle()
|
||||
val now by media.now.collectAsStateWithLifecycle()
|
||||
val queue by media.queue.collectAsStateWithLifecycle()
|
||||
val browse by media.browse.collectAsStateWithLifecycle()
|
||||
val rejection by media.rejection.collectAsStateWithLifecycle()
|
||||
val capture by CarAudio.capture.collectAsStateWithLifecycle()
|
||||
var tab by remember { mutableStateOf(Tab.BROWSE) }
|
||||
var tab by remember { mutableStateOf(Tab.NOW_PLAYING) }
|
||||
|
||||
// The connection outlives this screen: the home screen's player is the same one.
|
||||
LaunchedEffect(app) { CarServices.openMedia(app) }
|
||||
@@ -88,28 +96,51 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
|
||||
}
|
||||
|
||||
when (phase) {
|
||||
CarMedia.Phase.REJECTED -> Notice(
|
||||
"${app.label} would not let Gearslip browse it.",
|
||||
"Some media apps only accept Google's own host.",
|
||||
)
|
||||
CarMedia.Phase.REJECTED -> when (rejection) {
|
||||
CarMedia.Rejection.NEEDS_NOTIFICATION_ACCESS -> Notice(
|
||||
"${app.label} keeps its library to itself.",
|
||||
"Gearslip can still control it once it has Notification access on the phone. " +
|
||||
"Allow it under Settings > Notifications > Notification access.",
|
||||
action = "Open on phone" to {
|
||||
val app = context.applicationContext
|
||||
runCatching {
|
||||
app.startActivity(
|
||||
Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
CarMedia.Rejection.REFUSED -> Notice(
|
||||
"${app.label} would not let Gearslip browse it.",
|
||||
"Some media apps only accept Google's own host.",
|
||||
)
|
||||
}
|
||||
CarMedia.Phase.CONNECTING, CarMedia.Phase.IDLE -> Notice("Connecting to ${app.label}…", null)
|
||||
CarMedia.Phase.READY -> Row(
|
||||
Modifier.fillMaxSize().padding(top = 6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
NowPlayingPanel(now, media, Modifier.width(250.dp).fillMaxHeight())
|
||||
// The art rail sits at the top, level with the tab row beside it, on every tab -
|
||||
// not under a full-width tab bar the way it used to be. Transport and the
|
||||
// progress bar stay on it always; only the app's extra buttons (shuffle, repeat,
|
||||
// like) are exclusive to the "Now playing" tab's own content area, since those are
|
||||
// the one thing that isn't already always visible here.
|
||||
NowPlayingRail(now, media, Modifier.width(220.dp).fillMaxHeight())
|
||||
Column(Modifier.weight(1f).fillMaxHeight()) {
|
||||
Row {
|
||||
TextButton(onClick = { tab = Tab.BROWSE }) {
|
||||
Text("Browse", fontWeight = if (tab == Tab.BROWSE) FontWeight.Bold else FontWeight.Normal)
|
||||
}
|
||||
TextButton(onClick = { tab = Tab.LYRICS }) {
|
||||
Text("Lyrics", fontWeight = if (tab == Tab.LYRICS) FontWeight.Bold else FontWeight.Normal)
|
||||
}
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
MediaTab("Now playing", tab == Tab.NOW_PLAYING, Modifier.weight(1f)) { tab = Tab.NOW_PLAYING }
|
||||
MediaTab("Browse", tab == Tab.BROWSE, Modifier.weight(1f)) { tab = Tab.BROWSE }
|
||||
MediaTab("Up next", tab == Tab.QUEUE, Modifier.weight(1f)) { tab = Tab.QUEUE }
|
||||
MediaTab("Lyrics", tab == Tab.LYRICS, Modifier.weight(1f)) { tab = Tab.LYRICS }
|
||||
}
|
||||
when (tab) {
|
||||
Tab.BROWSE -> BrowsePanel(browse, media, Modifier.fillMaxSize())
|
||||
Tab.LYRICS -> LyricsPanel(now, Modifier.fillMaxSize())
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||
when (tab) {
|
||||
Tab.NOW_PLAYING -> NowPlayingExtras(now, media, Modifier.fillMaxSize())
|
||||
Tab.BROWSE -> BrowsePanel(browse, media, app.label, Modifier.fillMaxSize())
|
||||
Tab.QUEUE -> QueuePanel(queue, now, media, Modifier.fillMaxSize())
|
||||
Tab.LYRICS -> LyricsPanel(now, Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,14 +148,30 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
private enum class Tab { BROWSE, LYRICS }
|
||||
private enum class Tab { NOW_PLAYING, BROWSE, QUEUE, LYRICS }
|
||||
|
||||
/** A bigger, easier-to-hit tab than a plain [TextButton] - this row is the only navigation on the screen. */
|
||||
@Composable
|
||||
private fun MediaTab(label: String, selected: Boolean, modifier: Modifier, onClick: () -> Unit) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = modifier.height(48.dp),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
|
||||
) {
|
||||
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(label, style = MaterialTheme.typography.titleMedium, fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The song's lyrics, fetched when this tab opens. Synced lyrics follow the playback position with
|
||||
* the current line in the accent colour; unsynced ones are plain scrolling text.
|
||||
*/
|
||||
@Composable
|
||||
private fun LyricsPanel(now: NowPlaying, modifier: Modifier) {
|
||||
internal fun LyricsPanel(now: NowPlaying, modifier: Modifier) {
|
||||
val lyrics by produceState<LyricsState>(LyricsState.Loading, now.title, now.artist, now.album, now.durationMs) {
|
||||
value = LyricsState.Loading
|
||||
value = Lyrics.find(now.title, now.artist, now.album, now.durationMs)
|
||||
@@ -189,17 +236,44 @@ private fun AudioBadge(capture: CarAudio.Capture) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Notice(title: String, detail: String?) {
|
||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||
detail?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
private fun Notice(title: String, detail: String?, action: Pair<String, () -> Unit>? = null) {
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(horizontal = 24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, textAlign = androidx.compose.ui.text.style.TextAlign.Center)
|
||||
detail?.let {
|
||||
Text(
|
||||
it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
)
|
||||
}
|
||||
action?.let { (label, run) ->
|
||||
Spacer(Modifier.height(12.dp))
|
||||
androidx.compose.material3.FilledTonalButton(onClick = run) { Text(label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "1:05" for under a minute past the hour, "1:01:05" once an hour is involved. */
|
||||
private fun formatDuration(ms: Long): String {
|
||||
val totalSeconds = (ms / 1000).coerceAtLeast(0)
|
||||
val hours = totalSeconds / 3600
|
||||
val minutes = (totalSeconds % 3600) / 60
|
||||
val seconds = totalSeconds % 60
|
||||
return if (hours > 0) "%d:%02d:%02d".format(hours, minutes, seconds) else "%d:%02d".format(minutes, seconds)
|
||||
}
|
||||
|
||||
// --- now playing -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The art rail beside every tab: art, title, transport and the seek bar - always visible no
|
||||
* matter which tab is open. Only the app's extra buttons (shuffle, repeat, like) are held back
|
||||
* for [NowPlayingExtras], since those are the one thing that isn't already always shown here.
|
||||
*/
|
||||
@Composable
|
||||
private fun NowPlayingPanel(now: NowPlaying, media: CarMedia, modifier: Modifier) {
|
||||
private fun NowPlayingRail(now: NowPlaying, media: CarMedia, modifier: Modifier) {
|
||||
val context = LocalContext.current
|
||||
val art by produceState(now.art, now.art, now.artUri) {
|
||||
value = now.art ?: MediaArt.load(context, now.artUri)
|
||||
@@ -212,55 +286,76 @@ private fun NowPlayingPanel(now: NowPlaying, media: CarMedia, modifier: Modifier
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.size(170.dp),
|
||||
) {
|
||||
art?.let {
|
||||
Image(it.asImageBitmap(), null, Modifier.fillMaxSize().clip(MaterialTheme.shapes.large), contentScale = ContentScale.Crop)
|
||||
// Whether the progress bar (and its duration text) is showing depends on whether the app has
|
||||
// reported a duration yet, which can arrive a beat after everything else - so this rail's
|
||||
// content height isn't fixed. A plain fillMaxHeight Column just clips whatever doesn't fit
|
||||
// once that row appears; scrolling means it's always reachable instead.
|
||||
BoxWithConstraints(modifier) {
|
||||
// Off the space actually available rather than a fixed dp value, so it doesn't overrun a
|
||||
// short car frame the way a flat size did on anything shorter than a phone screen.
|
||||
val artSize = minOf(maxWidth, maxHeight * 0.45f).coerceAtLeast(96.dp)
|
||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.size(artSize),
|
||||
) {
|
||||
art?.let {
|
||||
Image(it.asImageBitmap(), null, Modifier.fillMaxSize().clip(MaterialTheme.shapes.large), contentScale = ContentScale.Crop)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
now.title.ifEmpty { "Nothing playing" },
|
||||
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
listOf(now.artist, now.album).filter { it.isNotEmpty() }.joinToString(" - "),
|
||||
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
now.error?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, maxLines = 2) }
|
||||
|
||||
if (now.durationMs > 0) {
|
||||
WavyProgress(
|
||||
fraction = position.toFloat() / now.durationMs,
|
||||
playing = now.state == PlaybackState.STATE_PLAYING,
|
||||
onSeek = { media.seek((it * now.durationMs).toLong()) },
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
now.title.ifEmpty { "Nothing playing" },
|
||||
style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
} else {
|
||||
Spacer(Modifier.height(14.dp))
|
||||
}
|
||||
Text(
|
||||
listOf(now.artist, now.album).filter { it.isNotEmpty() }.joinToString(" - "),
|
||||
style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
now.error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, maxLines = 2) }
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
IconButton(onClick = media::previous, enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), modifier = Modifier.size(56.dp)) {
|
||||
Icon(MediaIcons.Previous, "Previous", Modifier.size(32.dp))
|
||||
if (now.durationMs > 0) {
|
||||
WavyProgress(
|
||||
fraction = position.toFloat() / now.durationMs,
|
||||
playing = now.state == PlaybackState.STATE_PLAYING,
|
||||
onSeek = { media.seek((it * now.durationMs).toLong()) },
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(formatDuration(position), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(formatDuration(now.durationMs), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
FilledIconButton(onClick = media::togglePlay, modifier = Modifier.size(68.dp)) {
|
||||
Icon(if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow, if (now.playing) "Pause" else "Play", Modifier.size(40.dp))
|
||||
}
|
||||
IconButton(onClick = media::next, enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_NEXT), modifier = Modifier.size(56.dp)) {
|
||||
Icon(MediaIcons.Next, "Next", Modifier.size(32.dp))
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
IconButton(onClick = media::previous, enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), modifier = Modifier.size(56.dp)) {
|
||||
Icon(MediaIcons.Previous, "Previous", Modifier.size(32.dp))
|
||||
}
|
||||
FilledIconButton(onClick = media::togglePlay, modifier = Modifier.size(68.dp)) {
|
||||
Icon(if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow, if (now.playing) "Pause" else "Play", Modifier.size(40.dp))
|
||||
}
|
||||
IconButton(onClick = media::next, enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_NEXT), modifier = Modifier.size(56.dp)) {
|
||||
Icon(MediaIcons.Next, "Next", Modifier.size(32.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Whatever extra buttons the app publishes (shuffle, repeat, like), by name: their
|
||||
// icons are resources inside the app's own package.
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
/**
|
||||
* The "Now playing" tab's own content: just the app's extra buttons (shuffle, repeat, like), by
|
||||
* name - their icons are resources inside the app's own package, so there's nothing to draw but
|
||||
* the label. Transport and progress already live on [NowPlayingRail], visible on every tab.
|
||||
*/
|
||||
@Composable
|
||||
private fun NowPlayingExtras(now: NowPlaying, media: CarMedia, modifier: Modifier) {
|
||||
Column(modifier, verticalArrangement = Arrangement.Center) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
now.custom.take(3).forEach { action ->
|
||||
TextButton(onClick = { media.custom(action) }) { Text(action.name, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||
}
|
||||
@@ -271,20 +366,31 @@ private fun NowPlayingPanel(now: NowPlaying, media: CarMedia, modifier: Modifier
|
||||
// --- browsing --------------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
private fun BrowsePanel(browse: app.seb3thehacker.gearslip.media.BrowseState, media: CarMedia, modifier: Modifier) {
|
||||
private fun BrowsePanel(browse: app.seb3thehacker.gearslip.media.BrowseState, media: CarMedia, appLabel: String, modifier: Modifier) {
|
||||
Column(modifier) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (media.canGoUp) {
|
||||
IconButton(onClick = media::up) { Icon(Icons.Filled.ArrowBack, "Up") }
|
||||
// At the library root there's no trail and nowhere to go up to, so there's nothing this
|
||||
// row could say that the app's name in the top-left corner hasn't already - showing it
|
||||
// again here (as "Browse", or as the app's own name) was just the same label twice.
|
||||
if (media.canGoUp || browse.trail.isNotEmpty()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (media.canGoUp) {
|
||||
IconButton(onClick = media::up) { Icon(Icons.Filled.ArrowBack, "Up") }
|
||||
}
|
||||
browse.trail.lastOrNull()?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
browse.trail.lastOrNull() ?: "Browse",
|
||||
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
when {
|
||||
browse.loading -> Notice("Loading…", null)
|
||||
browse.unavailable -> Notice(
|
||||
"$appLabel keeps its library to itself.",
|
||||
"Start music in the app on your phone. Play, pause, skip and seek work from here.",
|
||||
)
|
||||
browse.failed -> Notice("Could not load this list.", null)
|
||||
browse.entries.isEmpty() -> Notice("Nothing here.", null)
|
||||
else -> LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
@@ -294,6 +400,61 @@ private fun BrowsePanel(browse: app.seb3thehacker.gearslip.media.BrowseState, me
|
||||
}
|
||||
}
|
||||
|
||||
// --- up next -----------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Whatever the app has queued up, starting right after the track that's playing now - not the
|
||||
* whole queue from the top, which would just repeat what [NowPlayingRail] already shows. Apps
|
||||
* that never publish a queue (many don't) show an empty notice rather than a permanently blank tab.
|
||||
*/
|
||||
@Composable
|
||||
private fun QueuePanel(queue: List<QueueTrack>, now: NowPlaying, media: CarMedia, modifier: Modifier) {
|
||||
val upNext = remember(queue, now.activeQueueItemId) {
|
||||
val current = queue.indexOfFirst { it.queueId == now.activeQueueItemId }
|
||||
if (current >= 0) queue.drop(current + 1) else queue
|
||||
}
|
||||
Column(modifier) {
|
||||
if (upNext.isEmpty()) {
|
||||
Notice(
|
||||
"Nothing queued.",
|
||||
"${if (now.title.isEmpty()) "Nothing playing" else "Only what's already playing"} - " +
|
||||
"not every app tells Gearslip what comes next.",
|
||||
)
|
||||
} else {
|
||||
LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(upNext, key = { it.queueId }) { track -> QueueRow(track, media) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QueueRow(track: QueueTrack, media: CarMedia) {
|
||||
val context = LocalContext.current
|
||||
val icon by produceState(track.iconBitmap, track.iconUri) {
|
||||
value = track.iconBitmap ?: MediaArt.load(context, track.iconUri)
|
||||
}
|
||||
Surface(
|
||||
onClick = { media.playQueueItem(track) },
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.size(52.dp).clip(MaterialTheme.shapes.medium).background(MaterialTheme.colorScheme.surface)) {
|
||||
icon?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
||||
}
|
||||
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||
Text(track.title, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (track.subtitle.isNotEmpty()) {
|
||||
Text(track.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
Icon(Icons.Filled.PlayArrow, "Play", tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EntryRow(entry: MediaEntry, media: CarMedia) {
|
||||
val context = LocalContext.current
|
||||
|
||||
@@ -48,8 +48,8 @@ import kotlinx.coroutines.withContext
|
||||
@Composable
|
||||
fun PhoneAppsScreen() {
|
||||
val context = LocalContext.current
|
||||
val apps by produceState(initialValue = emptyList<PhoneApp>(), context) {
|
||||
value = withContext(Dispatchers.IO) { PhoneApps.installed(context) }
|
||||
val apps by produceState(initialValue = PhoneApps.cached() ?: emptyList(), context) {
|
||||
value = PhoneApps.cached() ?: withContext(Dispatchers.IO) { PhoneApps.installed(context).also { PhoneApps.warm(context) } }
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 12.dp)) {
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.provider.ContactsContract
|
||||
import android.provider.Settings
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Call
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/** A phone contact with at least one number, as read off the device's contacts. */
|
||||
private data class Contact(val name: String, val number: String)
|
||||
|
||||
private val CALL_PERMISSIONS = arrayOf(Manifest.permission.CALL_PHONE, Manifest.permission.READ_CONTACTS)
|
||||
|
||||
private fun hasCallPermissions(context: Context): Boolean =
|
||||
CALL_PERMISSIONS.all { context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }
|
||||
|
||||
private fun loadContacts(context: Context): List<Contact> {
|
||||
if (context.checkSelfPermission(Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) return emptyList()
|
||||
val out = mutableListOf<Contact>()
|
||||
val projection = arrayOf(
|
||||
ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME,
|
||||
ContactsContract.CommonDataKinds.Phone.NUMBER,
|
||||
)
|
||||
context.contentResolver.query(
|
||||
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
|
||||
projection, null, null,
|
||||
"${ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME} ASC",
|
||||
)?.use { cursor ->
|
||||
val nameIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME)
|
||||
val numberIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
|
||||
while (cursor.moveToNext()) {
|
||||
val name = cursor.getString(nameIndex) ?: continue
|
||||
val number = cursor.getString(numberIndex) ?: continue
|
||||
out += Contact(name, number)
|
||||
}
|
||||
}
|
||||
return out.distinctBy { it.number }
|
||||
}
|
||||
|
||||
private fun placeCall(context: Context, number: String) {
|
||||
if (number.isBlank()) return
|
||||
if (context.checkSelfPermission(Manifest.permission.CALL_PHONE) != PackageManager.PERMISSION_GRANTED) return
|
||||
runCatching {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_CALL, Uri.parse("tel:${Uri.encode(number)}")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val TAB_CONTACTS = 0
|
||||
private const val TAB_KEYPAD = 1
|
||||
|
||||
/** Dialing a number or calling a contact from the car screen: a contacts tab and a keypad tab. */
|
||||
@Composable
|
||||
fun PhoneDialerScreen() {
|
||||
val context = LocalContext.current
|
||||
val hasPermissions = hasCallPermissions(context)
|
||||
var tab by remember { mutableStateOf(TAB_CONTACTS) }
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Text(
|
||||
"Phone",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
)
|
||||
|
||||
if (!hasPermissions) {
|
||||
MissingPermission(context)
|
||||
return@Column
|
||||
}
|
||||
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
DialerTab("Contacts", tab == TAB_CONTACTS, Modifier.weight(1f)) { tab = TAB_CONTACTS }
|
||||
DialerTab("Keypad", tab == TAB_KEYPAD, Modifier.weight(1f)) { tab = TAB_KEYPAD }
|
||||
}
|
||||
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||
if (tab == TAB_CONTACTS) ContactsList(context) else Keypad(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MissingPermission(context: Context) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
"Gearslip needs the Phone and Contacts permissions to dial from here. Grant them " +
|
||||
"from the app's settings.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
FilledTonalButton(onClick = {
|
||||
context.startActivity(
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)),
|
||||
)
|
||||
}) { Text("Open app settings") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DialerTab(label: String, selected: Boolean, modifier: Modifier, onClick: () -> Unit) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = modifier.height(52.dp),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
|
||||
) {
|
||||
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(label, style = MaterialTheme.typography.titleMedium, fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A 3-column digit grid beside a fourth column holding backspace and call, stacked - four equal
|
||||
* columns in all. Every row and both side buttons take an equal weighted share of whatever height
|
||||
* is available, so this fits the car's short landscape frame without scrolling instead of
|
||||
* overflowing off the bottom the way fixed-height rows did.
|
||||
*/
|
||||
@Composable
|
||||
private fun Keypad(context: Context) {
|
||||
var number by remember { mutableStateOf("") }
|
||||
val rows = listOf(
|
||||
listOf("1", "2", "3"),
|
||||
listOf("4", "5", "6"),
|
||||
listOf("7", "8", "9"),
|
||||
listOf("*", "0", "#"),
|
||||
)
|
||||
|
||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
number.ifEmpty { "Enter a number" },
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = if (number.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(vertical = 6.dp),
|
||||
)
|
||||
Row(Modifier.weight(1f).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Column(Modifier.weight(3f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
rows.forEach { row ->
|
||||
Row(Modifier.weight(1f).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
row.forEach { digit ->
|
||||
KeypadButton(digit, Modifier.weight(1f).fillMaxHeight()) { number += digit }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(Modifier.weight(1f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilledTonalButton(
|
||||
onClick = { if (number.isNotEmpty()) number = number.dropLast(1) },
|
||||
enabled = number.isNotEmpty(),
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
) { Text("⌫") }
|
||||
FilledIconButton(
|
||||
onClick = { placeCall(context, number) },
|
||||
enabled = number.isNotEmpty(),
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
colors = androidx.compose.material3.IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
),
|
||||
) { Icon(Icons.Filled.Call, "Call") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KeypadButton(digit: String, modifier: Modifier, onClick: () -> Unit) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
) {
|
||||
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(digit, style = MaterialTheme.typography.titleLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContactsList(context: Context) {
|
||||
val contacts by produceState(emptyList<Contact>(), context) {
|
||||
value = withContext(Dispatchers.IO) { loadContacts(context) }
|
||||
}
|
||||
var query by remember { mutableStateOf("") }
|
||||
var searching by remember { mutableStateOf(false) }
|
||||
var pendingCall by remember { mutableStateOf<Contact?>(null) }
|
||||
|
||||
val filtered = remember(contacts, query) {
|
||||
if (query.isBlank()) contacts
|
||||
else contacts.filter { it.name.contains(query, ignoreCase = true) || it.number.contains(query) }
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (contacts.isNotEmpty()) {
|
||||
ContactSearchField(query, searching, onToggle = { searching = !searching })
|
||||
}
|
||||
// The list (and its empty states) sit between the search field and the keyboard, never
|
||||
// under either - the keyboard is docked at the bottom instead of jammed in right below
|
||||
// the field, so results stay visible while typing rather than getting squeezed out.
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||
when {
|
||||
contacts.isEmpty() -> Text(
|
||||
"No contacts with a phone number found.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
filtered.isEmpty() -> Text(
|
||||
"No contacts match \"$query\".",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
else -> LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(filtered) { contact ->
|
||||
ContactRow(contact) { pendingCall = contact }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (searching) {
|
||||
CarKeyboard(query, onTextChange = { query = it }, onSubmit = { searching = false })
|
||||
}
|
||||
}
|
||||
|
||||
pendingCall?.let { contact ->
|
||||
ConfirmCallOverlay(
|
||||
contact = contact,
|
||||
onConfirm = { placeCall(context, contact.number); pendingCall = null },
|
||||
onDismiss = { pendingCall = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A plain Compose overlay, not [androidx.compose.material3.AlertDialog] - that opens a real
|
||||
* Android Dialog/window, and this UI lives in a Presentation on a virtual display where a second
|
||||
* window of the wrong type crashes outright ("Window type mismatch"). Every other popup in the
|
||||
* car UI is drawn in-composition for the same reason; this just follows suit.
|
||||
*/
|
||||
@Composable
|
||||
private fun ConfirmCallOverlay(contact: Contact, onConfirm: () -> Unit, onDismiss: () -> Unit) {
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.6f)).clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
onClick = onDismiss,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Surface(
|
||||
onClick = {},
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
modifier = Modifier.fillMaxWidth(0.85f).padding(16.dp),
|
||||
) {
|
||||
Column(
|
||||
Modifier.padding(32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text("Call ${contact.name}?", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||
Text(contact.number, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
OutlinedButton(onClick = onDismiss, modifier = Modifier.weight(1f).height(56.dp)) {
|
||||
Text("Cancel", style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
Button(onClick = onConfirm, modifier = Modifier.weight(1f).height(56.dp)) {
|
||||
Text("Call", style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContactSearchField(query: String, active: Boolean, onToggle: () -> Unit) {
|
||||
Surface(
|
||||
onClick = onToggle,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = if (active) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
query.ifEmpty { "Search contacts" },
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (query.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContactRow(contact: Contact, onClick: () -> Unit) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Box(
|
||||
Modifier.size(40.dp).background(MaterialTheme.colorScheme.primaryContainer, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
contact.name.take(1).uppercase(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(contact.name, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(contact.number, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Icon(Icons.Filled.Call, contentDescription = "Call", tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import android.content.Context
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.mirror.PhoneApps
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* Loads what the car screens show before anyone opens them: the car and media apps with their
|
||||
* icons, the phone's launcher apps, the weather and the calendar. Each screen paints from these
|
||||
* results at once and refreshes behind them, so nothing waits on the package manager, the
|
||||
* network or the calendar provider.
|
||||
*
|
||||
* Safe to call as often as you like: calls closer together than [MIN_GAP_MS] are dropped.
|
||||
*/
|
||||
object Prefetch {
|
||||
|
||||
private const val MIN_GAP_MS = 60_000L
|
||||
|
||||
private val worker = Executors.newSingleThreadExecutor { Thread(it, "gearslip-prefetch").apply { isDaemon = true } }
|
||||
@Volatile private var lastRun = 0L
|
||||
|
||||
/** [force] skips the throttle; use it when something changed, such as a permission grant. */
|
||||
fun warm(context: Context, force: Boolean = false) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (!force && now - lastRun < MIN_GAP_MS) return
|
||||
lastRun = now
|
||||
val app = context.applicationContext
|
||||
worker.execute {
|
||||
val start = System.currentTimeMillis()
|
||||
step("launcher apps") { warmLauncherApps(app) }
|
||||
step("phone apps") { PhoneApps.warm(app) }
|
||||
step("weather and calendar") { warmDashboard(app) }
|
||||
GearslipLog.i("prefetch done in ${System.currentTimeMillis() - start}ms")
|
||||
}
|
||||
}
|
||||
|
||||
// One failing loader must not stop the others.
|
||||
private fun step(name: String, block: () -> Unit) {
|
||||
runCatching(block).onFailure { GearslipLog.w("prefetch: $name failed: ${it.message}") }
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import android.graphics.Rect
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -10,28 +13,46 @@ import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.car.app.model.CarIcon
|
||||
import androidx.car.app.model.Template
|
||||
import androidx.car.app.model.PlaceListMapTemplate
|
||||
import androidx.car.app.navigation.model.Lane
|
||||
import androidx.car.app.navigation.model.LaneDirection
|
||||
import androidx.car.app.navigation.model.MapTemplate
|
||||
import androidx.car.app.navigation.model.MapWithContentTemplate
|
||||
import androidx.car.app.navigation.model.NavigationTemplate
|
||||
import androidx.car.app.navigation.model.PlaceListNavigationTemplate
|
||||
import androidx.car.app.navigation.model.RoutePreviewNavigationTemplate
|
||||
import androidx.car.app.navigation.model.RoutingInfo
|
||||
import androidx.car.app.navigation.model.Step
|
||||
import androidx.car.app.navigation.model.TravelEstimate
|
||||
import app.seb3thehacker.gearslip.host.clockTime
|
||||
import app.seb3thehacker.gearslip.host.color
|
||||
import app.seb3thehacker.gearslip.host.image
|
||||
import app.seb3thehacker.gearslip.host.display
|
||||
import app.seb3thehacker.gearslip.host.remainingTime
|
||||
import app.seb3thehacker.gearslip.host.text
|
||||
@@ -82,17 +103,18 @@ fun visibleAreaFor(template: Template?, width: Int, height: Int): Rect = when (t
|
||||
|
||||
@Composable
|
||||
private fun NavigationChrome(template: NavigationTemplate, modifier: Modifier) {
|
||||
val pan by CarServices.nav.panMode.collectAsState()
|
||||
Box(modifier.fillMaxWidth()) {
|
||||
(template.navigationInfo as? RoutingInfo)?.let {
|
||||
if (!pan) (template.navigationInfo as? RoutingInfo)?.let {
|
||||
RoutingCard(it, Modifier.align(Alignment.TopStart).padding(12.dp).width(320.dp))
|
||||
}
|
||||
RightEdgeControls(
|
||||
template.actionStrip,
|
||||
if (pan) null else template.actionStrip,
|
||||
template.mapActionStrip,
|
||||
Modifier.align(Alignment.TopEnd).padding(12.dp),
|
||||
)
|
||||
template.destinationTravelEstimate?.let {
|
||||
TravelEstimateBar(it, Modifier.align(Alignment.BottomStart))
|
||||
if (!pan) template.destinationTravelEstimate?.let {
|
||||
TravelEstimateBar(it, Modifier.align(Alignment.BottomStart).padding(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,7 +124,7 @@ private fun NavigationChrome(template: NavigationTemplate, modifier: Modifier) {
|
||||
private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) {
|
||||
if (info.isLoading) {
|
||||
ChromeSurface(modifier) {
|
||||
Text("Finding a route…", Modifier.padding(16.dp), style = MaterialTheme.typography.titleMedium)
|
||||
Text("Finding a route…", Modifier.padding(16.dp), style = ChromeType.title)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -116,89 +138,174 @@ private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
val distance = info.currentDistance.display()
|
||||
if (distance.isNotEmpty()) {
|
||||
Text(
|
||||
distance,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(distance, style = ChromeType.headline)
|
||||
}
|
||||
Text(
|
||||
step.cue.text(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(step.cue.text(), style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
val road = step.road.text()
|
||||
if (road.isNotEmpty()) {
|
||||
Text(
|
||||
road,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
style = ChromeType.body,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
// Neither is sent by every app - most say only "turn left" and trust the driver to
|
||||
// find the lane themselves - so both are drawn only when there is something to draw.
|
||||
info.junctionImage?.let { junction ->
|
||||
Spacer(Modifier.height(10.dp))
|
||||
JunctionImage(junction)
|
||||
}
|
||||
LaneGuidance(step, Modifier.padding(top = 10.dp))
|
||||
info.nextStep?.let { next ->
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"then",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text("then", style = ChromeType.label, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
CarGlyph(next.maneuver?.icon, Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
next.cue.text(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(next.cue.text(), style = ChromeType.body, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Time and distance left, along the bottom where it stays out of the map's way. */
|
||||
/** The zoomed-in intersection an app draws for a junction too complex for an arrow to explain. */
|
||||
@Composable
|
||||
private fun JunctionImage(icon: CarIcon, modifier: Modifier = Modifier) {
|
||||
val bitmap = icon.image(LocalContext.current) ?: return
|
||||
Image(
|
||||
bitmap,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = modifier.fillMaxWidth().heightIn(max = 120.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Which lane to be in before the manoeuvre, either as the app's own picture ([Step.getLanesImage])
|
||||
* or as a row of arrows built from its structured lane data - whichever it sent. The recommended
|
||||
* lane, or lanes, stand out in the theme's own colour; the rest sit on the same muted, solid tile
|
||||
* everything else uses, never dimmed by transparency.
|
||||
*/
|
||||
@Composable
|
||||
private fun LaneGuidance(step: Step, modifier: Modifier = Modifier) {
|
||||
step.lanesImage?.let {
|
||||
JunctionImage(it, modifier)
|
||||
return
|
||||
}
|
||||
val lanes = step.lanes.orEmpty()
|
||||
if (lanes.isEmpty()) return
|
||||
Row(modifier, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
lanes.forEach { lane -> LaneTile(lane) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LaneTile(lane: Lane) {
|
||||
val directions = lane.directions.orEmpty()
|
||||
val shown = directions.firstOrNull { it.isRecommended } ?: directions.firstOrNull()
|
||||
val recommended = shown?.isRecommended == true
|
||||
Box(
|
||||
Modifier
|
||||
.size(36.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(
|
||||
if (recommended) MaterialTheme.colorScheme.primaryContainer
|
||||
else MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.KeyboardArrowUp,
|
||||
contentDescription = if (recommended) "Recommended lane" else "Lane",
|
||||
tint = if (recommended) MaterialTheme.colorScheme.onPrimaryContainer
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(24.dp).rotate(shown.turnDegrees()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The one arrow glyph this app draws, turned to stand in for every shape a lane can permit. */
|
||||
private fun LaneDirection?.turnDegrees(): Float = when (this?.shape) {
|
||||
LaneDirection.SHAPE_SLIGHT_RIGHT -> 30f
|
||||
LaneDirection.SHAPE_NORMAL_RIGHT -> 90f
|
||||
LaneDirection.SHAPE_SHARP_RIGHT -> 135f
|
||||
LaneDirection.SHAPE_U_TURN_RIGHT -> 170f
|
||||
LaneDirection.SHAPE_SLIGHT_LEFT -> -30f
|
||||
LaneDirection.SHAPE_NORMAL_LEFT -> -90f
|
||||
LaneDirection.SHAPE_SHARP_LEFT -> -135f
|
||||
LaneDirection.SHAPE_U_TURN_LEFT -> -170f
|
||||
else -> 0f
|
||||
}
|
||||
|
||||
/**
|
||||
* The trip at a glance: time and distance left, the clock time it ends at, and - beside them,
|
||||
* where a thumb reaches - the app's own way to stop navigating. A floating card rather than a
|
||||
* full-width bar, so it reads as one thing beside the manoeuvre card above it instead of a strip
|
||||
* that happens to hold three numbers. The close button is Gearslip's own, not the app's: an
|
||||
* actionStrip's actions carry no flag saying which one (if any) means "stop navigating" - the
|
||||
* first button here turned out to be a mute toggle, not an exit - so this leaves the app the way
|
||||
* going Home already does, rather than guess at the app's own controls and risk getting it wrong.
|
||||
*/
|
||||
@Composable
|
||||
private fun TravelEstimateBar(estimate: TravelEstimate, modifier: Modifier = Modifier) {
|
||||
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
|
||||
val time = remainingTime(estimate.remainingTimeSeconds)
|
||||
val distance = estimate.remainingDistance.display()
|
||||
val arrival = estimate.arrivalTimeAtDestination.clockTime()
|
||||
val trip = estimate.tripText.text()
|
||||
if (time.isEmpty() && distance.isEmpty() && trip.isEmpty()) return
|
||||
if (time.isEmpty() && distance.isEmpty() && arrival.isEmpty() && trip.isEmpty()) return
|
||||
|
||||
Surface(
|
||||
color = Color.Black.copy(alpha = 0.6f),
|
||||
contentColor = Color.White,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
val detail = listOfNotNull(
|
||||
arrival.takeIf { it.isNotEmpty() },
|
||||
distance.takeIf { it.isNotEmpty() },
|
||||
trip.takeIf { it.isNotEmpty() },
|
||||
).joinToString(" • ")
|
||||
|
||||
// The same solid card the manoeuvre card above it uses - one surface for the whole map
|
||||
// chrome, rather than this one spot styled on its own as a bar over the map.
|
||||
ChromeSurface(modifier, shadowElevation = 8.dp) {
|
||||
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
|
||||
Row(
|
||||
Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
Modifier.padding(start = 8.dp, end = 18.dp, top = 10.dp, bottom = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
if (time.isNotEmpty()) {
|
||||
Text(
|
||||
time,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = estimate.remainingTimeColor.color(dark, Color.White),
|
||||
Box(
|
||||
Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
||||
// Not a disconnect: the connection stays up and the app decides its own next
|
||||
// screen. Ending navigation is its own call, separate from a back press - a
|
||||
// back press only pops the on-screen card, leaving the app still believing it
|
||||
// is navigating (so a freshly picked destination silently does nothing); this
|
||||
// is the call that actually clears that flag on the app's side.
|
||||
.clickable { CarServices.nav.stopNavigating() },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Close,
|
||||
contentDescription = "Stop navigating",
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
if (distance.isNotEmpty()) {
|
||||
Text(
|
||||
distance,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = estimate.remainingDistanceColor.color(dark, Color.White),
|
||||
)
|
||||
}
|
||||
if (trip.isNotEmpty()) {
|
||||
Text(trip, style = MaterialTheme.typography.bodyMedium, maxLines = 1)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column {
|
||||
if (time.isNotEmpty()) {
|
||||
Text(
|
||||
time,
|
||||
style = ChromeType.headline,
|
||||
color = estimate.remainingTimeColor.color(dark, MaterialTheme.colorScheme.onSurface),
|
||||
)
|
||||
}
|
||||
if (detail.isNotEmpty()) {
|
||||
Text(detail, style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -207,25 +314,39 @@ private fun TravelEstimateBar(estimate: TravelEstimate, modifier: Modifier = Mod
|
||||
// --- map with a side pane ----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The shape every remaining map-backed template shares: a pane down the left, the app's map
|
||||
* showing through on the right, and both action strips stacked at the right edge.
|
||||
* The shape every remaining map-backed template shares: the app's map across the whole surface,
|
||||
* the template's content in a card floating over its left side, and both action strips stacked at
|
||||
* the right edge. The card is as tall as its content, up to the height of the screen, so a
|
||||
* handful of buttons no longer claim a whole column of the map. [fullHeight] is for content that
|
||||
* needs the room, such as a search field with its keyboard.
|
||||
*/
|
||||
@Composable
|
||||
private fun MapPaneLayout(
|
||||
modifier: Modifier,
|
||||
actionStrip: androidx.car.app.model.ActionStrip?,
|
||||
mapActionStrip: androidx.car.app.model.ActionStrip?,
|
||||
pane: @Composable (Modifier) -> Unit,
|
||||
fullHeight: Boolean = false,
|
||||
pane: @Composable () -> Unit,
|
||||
) {
|
||||
Box(modifier.fillMaxWidth()) {
|
||||
pane(
|
||||
Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth(CONTENT_PANE_FRACTION),
|
||||
)
|
||||
// Pan mode hands the whole screen to the map, leaving only its own controls.
|
||||
val pan by CarServices.nav.panMode.collectAsState()
|
||||
val edgeActions = (actionStrip?.actions.orEmpty() + mapActionStrip?.actions.orEmpty()).map { it.identity() }.toSet()
|
||||
androidx.compose.foundation.layout.BoxWithConstraints(modifier.fillMaxWidth()) {
|
||||
if (!pan) {
|
||||
ChromeSurface(
|
||||
Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(12.dp)
|
||||
.width(maxOf(maxWidth * CONTENT_PANE_FRACTION - 12.dp, 232.dp))
|
||||
.let { if (fullHeight) it.height(maxHeight - 24.dp) else it.heightIn(max = maxHeight - 24.dp) },
|
||||
shape = androidx.compose.foundation.shape.RoundedCornerShape(20.dp),
|
||||
shadowElevation = 8.dp,
|
||||
) {
|
||||
androidx.compose.runtime.CompositionLocalProvider(LocalMapEdgeActions provides edgeActions) { pane() }
|
||||
}
|
||||
}
|
||||
RightEdgeControls(
|
||||
actionStrip,
|
||||
if (pan) null else actionStrip,
|
||||
mapActionStrip,
|
||||
Modifier.align(Alignment.TopEnd).padding(12.dp),
|
||||
)
|
||||
@@ -238,8 +359,12 @@ private fun MapWithContentChrome(template: MapWithContentTemplate, modifier: Mod
|
||||
modifier,
|
||||
template.actionStrip,
|
||||
template.mapController?.mapActionStrip,
|
||||
) { paneModifier ->
|
||||
ContentTemplate(template.contentTemplate, paneModifier)
|
||||
fullHeight = template.contentTemplate.needsFullHeight(),
|
||||
) {
|
||||
// A pane floating over the map is a place or a route the app is proposing, so its one
|
||||
// action - Start - counts itself down rather than waiting to be pressed; the same pane
|
||||
// filling a whole screen elsewhere gets no such push.
|
||||
ContentTemplate(template.contentTemplate, Modifier, autoStartPane = true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,15 +374,15 @@ private fun MapChrome(template: MapTemplate, modifier: Modifier) {
|
||||
modifier,
|
||||
template.actionStrip,
|
||||
template.mapController?.mapActionStrip,
|
||||
) { paneModifier ->
|
||||
ChromeSurface(paneModifier, shape = androidx.compose.foundation.shape.RoundedCornerShape(0.dp)) {
|
||||
) {
|
||||
ChromeSurface(Modifier, shape = androidx.compose.foundation.shape.RoundedCornerShape(0.dp)) {
|
||||
Column {
|
||||
HeaderBar(
|
||||
headerTitle(template.header, ""),
|
||||
template.header?.startHeaderAction,
|
||||
template.header?.endHeaderActions.orEmpty(),
|
||||
)
|
||||
if (template.pane != null) PaneRows(template.pane)
|
||||
if (template.pane != null) PaneRows(template.pane, autoStart = true)
|
||||
else ItemListColumn(template.itemList)
|
||||
}
|
||||
}
|
||||
@@ -266,26 +391,26 @@ private fun MapChrome(template: MapTemplate, modifier: Modifier) {
|
||||
|
||||
@Composable
|
||||
private fun PlaceListNavigationChrome(template: PlaceListNavigationTemplate, modifier: Modifier) {
|
||||
MapPaneLayout(modifier, template.actionStrip, template.mapActionStrip) { paneModifier ->
|
||||
MapPaneLayout(modifier, template.actionStrip, template.mapActionStrip) {
|
||||
ListPane(
|
||||
headerTitle(template.header, template.title.text()),
|
||||
template.header?.startHeaderAction ?: template.headerAction,
|
||||
template.isLoading,
|
||||
template.itemList,
|
||||
paneModifier,
|
||||
Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlaceListMapChrome(template: PlaceListMapTemplate, modifier: Modifier) {
|
||||
MapPaneLayout(modifier, template.actionStrip, null) { paneModifier ->
|
||||
MapPaneLayout(modifier, template.actionStrip, null) {
|
||||
ListPane(
|
||||
template.title.text(),
|
||||
template.headerAction,
|
||||
template.isLoading,
|
||||
template.itemList,
|
||||
paneModifier,
|
||||
Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -293,23 +418,23 @@ private fun PlaceListMapChrome(template: PlaceListMapTemplate, modifier: Modifie
|
||||
/** Route choices down the side, with the app's "start" action pinned under them. */
|
||||
@Composable
|
||||
private fun RoutePreviewChrome(template: RoutePreviewNavigationTemplate, modifier: Modifier) {
|
||||
MapPaneLayout(modifier, template.actionStrip, template.mapActionStrip) { paneModifier ->
|
||||
ChromeSurface(paneModifier, shape = androidx.compose.foundation.shape.RoundedCornerShape(0.dp)) {
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
MapPaneLayout(modifier, template.actionStrip, template.mapActionStrip) {
|
||||
ChromeSurface(Modifier, shape = androidx.compose.foundation.shape.RoundedCornerShape(0.dp)) {
|
||||
Column {
|
||||
HeaderBar(
|
||||
headerTitle(template.header, template.title.text()),
|
||||
template.header?.startHeaderAction ?: template.headerAction,
|
||||
template.header?.endHeaderActions.orEmpty(),
|
||||
)
|
||||
Box(Modifier.weight(1f)) {
|
||||
Box(Modifier.weight(1f, fill = false)) {
|
||||
if (template.isLoading) {
|
||||
Text("Finding routes…", Modifier.padding(14.dp))
|
||||
Text("Finding routes…", Modifier.padding(14.dp), style = ChromeType.title)
|
||||
} else {
|
||||
ItemListColumn(template.itemList)
|
||||
}
|
||||
}
|
||||
template.navigateAction?.let {
|
||||
ActionRow(listOf(it), Modifier.padding(12.dp))
|
||||
AutoStartButton(it, Modifier.padding(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -327,7 +452,7 @@ private fun ListPane(
|
||||
ChromeSurface(modifier, shape = androidx.compose.foundation.shape.RoundedCornerShape(0.dp)) {
|
||||
Column {
|
||||
HeaderBar(title, startAction)
|
||||
if (loading) Text("Loading…", Modifier.padding(14.dp))
|
||||
if (loading) Text("Loading…", Modifier.padding(14.dp), style = ChromeType.title)
|
||||
else ItemListColumn(itemList)
|
||||
}
|
||||
}
|
||||
@@ -338,3 +463,8 @@ private const val CONTENT_PANE_FRACTION = 0.42f
|
||||
|
||||
/** Fraction of the surface the travel-estimate bar covers along the bottom. */
|
||||
private const val ESTIMATE_FRACTION = 0.14f
|
||||
|
||||
/** Search, sign-in and the like are laid out for a whole column, so they keep one. */
|
||||
private fun Template?.needsFullHeight() =
|
||||
this is androidx.car.app.model.SearchTemplate || this is androidx.car.app.model.signin.SignInTemplate ||
|
||||
this is androidx.car.app.model.TabTemplate || this is androidx.car.app.model.LongMessageTemplate
|
||||
@@ -27,6 +27,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.car.app.model.GridTemplate
|
||||
@@ -51,6 +52,7 @@ import androidx.car.app.media.model.MediaPlaybackTemplate
|
||||
import androidx.car.app.OnDoneCallback
|
||||
import androidx.car.app.serialization.Bundleable
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.host.isDrawable
|
||||
import app.seb3thehacker.gearslip.host.inputChanged
|
||||
import app.seb3thehacker.gearslip.host.inputSubmitted
|
||||
import app.seb3thehacker.gearslip.host.submitted
|
||||
@@ -65,12 +67,12 @@ import app.seb3thehacker.gearslip.host.textChanged
|
||||
* the same function either way - the only difference is how much room they get.
|
||||
*/
|
||||
@Composable
|
||||
fun ContentTemplate(template: Template?, modifier: Modifier = Modifier) {
|
||||
fun ContentTemplate(template: Template?, modifier: Modifier = Modifier, autoStartPane: Boolean = false) {
|
||||
when (template) {
|
||||
null -> Unit
|
||||
is ListTemplate -> ListContent(template, modifier)
|
||||
is GridTemplate -> GridContent(template, modifier)
|
||||
is PaneTemplate -> PaneContent(template, modifier)
|
||||
is PaneTemplate -> PaneContent(template, modifier, autoStartPane)
|
||||
is MessageTemplate -> MessageContent(template, modifier)
|
||||
is LongMessageTemplate -> LongMessageContent(template, modifier)
|
||||
is SearchTemplate -> SearchContent(template, modifier)
|
||||
@@ -103,7 +105,10 @@ private fun ListContent(template: ListTemplate, modifier: Modifier) {
|
||||
template.actionStrip,
|
||||
)
|
||||
if (template.isLoading) Loading()
|
||||
else ItemListColumn(template.singleList, template.sectionedLists?.map { it.itemList })
|
||||
else ItemListColumn(
|
||||
template.singleList, template.sectionedLists?.map { it.itemList },
|
||||
Modifier.weight(1f, fill = false),
|
||||
)
|
||||
ActionRow(template.actions.orEmpty(), Modifier.padding(12.dp))
|
||||
}
|
||||
}
|
||||
@@ -117,13 +122,13 @@ private fun GridContent(template: GridTemplate, modifier: Modifier) {
|
||||
template.header?.endHeaderActions.orEmpty(),
|
||||
template.actionStrip,
|
||||
)
|
||||
if (template.isLoading) Loading() else GridItems(template.singleList)
|
||||
if (template.isLoading) Loading() else GridItems(template.singleList, Modifier.weight(1f, fill = false))
|
||||
ActionRow(template.actions.orEmpty(), Modifier.padding(12.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PaneContent(template: PaneTemplate, modifier: Modifier) {
|
||||
private fun PaneContent(template: PaneTemplate, modifier: Modifier, autoStart: Boolean = false) {
|
||||
ContentSurface(modifier) {
|
||||
HeaderBar(
|
||||
headerTitle(template.header, template.title.text()),
|
||||
@@ -131,7 +136,7 @@ private fun PaneContent(template: PaneTemplate, modifier: Modifier) {
|
||||
template.header?.endHeaderActions.orEmpty(),
|
||||
template.actionStrip,
|
||||
)
|
||||
if (template.pane?.isLoading == true) Loading() else PaneRows(template.pane)
|
||||
if (template.pane?.isLoading == true) Loading() else PaneRows(template.pane, autoStart = autoStart)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,27 +145,45 @@ private fun PaneContent(template: PaneTemplate, modifier: Modifier) {
|
||||
@Composable
|
||||
private fun MessageContent(template: MessageTemplate, modifier: Modifier) {
|
||||
ContentSurface(modifier) {
|
||||
HeaderBar(
|
||||
headerTitle(template.header, template.title.text()),
|
||||
template.header?.startHeaderAction ?: template.headerAction,
|
||||
template.header?.endHeaderActions.orEmpty(),
|
||||
template.actionStrip,
|
||||
)
|
||||
// The header carries only the back arrow, not a title - a message template's own title
|
||||
// is usually the long-form question ("Would you like to download the map...") that
|
||||
// reads fine centred in the body but doesn't fit a one-line header without truncating.
|
||||
val backAction = template.header?.startHeaderAction ?: template.headerAction
|
||||
HeaderBar("", backAction, template.header?.endHeaderActions.orEmpty(), template.actionStrip)
|
||||
if (template.isLoading) {
|
||||
Loading()
|
||||
return@ContentSurface
|
||||
}
|
||||
Row(
|
||||
Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
// A back arrow already undoes this screen, so an app's own "Cancel" button would just
|
||||
// be a second way to do the one thing the header already offers.
|
||||
val actions = template.actions.orEmpty().filter { it.isDrawable() }.let { list ->
|
||||
if (backAction != null) list.filterNot { it.title.text().equals("Cancel", ignoreCase = true) } else list
|
||||
}
|
||||
Column(
|
||||
Modifier.weight(1f).fillMaxWidth().padding(24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
template.icon?.let {
|
||||
CarGlyph(it, Modifier.size(32.dp))
|
||||
Spacer(Modifier.width(12.dp))
|
||||
CarGlyph(it, Modifier.size(40.dp))
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
Text(template.title.text(), style = ChromeType.title, textAlign = TextAlign.Center)
|
||||
val detail = template.message.text()
|
||||
if (detail.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
detail,
|
||||
style = ChromeType.body,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (actions.isNotEmpty()) {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
ActionRow(actions, Modifier.fillMaxWidth(0.6f), large = true)
|
||||
}
|
||||
Text(template.message.text(), style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
ActionRow(template.actions.orEmpty(), Modifier.padding(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,7 +201,7 @@ private fun LongMessageContent(template: LongMessageTemplate, modifier: Modifier
|
||||
)
|
||||
Text(
|
||||
template.message.text(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
style = ChromeType.body,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.verticalScroll(rememberScrollState())
|
||||
@@ -242,7 +265,7 @@ private fun SearchField(query: String, hint: String, modifier: Modifier = Modifi
|
||||
) {
|
||||
Text(
|
||||
query.ifEmpty { hint.ifEmpty { "Search" } },
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
style = ChromeType.body,
|
||||
color = if (query.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant
|
||||
else MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
@@ -276,7 +299,7 @@ private fun SignInContent(template: SignInTemplate, modifier: Modifier) {
|
||||
if (instructions.isNotEmpty()) {
|
||||
Text(
|
||||
instructions,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
style = ChromeType.body,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
@@ -295,7 +318,7 @@ private fun SignInContent(template: SignInTemplate, modifier: Modifier) {
|
||||
if (additional.isNotEmpty()) {
|
||||
Text(
|
||||
additional,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
style = ChromeType.label,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
@@ -316,7 +339,7 @@ private fun InputSignIn(method: InputSignInMethod, modifier: Modifier) {
|
||||
if (error.isNotEmpty()) {
|
||||
Text(
|
||||
error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
style = ChromeType.label,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
@@ -340,8 +363,7 @@ private fun Callout(value: String, modifier: Modifier) {
|
||||
Box(modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
value,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
style = ChromeType.headline,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
)
|
||||
}
|
||||
@@ -382,7 +404,7 @@ private fun TabContent(template: TabTemplate, modifier: Modifier) {
|
||||
CarGlyph(it, Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
}
|
||||
Text(tab.title.text(), style = MaterialTheme.typography.labelLarge, maxLines = 1)
|
||||
Text(tab.title.text(), style = ChromeType.label, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -447,7 +469,7 @@ private fun SectionRows(section: Section<*>) {
|
||||
|
||||
if (items.isEmpty()) {
|
||||
val message = section.noItemsMessage.text()
|
||||
if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp))
|
||||
if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp), style = ChromeType.body)
|
||||
return
|
||||
}
|
||||
items.filterIsInstance<CarRow>().forEach { RowItem(it) }
|
||||
@@ -471,7 +493,7 @@ private fun MediaPlaybackContent(template: MediaPlaybackTemplate, modifier: Modi
|
||||
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
"Playback controls come from the app's media session,\nwhich Gearslip doesn't carry yet.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
style = ChromeType.body,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
)
|
||||
@@ -484,7 +506,7 @@ private fun MediaPlaybackContent(template: MediaPlaybackTemplate, modifier: Modi
|
||||
@Composable
|
||||
private fun ContentSurface(modifier: Modifier, content: @Composable ColumnScopeAlias.() -> Unit) {
|
||||
ChromeSurface(modifier, shape = RoundedCornerShape(0.dp)) {
|
||||
Column(Modifier.fillMaxSize(), content = content)
|
||||
Column(Modifier.fillMaxWidth(), content = content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,6 +515,6 @@ private typealias ColumnScopeAlias = androidx.compose.foundation.layout.ColumnSc
|
||||
@Composable
|
||||
private fun Loading() {
|
||||
Box(Modifier.fillMaxWidth().height(80.dp), contentAlignment = Alignment.Center) {
|
||||
Text("Loading…", style = MaterialTheme.typography.bodyLarge)
|
||||
Text("Loading…", style = ChromeType.title)
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,14 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -30,6 +32,21 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import app.seb3thehacker.gearslip.host.selected
|
||||
import app.seb3thehacker.gearslip.host.checkedChanged
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -67,17 +84,21 @@ import app.seb3thehacker.gearslip.host.text
|
||||
/**
|
||||
* The app keeps drawing its map across the whole surface - the visible area we report tells it
|
||||
* where to put its own markers, not where to stop painting. So anything the host lays over the
|
||||
* map is opaque by default, or the map reads through it.
|
||||
* map is opaque, always, in the same one colour: a card that lets the map show through in places
|
||||
* reads as broken glass, not as a deliberate choice, and a driver has no way to tell which was
|
||||
* meant. There is no alpha knob here on purpose - every card, bar and strip in the template
|
||||
* chrome shares this surface, so nothing drawn over a map is ever see-through.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ChromeSurface(
|
||||
modifier: Modifier = Modifier,
|
||||
shape: Shape = RoundedCornerShape(16.dp),
|
||||
alpha: Float = 1f,
|
||||
shadowElevation: androidx.compose.ui.unit.Dp = 0.dp,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = alpha),
|
||||
shadowElevation = shadowElevation,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
shape = shape,
|
||||
modifier = modifier,
|
||||
@@ -85,6 +106,32 @@ internal fun ChromeSurface(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The one type scale for everything a templated app's screen draws. Apps hand the host plain
|
||||
* strings with no size of their own - the Car App Library deliberately keeps that decision out of
|
||||
* their hands - so without a shared scale, each template's text ends up whatever size felt right
|
||||
* when that template was written, and two apps' otherwise-identical rows read as different sizes
|
||||
* of importance. Every car chrome file should reach for one of these four rather than naming a
|
||||
* Material size directly.
|
||||
*/
|
||||
internal object ChromeType {
|
||||
/** The one number or word a card exists to show: distance to a turn, minutes left, a PIN. */
|
||||
val headline: androidx.compose.ui.text.TextStyle
|
||||
@Composable get() = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Bold)
|
||||
|
||||
/** A row's name, a header, a manoeuvre's instruction - the line the rest is supporting. */
|
||||
val title: androidx.compose.ui.text.TextStyle
|
||||
@Composable get() = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold)
|
||||
|
||||
/** Supporting detail: a road, an address, a message's body, a second line under a title. */
|
||||
val body: androidx.compose.ui.text.TextStyle
|
||||
@Composable get() = MaterialTheme.typography.titleMedium
|
||||
|
||||
/** The smallest text Gearslip draws over a map - still large enough to read while moving. */
|
||||
val label: androidx.compose.ui.text.TextStyle
|
||||
@Composable get() = MaterialTheme.typography.bodyLarge
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun UnsupportedChrome(template: Template, modifier: Modifier = Modifier) {
|
||||
androidx.compose.foundation.layout.Box(modifier, contentAlignment = Alignment.Center) {
|
||||
@@ -92,7 +139,7 @@ internal fun UnsupportedChrome(template: Template, modifier: Modifier = Modifier
|
||||
Text(
|
||||
"${template.javaClass.simpleName} isn't drawn yet",
|
||||
Modifier.padding(16.dp),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
style = ChromeType.title,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -109,7 +156,12 @@ internal fun HeaderBar(
|
||||
endActions: List<Action> = emptyList(),
|
||||
actionStrip: ActionStrip? = null,
|
||||
) {
|
||||
val end = (endActions + actionStrip?.actions.orEmpty()).filter { it.isDrawable() }
|
||||
// Beside a map the action strip already sits at the right edge, so the header must not repeat it;
|
||||
// and an app that lists the same action in two places should still see it once.
|
||||
val onMap = LocalMapEdgeActions.current
|
||||
val end = (endActions + if (onMap != null) emptyList() else actionStrip?.actions.orEmpty())
|
||||
.filter { it.isDrawable() && (onMap == null || it.identity() !in onMap) }
|
||||
.distinctBy { it.identity() }
|
||||
if (title.isEmpty() && startAction == null && end.isEmpty()) return
|
||||
|
||||
Row(
|
||||
@@ -120,8 +172,8 @@ internal fun HeaderBar(
|
||||
startAction?.takeIf { it.isDrawable() }?.let { ActionButton(it) }
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
style = ChromeType.title,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
@@ -130,6 +182,22 @@ internal fun HeaderBar(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set inside the card beside a map to what the map's right edge already shows: the action strip is
|
||||
* drawn there rather than in the header, and the header leaves out anything the edge repeats.
|
||||
* Null anywhere else.
|
||||
*/
|
||||
internal val LocalMapEdgeActions = androidx.compose.runtime.compositionLocalOf<Set<String>?> { null }
|
||||
|
||||
/** What makes two actions the same button: what they do and what they look like. */
|
||||
internal fun Action.identity(): String {
|
||||
val icon = icon?.icon
|
||||
val image = if (icon != null && icon.type == androidx.core.graphics.drawable.IconCompat.TYPE_RESOURCE) {
|
||||
"${icon.resPackage}:${icon.resId}"
|
||||
} else icon?.toString().orEmpty()
|
||||
return "$type|${title.text()}|$image"
|
||||
}
|
||||
|
||||
/** Reads a header whichever way the template carries it. */
|
||||
internal fun headerTitle(header: Header?, fallback: String): String =
|
||||
header?.title.text().ifEmpty { fallback }
|
||||
@@ -150,8 +218,35 @@ internal fun RightEdgeControls(
|
||||
val actions = actionStrip?.actions.orEmpty().filter { it.isDrawable() }
|
||||
val mapActions = mapActionStrip?.actions.orEmpty().filter { it.isDrawable() }
|
||||
if (actions.isEmpty() && mapActions.isEmpty()) return
|
||||
// A template that stops offering Pan must not leave the screen stuck in pan mode.
|
||||
val hasPan = (actions + mapActions).any { it.type == Action.TYPE_PAN }
|
||||
androidx.compose.runtime.LaunchedEffect(hasPan) { if (!hasPan) CarServices.nav.setPanMode(false) }
|
||||
|
||||
Column(modifier, horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
// Faded out when the screen has been left alone; then they are only a memory of buttons, so
|
||||
// the first touch on one wakes them rather than pressing it.
|
||||
val awake = LocalMapControlsWake.current.awake
|
||||
val pan by CarServices.nav.panMode.collectAsState()
|
||||
val visible = awake || pan
|
||||
val alpha by androidx.compose.animation.core.animateFloatAsState(
|
||||
targetValue = if (visible) 1f else 0f,
|
||||
animationSpec = androidx.compose.animation.core.tween(if (visible) 150 else 500),
|
||||
label = "mapControls",
|
||||
)
|
||||
Column(
|
||||
modifier
|
||||
.alpha(alpha)
|
||||
.let { base ->
|
||||
if (visible) base else base.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
awaitPointerEvent(PointerEventPass.Initial).changes.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
horizontalAlignment = Alignment.End,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (actions.isNotEmpty()) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
actions.forEach { ActionButton(it) }
|
||||
@@ -161,43 +256,169 @@ internal fun RightEdgeControls(
|
||||
}
|
||||
}
|
||||
|
||||
/** [large] is the pane's main call to action: it shares the card's full width, taller, in bigger type. */
|
||||
@Composable
|
||||
internal fun ActionRow(actions: List<Action>, modifier: Modifier = Modifier) {
|
||||
internal fun ActionRow(actions: List<Action>, modifier: Modifier = Modifier, large: Boolean = false) {
|
||||
val drawable = actions.filter { it.isDrawable() }
|
||||
if (drawable.isEmpty()) return
|
||||
Row(modifier, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
drawable.forEach { ActionButton(it) }
|
||||
Row(if (large) modifier.fillMaxWidth() else modifier, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
drawable.forEach { ActionButton(it, large = large, modifier = if (large) Modifier.weight(1f) else Modifier) }
|
||||
}
|
||||
}
|
||||
|
||||
/** A row with nothing to show: no words, no picture, nothing to tap. */
|
||||
private fun CarRow.isBlank(): Boolean =
|
||||
title.text().isBlank() && texts.orEmpty().all { it.text().isBlank() } && image == null &&
|
||||
toggle == null && onClickDelegate == null && actions.orEmpty().isEmpty()
|
||||
|
||||
/**
|
||||
* One look everywhere: solid, in the theme's own colors, whether the button sits on a card or over
|
||||
* the map. An app that names a color for its button (a green Start) keeps it.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ActionButton(action: Action) {
|
||||
internal fun ActionButton(action: Action, large: Boolean = false, modifier: Modifier = Modifier) {
|
||||
if (action.type == Action.TYPE_APP_ICON) {
|
||||
AppIconBadge()
|
||||
return
|
||||
}
|
||||
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
|
||||
val title = action.title.text()
|
||||
val background = action.backgroundColor.color(dark, Color.Black.copy(alpha = 0.6f))
|
||||
val panOn by CarServices.nav.panMode.collectAsState()
|
||||
val active = action.type == Action.TYPE_PAN && panOn
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val custom = action.backgroundColor?.type?.let { it != androidx.car.app.model.CarColor.TYPE_DEFAULT } == true
|
||||
val fallback = scheme.surfaceContainerHighest
|
||||
val background = if (active) scheme.primary else action.backgroundColor.color(dark, fallback)
|
||||
val content = when {
|
||||
active -> scheme.onPrimary
|
||||
custom -> Color.White
|
||||
else -> scheme.onSurface
|
||||
}
|
||||
val hasGlyph = action.icon != null || action.type != Action.TYPE_CUSTOM
|
||||
|
||||
Surface(
|
||||
color = background,
|
||||
contentColor = Color.White,
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
modifier = Modifier.clickable(enabled = action.isEnabled) {
|
||||
action.onClickDelegate.click("action")
|
||||
contentColor = content,
|
||||
shape = if (title.isEmpty()) androidx.compose.foundation.shape.CircleShape else RoundedCornerShape(if (large) 28.dp else 24.dp),
|
||||
modifier = modifier.clickable(enabled = action.isEnabled) {
|
||||
if (action.type == Action.TYPE_BACK) CarServices.nav.backPressed()
|
||||
else if (action.type == Action.TYPE_PAN) CarServices.nav.setPanMode(!panOn)
|
||||
else action.onClickDelegate?.click("action")
|
||||
},
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = if (title.isEmpty()) 10.dp else 14.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (hasGlyph) CarGlyph(action.icon, Modifier.size(24.dp), standardFor(action))
|
||||
if (title.isNotEmpty()) {
|
||||
if (large && title.isNotEmpty()) {
|
||||
// The label sits dead centre on the button; the icon hangs off the left edge, so the
|
||||
// pair's uneven visual weight can't pull the words off to one side.
|
||||
androidx.compose.foundation.layout.Box(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 18.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (hasGlyph) CarGlyph(action.icon, Modifier.size(30.dp).align(Alignment.CenterStart), standardFor(action))
|
||||
Text(title, style = ChromeType.title, maxLines = 1)
|
||||
}
|
||||
} else if (title.isEmpty()) {
|
||||
// Every icon-only button - back, settings, the map's zoom and locate - is the same size.
|
||||
androidx.compose.foundation.layout.Box(Modifier.size(ICON_BUTTON_SIZE), contentAlignment = Alignment.Center) {
|
||||
if (hasGlyph) CarGlyph(action.icon, Modifier.size(ICON_BUTTON_GLYPH), standardFor(action))
|
||||
}
|
||||
} else {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 18.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (hasGlyph) CarGlyph(action.icon, Modifier.size(24.dp), standardFor(action))
|
||||
if (hasGlyph) Spacer(Modifier.width(8.dp))
|
||||
Text(title, style = MaterialTheme.typography.labelLarge, maxLines = 1)
|
||||
Text(title, style = ChromeType.body, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A large action that fires on its own after five seconds, filling to show the countdown - for
|
||||
* the one button on a map-preview pane, where the app is proposing a single place or route and a
|
||||
* driver glancing at the car screen has already decided. Tapping it, same as any other button,
|
||||
* fires it at once; the fill is only ever a preview of what a touch already does.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AutoStartButton(action: Action, modifier: Modifier = Modifier) {
|
||||
var fraction by remember(action.identity()) { mutableStateOf(0f) }
|
||||
var fired by remember(action.identity()) { mutableStateOf(false) }
|
||||
fun fire() {
|
||||
if (fired) return
|
||||
fired = true
|
||||
action.onClickDelegate.click("auto-start")
|
||||
}
|
||||
androidx.compose.runtime.LaunchedEffect(action.identity()) {
|
||||
val steps = 60
|
||||
repeat(steps) { step ->
|
||||
kotlinx.coroutines.delay(AUTO_START_MS / steps)
|
||||
fraction = (step + 1) / steps.toFloat()
|
||||
}
|
||||
fire()
|
||||
}
|
||||
|
||||
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
|
||||
val custom = action.backgroundColor?.type?.let { it != androidx.car.app.model.CarColor.TYPE_DEFAULT } == true
|
||||
val background = action.backgroundColor.color(dark, MaterialTheme.colorScheme.primary)
|
||||
val content = if (custom) Color.White else MaterialTheme.colorScheme.onPrimary
|
||||
val hasGlyph = action.icon != null
|
||||
|
||||
Surface(
|
||||
// An unfilled track, solid, not the button's own colour dimmed with alpha - so the map
|
||||
// behind never shows through however much of the five seconds is left.
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
contentColor = content,
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
modifier = modifier.fillMaxWidth().clickable(enabled = action.isEnabled) { fire() },
|
||||
) {
|
||||
androidx.compose.foundation.layout.Box {
|
||||
// The countdown itself: a plain fill sweeping left to right underneath the label.
|
||||
// matchParentSize(), not fillMaxHeight() - this Box sits beside a label Box that
|
||||
// wraps its own content, and the outer Box sizes itself to its tallest child; a
|
||||
// plain fillMaxHeight() here has nothing bounded to fill up to but the whole screen,
|
||||
// which is what was stretching the whole button that tall. matchParentSize() instead
|
||||
// takes whatever size the label ends up being, without itself voting on what that is.
|
||||
androidx.compose.foundation.layout.Box(Modifier.matchParentSize()) {
|
||||
androidx.compose.foundation.layout.Box(
|
||||
Modifier
|
||||
.fillMaxWidth(fraction)
|
||||
.fillMaxHeight()
|
||||
.background(background),
|
||||
)
|
||||
}
|
||||
androidx.compose.foundation.layout.Box(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (hasGlyph) CarGlyph(action.icon, Modifier.size(24.dp).align(Alignment.CenterStart))
|
||||
Text(
|
||||
action.title.text().ifEmpty { "Start" },
|
||||
style = ChromeType.title,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val AUTO_START_MS = 5_000L
|
||||
|
||||
/** The connected app's own launcher icon, which is what the app-icon action stands for. */
|
||||
@Composable
|
||||
private fun AppIconBadge() {
|
||||
val context = LocalContext.current
|
||||
val pkg = CarServices.nav.appPackage
|
||||
val icon = remember(pkg) {
|
||||
pkg?.let {
|
||||
runCatching {
|
||||
context.packageManager.getApplicationIcon(it).toBitmap(96, 96).asImageBitmap()
|
||||
}.getOrNull()
|
||||
}
|
||||
} ?: return
|
||||
Image(icon, contentDescription = null, modifier = Modifier.size(36.dp).clip(RoundedCornerShape(10.dp)))
|
||||
}
|
||||
|
||||
/** A standard Action carries no icon of its own - the host is expected to supply the glyph. */
|
||||
private fun standardFor(action: Action): Int? = when (action.type) {
|
||||
Action.TYPE_BACK -> CarIcon.TYPE_BACK
|
||||
@@ -249,8 +470,7 @@ internal fun PaneTitle(title: String) {
|
||||
if (title.isEmpty()) return
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
style = ChromeType.title,
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
)
|
||||
}
|
||||
@@ -267,11 +487,16 @@ internal fun ItemListColumn(
|
||||
}
|
||||
if (rows.isEmpty()) {
|
||||
val message = single?.noItemsMessage.text()
|
||||
if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp))
|
||||
if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp), style = ChromeType.body)
|
||||
return
|
||||
}
|
||||
// A list the app made selectable is a single-choice list: one option chosen, shown as a radio.
|
||||
val onSelected = single?.onSelectedDelegate
|
||||
val chosen = single?.selectedIndex ?: -1
|
||||
LazyColumn(modifier, contentPadding = PaddingValues(bottom = 8.dp)) {
|
||||
items(rows) { RowItem(it) }
|
||||
itemsIndexed(rows) { index, row ->
|
||||
RowItem(row, onSelected?.let { RowSelection(index == chosen) { it.selected(index) } })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,9 +506,9 @@ internal fun GridItems(list: ItemList?, modifier: Modifier = Modifier) {
|
||||
val items = list?.items?.filterIsInstance<GridItem>().orEmpty()
|
||||
if (items.isEmpty()) return
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(96.dp),
|
||||
columns = GridCells.Adaptive(84.dp),
|
||||
modifier = modifier,
|
||||
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp),
|
||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
@@ -298,7 +523,7 @@ internal fun GridItems(list: ItemList?, modifier: Modifier = Modifier) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
item.title.text(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
style = ChromeType.label,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
@@ -306,7 +531,7 @@ internal fun GridItems(list: ItemList?, modifier: Modifier = Modifier) {
|
||||
if (subtitle.isNotEmpty()) {
|
||||
Text(
|
||||
subtitle,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
style = ChromeType.label,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
@@ -317,53 +542,91 @@ internal fun GridItems(list: ItemList?, modifier: Modifier = Modifier) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [autoStart] is for a pane floating over a map - a place or a route the app is proposing - where
|
||||
* a single action counts itself down rather than waiting to be pressed, on the theory that a
|
||||
* driver glancing at the car screen has already decided. It never applies to more than one
|
||||
* action: a pane offering a real choice (Start vs. something else) always waits to be tapped.
|
||||
*/
|
||||
@Composable
|
||||
internal fun PaneRows(pane: Pane?, modifier: Modifier = Modifier) {
|
||||
val rows = pane?.rows.orEmpty()
|
||||
internal fun PaneRows(pane: Pane?, modifier: Modifier = Modifier, autoStart: Boolean = false) {
|
||||
// Some apps pad a pane with a row of blank strings; drawn, it is just a gap.
|
||||
val rows = pane?.rows.orEmpty().filterNot { it.isBlank() }
|
||||
val actions = pane?.actions.orEmpty().filter { it.isDrawable() }
|
||||
Column(modifier) {
|
||||
rows.forEach { RowItem(it) }
|
||||
ActionRow(
|
||||
pane?.actions.orEmpty(),
|
||||
Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
)
|
||||
rows.forEach { RowItem(it, large = true) }
|
||||
if (autoStart && actions.size == 1) {
|
||||
AutoStartButton(actions.single(), Modifier.padding(horizontal = 14.dp, vertical = 10.dp))
|
||||
} else {
|
||||
ActionRow(actions, Modifier.padding(horizontal = 14.dp, vertical = 10.dp), large = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun RowItem(row: CarRow) {
|
||||
val clickable = row.onClickDelegate != null && row.isEnabled
|
||||
internal fun RowItem(row: CarRow, selection: RowSelection? = null, large: Boolean = false) {
|
||||
val toggle = row.toggle
|
||||
// Shown at once and corrected when the app answers with its own template, so the switch
|
||||
// never lags a tap; keyed on what the app last said so its answer wins.
|
||||
var checked by remember(toggle?.isChecked) { mutableStateOf(toggle?.isChecked == true) }
|
||||
val clickable = row.isEnabled && (toggle != null || selection != null || row.onClickDelegate != null)
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.let { if (clickable) it.clickable { row.onClickDelegate.click("row") } else it }
|
||||
.let {
|
||||
if (!clickable) it else it.clickable {
|
||||
when {
|
||||
toggle != null -> {
|
||||
checked = !checked
|
||||
toggle.onCheckedChangeDelegate.checkedChanged(checked)
|
||||
}
|
||||
selection != null -> selection.onPick()
|
||||
else -> row.onClickDelegate.click("row")
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (selection != null) {
|
||||
RadioButton(selected = selection.selected, onClick = null)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
}
|
||||
row.image?.let {
|
||||
CarGlyph(it, Modifier.size(28.dp))
|
||||
Spacer(Modifier.width(12.dp))
|
||||
}
|
||||
// large only changes how much room a row gets (more lines, a bigger icon above) - the
|
||||
// type itself is the same scale as every other row, so a pane's one row and a plain
|
||||
// list's tenth row read as the same kind of text.
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
row.title.text(),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
style = ChromeType.title,
|
||||
maxLines = if (large) 2 else 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
row.texts.orEmpty().take(2).forEach { line ->
|
||||
row.texts.orEmpty().take(if (large) 3 else 2).forEach { line ->
|
||||
Text(
|
||||
line.text(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
style = ChromeType.body,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
maxLines = if (large) 2 else 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
ActionRow(row.actions.orEmpty())
|
||||
if (toggle != null) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Switch(checked = checked, onCheckedChange = null, enabled = row.isEnabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A row's place in a single-choice list: whether it is the chosen one, and what picking it does. */
|
||||
internal class RowSelection(val selected: Boolean, val onPick: () -> Unit)
|
||||
|
||||
internal fun Color.luminanceIsDark(): Boolean =
|
||||
(red * 0.299f + green * 0.587f + blue * 0.114f) < 0.5f
|
||||
|
||||
@@ -381,3 +644,6 @@ private fun androidx.compose.ui.graphics.ImageBitmap.isPlainWhite(): Boolean {
|
||||
}
|
||||
return seen > 0
|
||||
}
|
||||
|
||||
private val ICON_BUTTON_SIZE = 54.dp
|
||||
private val ICON_BUTTON_GLYPH = 30.dp
|
||||
@@ -0,0 +1,213 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.Geocoder
|
||||
import android.location.Location
|
||||
import android.location.LocationManager
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.json.JSONObject
|
||||
|
||||
class HourForecast(val time: Long, val temp: Int, val code: Int, val isDay: Boolean, val rainChance: Int)
|
||||
|
||||
class DayForecast(val date: Long, val high: Int, val low: Int, val code: Int, val rainChance: Int)
|
||||
|
||||
class WeatherData(
|
||||
val place: String,
|
||||
val temp: Int,
|
||||
val feelsLike: Int,
|
||||
val code: Int,
|
||||
val isDay: Boolean,
|
||||
val humidity: Int,
|
||||
val wind: Int,
|
||||
val tempUnit: String,
|
||||
val windUnit: String,
|
||||
val hours: List<HourForecast>,
|
||||
val days: List<DayForecast>,
|
||||
val fetchedAt: Long,
|
||||
)
|
||||
|
||||
sealed interface WeatherState {
|
||||
data object Loading : WeatherState
|
||||
data class Unavailable(val reason: String) : WeatherState
|
||||
data class Ready(val data: WeatherData) : WeatherState
|
||||
}
|
||||
|
||||
/**
|
||||
* Current weather and a week's forecast for wherever the phone is, from Open-Meteo (free, no
|
||||
* key or account; the data is CC BY 4.0, so the weather screen credits it).
|
||||
*
|
||||
* The location is the phone's last fix if it is recent, otherwise a fresh one is requested, so
|
||||
* a phone that has been idle does not leave the card on "unavailable".
|
||||
*/
|
||||
object Weather {
|
||||
|
||||
private const val TTL_MS = 30 * 60 * 1000L
|
||||
private const val FIX_MAX_AGE_MS = 30 * 60 * 1000L
|
||||
private const val FIX_TIMEOUT_S = 12L
|
||||
|
||||
private val flow = MutableStateFlow<WeatherState>(WeatherState.Loading)
|
||||
val state: StateFlow<WeatherState> = flow
|
||||
|
||||
private val io = Executors.newSingleThreadExecutor { Thread(it, "gearslip-weather").apply { isDaemon = true } }
|
||||
|
||||
private var placeCache: Pair<String, String>? = null // rounded "lat,lon" to place name
|
||||
|
||||
/** Runs off the main thread. Fresh data younger than [TTL_MS] is kept unless [force]. */
|
||||
fun refresh(context: Context, force: Boolean = false) {
|
||||
val current = flow.value
|
||||
if (!force && current is WeatherState.Ready && System.currentTimeMillis() - current.data.fetchedAt < TTL_MS) return
|
||||
val app = context.applicationContext
|
||||
io.execute { load(app) }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun load(context: Context) {
|
||||
// Keep showing the old reading while a new one loads; only the first load shows a spinner.
|
||||
val location = locate(context)
|
||||
if (location == null) {
|
||||
if (flow.value !is WeatherState.Ready) {
|
||||
val allowed = hasLocationPermission(context)
|
||||
flow.value = WeatherState.Unavailable(
|
||||
if (allowed) "Waiting for a location fix." else "Allow location for Gearslip on the phone to see the weather.",
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
val data = runCatching { fetch(context, location) }
|
||||
.onFailure { GearslipLog.w("weather fetch failed: ${it.message}") }
|
||||
.getOrNull()
|
||||
if (data != null) {
|
||||
flow.value = WeatherState.Ready(data)
|
||||
GearslipLog.i("weather: ${data.place} ${data.temp}${data.tempUnit} ${describe(data.code)}")
|
||||
}
|
||||
else if (flow.value !is WeatherState.Ready) flow.value = WeatherState.Unavailable("Could not reach the weather service.")
|
||||
}
|
||||
|
||||
// --- location -------------------------------------------------------------------------
|
||||
|
||||
private fun hasLocationPermission(context: Context) =
|
||||
context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
|
||||
context.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
private fun locate(context: Context): Location? {
|
||||
if (!hasLocationPermission(context)) return null
|
||||
val manager = context.getSystemService(LocationManager::class.java) ?: return null
|
||||
val known = manager.allProviders
|
||||
.mapNotNull { runCatching { manager.getLastKnownLocation(it) }.getOrNull() }
|
||||
.maxByOrNull { it.time }
|
||||
if (known != null && System.currentTimeMillis() - known.time < FIX_MAX_AGE_MS) return known
|
||||
|
||||
val provider = listOf(LocationManager.FUSED_PROVIDER, LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER)
|
||||
.firstOrNull { it in manager.allProviders && manager.isProviderEnabled(it) }
|
||||
?: return known
|
||||
val latch = CountDownLatch(1)
|
||||
var fresh: Location? = null
|
||||
runCatching {
|
||||
manager.getCurrentLocation(provider, null, io) { fresh = it; latch.countDown() }
|
||||
latch.await(FIX_TIMEOUT_S, TimeUnit.SECONDS)
|
||||
}.onFailure { GearslipLog.w("weather: fresh fix failed: ${it.message}") }
|
||||
// A stale fix still gives the right city, which is all a forecast needs.
|
||||
return fresh ?: known
|
||||
}
|
||||
|
||||
private fun placeName(context: Context, location: Location): String {
|
||||
val key = "%.2f,%.2f".format(Locale.US, location.latitude, location.longitude)
|
||||
placeCache?.let { if (it.first == key) return it.second }
|
||||
val name = runCatching {
|
||||
if (!Geocoder.isPresent()) return@runCatching null
|
||||
@Suppress("DEPRECATION")
|
||||
Geocoder(context, Locale.getDefault()).getFromLocation(location.latitude, location.longitude, 1)
|
||||
?.firstOrNull()?.let { it.locality ?: it.subAdminArea ?: it.adminArea }
|
||||
}.getOrNull() ?: "%.2f, %.2f".format(Locale.US, location.latitude, location.longitude)
|
||||
placeCache = key to name
|
||||
return name
|
||||
}
|
||||
|
||||
// --- network --------------------------------------------------------------------------
|
||||
|
||||
private fun fetch(context: Context, location: Location): WeatherData {
|
||||
val imperial = Locale.getDefault().country in setOf("US", "LR", "MM")
|
||||
val url = URL(
|
||||
"https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}" +
|
||||
"¤t=temperature_2m,apparent_temperature,relative_humidity_2m,weather_code,wind_speed_10m,is_day" +
|
||||
"&hourly=temperature_2m,weather_code,precipitation_probability,is_day" +
|
||||
"&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max" +
|
||||
"&timezone=auto&timeformat=unixtime&forecast_days=7" +
|
||||
if (imperial) "&temperature_unit=fahrenheit&wind_speed_unit=mph" else "",
|
||||
)
|
||||
val body = (url.openConnection() as HttpURLConnection).run {
|
||||
connectTimeout = 6_000
|
||||
readTimeout = 8_000
|
||||
inputStream.bufferedReader().use { it.readText() }
|
||||
}
|
||||
val json = JSONObject(body)
|
||||
val cur = json.getJSONObject("current")
|
||||
|
||||
val hourly = json.getJSONObject("hourly")
|
||||
val times = hourly.getJSONArray("time")
|
||||
val nowSec = System.currentTimeMillis() / 1000
|
||||
val start = (0 until times.length()).firstOrNull { times.getLong(it) >= nowSec - 3600 } ?: 0
|
||||
val hours = (start until minOf(start + 12, times.length())).map { i ->
|
||||
HourForecast(
|
||||
time = times.getLong(i) * 1000,
|
||||
temp = Math.round(hourly.getJSONArray("temperature_2m").getDouble(i)).toInt(),
|
||||
code = hourly.getJSONArray("weather_code").getInt(i),
|
||||
isDay = hourly.getJSONArray("is_day").getInt(i) == 1,
|
||||
rainChance = hourly.getJSONArray("precipitation_probability").optInt(i, 0),
|
||||
)
|
||||
}
|
||||
|
||||
val daily = json.getJSONObject("daily")
|
||||
val dates = daily.getJSONArray("time")
|
||||
val days = (0 until dates.length()).map { i ->
|
||||
DayForecast(
|
||||
date = dates.getLong(i) * 1000,
|
||||
high = Math.round(daily.getJSONArray("temperature_2m_max").getDouble(i)).toInt(),
|
||||
low = Math.round(daily.getJSONArray("temperature_2m_min").getDouble(i)).toInt(),
|
||||
code = daily.getJSONArray("weather_code").getInt(i),
|
||||
rainChance = daily.getJSONArray("precipitation_probability_max").optInt(i, 0),
|
||||
)
|
||||
}
|
||||
|
||||
return WeatherData(
|
||||
place = placeName(context, location),
|
||||
temp = Math.round(cur.getDouble("temperature_2m")).toInt(),
|
||||
feelsLike = Math.round(cur.getDouble("apparent_temperature")).toInt(),
|
||||
code = cur.getInt("weather_code"),
|
||||
isDay = cur.getInt("is_day") == 1,
|
||||
humidity = cur.getInt("relative_humidity_2m"),
|
||||
wind = Math.round(cur.getDouble("wind_speed_10m")).toInt(),
|
||||
tempUnit = if (imperial) "°F" else "°C",
|
||||
windUnit = if (imperial) "mph" else "km/h",
|
||||
hours = hours,
|
||||
days = days,
|
||||
fetchedAt = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
|
||||
/** Open-Meteo's WMO weather codes, in words. */
|
||||
fun describe(code: Int): String = when (code) {
|
||||
0 -> "Clear"
|
||||
1 -> "Mostly clear"
|
||||
2 -> "Partly cloudy"
|
||||
3 -> "Overcast"
|
||||
45, 48 -> "Fog"
|
||||
in 51..57 -> "Drizzle"
|
||||
in 61..67 -> "Rain"
|
||||
in 71..77 -> "Snow"
|
||||
in 80..82 -> "Rain showers"
|
||||
in 85..86 -> "Snow showers"
|
||||
in 95..99 -> "Thunderstorm"
|
||||
else -> "-"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
|
||||
private val SUN = Color(0xFFFFB300)
|
||||
private val MOON = Color(0xFFCFD8DC)
|
||||
private val CLOUD = Color(0xFF90A4AE)
|
||||
private val RAIN = Color(0xFF29B6F6)
|
||||
private val SNOW = Color(0xFF81D4FA)
|
||||
private val BOLT = Color(0xFFFFD54F)
|
||||
|
||||
/** Sun, moon, cloud, rain, snow, storm or fog for a WMO weather code. Drawn, so it needs no icon pack. */
|
||||
@Composable
|
||||
fun WeatherIcon(code: Int, isDay: Boolean, size: Dp = 28.dp, modifier: Modifier = Modifier) {
|
||||
Canvas(modifier.size(size)) {
|
||||
when (code) {
|
||||
0 -> if (isDay) sun(center, this.size.minDimension * 0.5f) else moon(center, this.size.minDimension * 0.5f)
|
||||
1, 2 -> {
|
||||
if (isDay) sun(Offset(this.size.width * 0.38f, this.size.height * 0.36f), this.size.minDimension * 0.36f)
|
||||
else moon(Offset(this.size.width * 0.38f, this.size.height * 0.36f), this.size.minDimension * 0.36f)
|
||||
cloud(Offset(this.size.width * 0.06f, this.size.height * 0.34f), Size(this.size.width * 0.9f, this.size.height * 0.6f))
|
||||
}
|
||||
3 -> cloud(Offset(0f, this.size.height * 0.2f), Size(this.size.width, this.size.height * 0.7f))
|
||||
45, 48 -> fog()
|
||||
in 71..77, in 85..86 -> precip(SNOW, dots = true)
|
||||
in 95..99 -> storm()
|
||||
else -> precip(RAIN, dots = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.sun(c: Offset, r: Float) {
|
||||
drawCircle(SUN, r * 0.55f, c)
|
||||
val stroke = r * 0.14f
|
||||
for (i in 0 until 8) {
|
||||
val a = Math.toRadians(i * 45.0)
|
||||
drawLine(
|
||||
SUN, c + Offset(cos(a).toFloat(), sin(a).toFloat()) * (r * 0.75f),
|
||||
c + Offset(cos(a).toFloat(), sin(a).toFloat()) * r, stroke, StrokeCap.Round,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.moon(c: Offset, r: Float) {
|
||||
val disc = Path().apply { addOval(androidx.compose.ui.geometry.Rect(c, r * 0.62f)) }
|
||||
val bite = Path().apply { addOval(androidx.compose.ui.geometry.Rect(c + Offset(r * 0.32f, -r * 0.22f), r * 0.55f)) }
|
||||
drawPath(Path.combine(androidx.compose.ui.graphics.PathOperation.Difference, disc, bite), MOON)
|
||||
}
|
||||
|
||||
private fun DrawScope.cloud(topLeft: Offset, s: Size) {
|
||||
val h = s.height
|
||||
drawCircle(CLOUD, h * 0.42f, topLeft + Offset(s.width * 0.3f, h * 0.55f))
|
||||
drawCircle(CLOUD, h * 0.55f, topLeft + Offset(s.width * 0.55f, h * 0.42f))
|
||||
drawCircle(CLOUD, h * 0.38f, topLeft + Offset(s.width * 0.78f, h * 0.62f))
|
||||
drawRoundRect(CLOUD, topLeft + Offset(s.width * 0.28f, h * 0.55f), Size(s.width * 0.52f, h * 0.45f),
|
||||
androidx.compose.ui.geometry.CornerRadius(h * 0.2f))
|
||||
}
|
||||
|
||||
private fun DrawScope.precip(color: Color, dots: Boolean) {
|
||||
cloud(Offset(0f, 0f), Size(size.width, size.height * 0.62f))
|
||||
for (i in 0 until 3) {
|
||||
val x = size.width * (0.28f + 0.22f * i)
|
||||
val y = size.height * 0.72f
|
||||
if (dots) drawCircle(color, size.width * 0.045f, Offset(x, y + size.height * 0.08f))
|
||||
else drawLine(color, Offset(x, y), Offset(x - size.width * 0.06f, y + size.height * 0.2f), size.width * 0.07f, StrokeCap.Round)
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.storm() {
|
||||
cloud(Offset(0f, 0f), Size(size.width, size.height * 0.62f))
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
val bolt = Path().apply {
|
||||
moveTo(w * 0.55f, h * 0.55f); lineTo(w * 0.36f, h * 0.82f); lineTo(w * 0.5f, h * 0.82f)
|
||||
lineTo(w * 0.42f, h * 1f); lineTo(w * 0.66f, h * 0.7f); lineTo(w * 0.52f, h * 0.7f); close()
|
||||
}
|
||||
drawPath(bolt, BOLT)
|
||||
}
|
||||
|
||||
private fun DrawScope.fog() {
|
||||
val stroke = Stroke(width = size.height * 0.09f, cap = StrokeCap.Round)
|
||||
for (i in 0 until 4) {
|
||||
val y = size.height * (0.25f + 0.17f * i)
|
||||
val inset = if (i % 2 == 0) 0.1f else 0.2f
|
||||
drawLine(CLOUD, Offset(size.width * inset, y), Offset(size.width * (1f - inset), y), stroke.width, stroke.cap)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import java.text.DateFormat
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/** The weather in full: now, the next twelve hours and the week. Opened from the nav bar or the dashboard. */
|
||||
@Composable
|
||||
fun WeatherScreen() {
|
||||
val context = LocalContext.current.applicationContext
|
||||
val state by Weather.state.collectAsState()
|
||||
LaunchedEffect(Unit) { Weather.refresh(context) }
|
||||
|
||||
Column(
|
||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
when (val s = state) {
|
||||
WeatherState.Loading -> Text("Getting the weather…", style = MaterialTheme.typography.titleMedium)
|
||||
is WeatherState.Unavailable -> Text(
|
||||
s.reason, style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
is WeatherState.Ready -> Forecast(s.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Forecast(w: WeatherData) {
|
||||
Text(w.place, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.extraLarge) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(24.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
WeatherIcon(w.code, w.isDay, size = 88.dp)
|
||||
Column {
|
||||
Text("${w.temp}${w.tempUnit}", fontSize = 56.sp, fontWeight = FontWeight.Bold)
|
||||
Text(Weather.describe(w.code), style = MaterialTheme.typography.titleLarge)
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text("Feels like ${w.feelsLike}${w.tempUnit}", style = MaterialTheme.typography.bodyLarge)
|
||||
Text("Humidity ${w.humidity}%", style = MaterialTheme.typography.bodyLarge)
|
||||
Text("Wind ${w.wind} ${w.windUnit}", style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text("Next hours", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
items(w.hours) { h ->
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.large) {
|
||||
Column(
|
||||
Modifier.width(84.dp).padding(vertical = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(
|
||||
DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(h.time)).replace(":00", ""),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
WeatherIcon(h.code, h.isDay, size = 32.dp)
|
||||
Text("${h.temp}°", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
if (h.rainChance > 0) "${h.rainChance}%" else " ",
|
||||
style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text("This week", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
w.days.forEachIndexed { i, d ->
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.large) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
if (i == 0) "Today" else SimpleDateFormat("EEEE", Locale.getDefault()).format(Date(d.date)),
|
||||
Modifier.width(120.dp), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
WeatherIcon(d.code, true, size = 28.dp)
|
||||
Text(Weather.describe(d.code), Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium)
|
||||
if (d.rainChance > 0) Text("${d.rainChance}%", color = MaterialTheme.colorScheme.primary)
|
||||
Text("${d.low}° / ${d.high}°", style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"Weather data by Open-Meteo.com (CC BY 4.0)",
|
||||
style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebResourceError
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
@@ -15,16 +19,25 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* A browser inside the car UI. [content] is a URL, or raw HTML when it does not look like one.
|
||||
* The bottom bar's Back walks the page history first and only leaves the app at the start.
|
||||
*
|
||||
* A page's own text inputs can't call up the phone's IME: the car UI lives in a Presentation on
|
||||
* a virtual display, and the platform's soft keyboard has nowhere sensible to appear there (see
|
||||
* [CarKeyboard]'s doc comment - every other text field in this app already draws its own for the
|
||||
* same reason). [FOCUS_TRACKING_JS] reports focus/blur on the page's editable elements, and
|
||||
* [CarKeyboard] edits the focused one back through injected JS instead of through the DOM's own
|
||||
* native input path, which this Presentation can't reach.
|
||||
*/
|
||||
@Composable
|
||||
fun WebApp(content: String) {
|
||||
val navigator = LocalCarNavigator.current
|
||||
val darkOutside = LocalDarkOutside.current
|
||||
var web by remember { mutableStateOf<WebView?>(null) }
|
||||
var fieldText by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
DisposableEffect(web) {
|
||||
navigator.backInterceptor = {
|
||||
@@ -39,32 +52,49 @@ fun WebApp(content: String) {
|
||||
onDispose { navigator.backInterceptor = null }
|
||||
}
|
||||
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { context ->
|
||||
WebView(context).apply {
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
settings.loadWithOverviewMode = true
|
||||
settings.useWideViewPort = true
|
||||
webViewClient = LoggingClient()
|
||||
if (content.startsWith("http://") || content.startsWith("https://")) {
|
||||
loadUrl(content)
|
||||
} else {
|
||||
loadDataWithBaseURL(null, content, "text/html", "utf-8", null)
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
AndroidView(
|
||||
modifier = Modifier.weight(1f).fillMaxSize(),
|
||||
factory = { context ->
|
||||
WebView(context).apply {
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
settings.loadWithOverviewMode = true
|
||||
settings.useWideViewPort = true
|
||||
webViewClient = LoggingClient()
|
||||
addJavascriptInterface(
|
||||
FieldFocusBridge(onFocus = { fieldText = it }, onBlur = { fieldText = null }),
|
||||
"GearslipField",
|
||||
)
|
||||
if (content.startsWith("http://") || content.startsWith("https://")) {
|
||||
loadUrl(content)
|
||||
} else {
|
||||
loadDataWithBaseURL(null, content, "text/html", "utf-8", null)
|
||||
}
|
||||
web = this
|
||||
}
|
||||
web = this
|
||||
}
|
||||
},
|
||||
// Pages that honour it follow the darkness outside; a first, tweakable use of the signal.
|
||||
update = { it.settings.isAlgorithmicDarkeningAllowed = darkOutside },
|
||||
onRelease = { it.destroy() },
|
||||
)
|
||||
},
|
||||
// Pages that honour it follow the darkness outside; a first, tweakable use of the signal.
|
||||
update = { it.settings.isAlgorithmicDarkeningAllowed = darkOutside },
|
||||
onRelease = { it.destroy() },
|
||||
)
|
||||
fieldText?.let { text ->
|
||||
CarKeyboard(
|
||||
text = text,
|
||||
onTextChange = { updated ->
|
||||
fieldText = updated
|
||||
web?.evaluateJavascript(setFieldValueJs(updated), null)
|
||||
},
|
||||
onSubmit = { web?.evaluateJavascript(SUBMIT_FIELD_JS, null) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class LoggingClient : WebViewClient() {
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
GearslipLog.i("car web page loaded: $url")
|
||||
view?.evaluateJavascript(FOCUS_TRACKING_JS, null)
|
||||
}
|
||||
|
||||
// onPageFinished fires for error pages too, so without this a blocked load (missing
|
||||
@@ -81,3 +111,70 @@ private class LoggingClient : WebViewClient() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Tells Kotlin when an editable element on the page gains or loses focus, so [CarKeyboard] can show. */
|
||||
private class FieldFocusBridge(private val onFocus: (String) -> Unit, private val onBlur: () -> Unit) {
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
|
||||
// WebView calls into this from its own thread, never Compose's.
|
||||
@JavascriptInterface
|
||||
fun focused(value: String) { main.post { onFocus(value) } }
|
||||
|
||||
@JavascriptInterface
|
||||
fun blurred() { main.post { onBlur() } }
|
||||
}
|
||||
|
||||
// Plain `el.value = x` never reaches a React-controlled input: React replaces the native value
|
||||
// setter with its own to track edits, so a direct assignment sets the DOM attribute but leaves
|
||||
// React's own state (and anything bound to it, like a search box's query) unaware. Calling the
|
||||
// *native* setter through the prototype, the same trick browser extensions use, is what actually
|
||||
// notifies it once the 'input' event fires.
|
||||
private fun setFieldValueJs(value: String) = """
|
||||
(function() {
|
||||
var el = document.activeElement;
|
||||
if (!el) return;
|
||||
var proto = el.tagName === 'TEXTAREA' ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype;
|
||||
var setter = Object.getOwnPropertyDescriptor(proto, 'value');
|
||||
if (setter && setter.set) { setter.set.call(el, ${JSONObject.quote(value)}); } else { el.value = ${JSONObject.quote(value)}; }
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
})();
|
||||
""".trimIndent()
|
||||
|
||||
// Fires a real Enter keydown first - the same event a site's own JS would see from a physical
|
||||
// keyboard - and only falls back to submitting the form directly if nothing handled it, so a
|
||||
// search-as-you-type page and a plain <form> both work without submitting twice.
|
||||
private const val SUBMIT_FIELD_JS = """
|
||||
(function() {
|
||||
var el = document.activeElement;
|
||||
if (!el) return;
|
||||
var enter = new KeyboardEvent('keydown', {
|
||||
key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true,
|
||||
});
|
||||
var notHandled = el.dispatchEvent(enter);
|
||||
if (notHandled && el.form) {
|
||||
if (el.form.requestSubmit) el.form.requestSubmit(); else el.form.submit();
|
||||
}
|
||||
})();
|
||||
"""
|
||||
|
||||
/** Reports focus/blur on text inputs, textareas and contenteditable elements to [FieldFocusBridge]. */
|
||||
private const val FOCUS_TRACKING_JS = """
|
||||
(function() {
|
||||
function isEditable(el) {
|
||||
if (!el) return false;
|
||||
var tag = el.tagName;
|
||||
if (tag === 'TEXTAREA') return true;
|
||||
if (el.isContentEditable) return true;
|
||||
if (tag !== 'INPUT') return false;
|
||||
var skip = ['button', 'submit', 'checkbox', 'radio', 'range', 'color', 'file', 'image', 'reset'];
|
||||
return skip.indexOf((el.type || 'text').toLowerCase()) === -1;
|
||||
}
|
||||
document.addEventListener('focusin', function(e) {
|
||||
if (isEditable(e.target)) window.GearslipField.focused(e.target.value || '');
|
||||
});
|
||||
document.addEventListener('focusout', function(e) {
|
||||
if (isEditable(e.target)) window.GearslipField.blurred();
|
||||
});
|
||||
})();
|
||||
"""
|
||||
@@ -1,11 +1,19 @@
|
||||
package app.seb3thehacker.gearslip.host
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
|
||||
/**
|
||||
* Google's own Android Auto, in whatever guise it appears. Gearslip replaces it, so its entries
|
||||
* (there are many: one per screen it registers) are noise in every list of apps to open.
|
||||
*/
|
||||
object AndroidAuto {
|
||||
|
||||
/** Kept in sync with the `<queries><package>` entries in the manifest: package visibility
|
||||
* hides an app's presence entirely unless one names it (or a broader query matches it), and
|
||||
* this one has no launcher icon on current builds for a launcher-category query to find. */
|
||||
private val KNOWN_PACKAGES = listOf("com.google.android.projection.gearhead")
|
||||
|
||||
private val PACKAGE_MARKERS = listOf("gearhead", "projection.gearhead", "android.auto", "androidauto")
|
||||
|
||||
fun matches(packageName: String, label: String): Boolean {
|
||||
@@ -14,4 +22,26 @@ object AndroidAuto {
|
||||
val name = label.lowercase().replace(Regex("[^a-z ]"), " ")
|
||||
return "android auto" in name || "androidauto" in name.replace(" ", "")
|
||||
}
|
||||
|
||||
/** The package name of Google's Android Auto, if it is installed. */
|
||||
fun installedPackage(context: Context): String? {
|
||||
val manager = context.packageManager
|
||||
return KNOWN_PACKAGES.firstOrNull { pkg ->
|
||||
runCatching { manager.getPackageInfo(pkg, 0) }.isSuccess
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [packageName] can still fight Gearslip for the USB connection: installed, and not
|
||||
* already disabled for this user. `ApplicationInfo.enabled` only reflects the manifest's
|
||||
* static default, not a driver's own Settings toggle, so this reads the actual runtime
|
||||
* setting instead - a driver who disabled it instead of uninstalling it (the app info screen
|
||||
* offers both) has already done what this step asks.
|
||||
*/
|
||||
fun isActive(context: Context, packageName: String): Boolean {
|
||||
val setting = runCatching { context.packageManager.getApplicationEnabledSetting(packageName) }
|
||||
.getOrDefault(PackageManager.COMPONENT_ENABLED_STATE_DEFAULT)
|
||||
return setting == PackageManager.COMPONENT_ENABLED_STATE_DEFAULT ||
|
||||
setting == PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ object CarAppCatalog {
|
||||
.asSequence()
|
||||
.filter { it.serviceInfo.packageName != context.packageName }
|
||||
.filterNot { AndroidAuto.matches(it.serviceInfo.packageName, it.loadLabel(manager).toString()) }
|
||||
.filterNot { KnownApps.isHidden(it.serviceInfo.packageName) }
|
||||
.map { resolved ->
|
||||
val info = resolved.serviceInfo
|
||||
TemplateApp(
|
||||
|
||||
@@ -31,6 +31,7 @@ import androidx.car.app.suggestion.ISuggestionHost
|
||||
import androidx.car.app.hardware.ICarHardwareHost
|
||||
import androidx.car.app.hardware.ICarHardwareResult
|
||||
import androidx.car.app.navigation.INavigationHost
|
||||
import androidx.car.app.navigation.INavigationManager
|
||||
import androidx.car.app.serialization.Bundleable
|
||||
import androidx.car.app.versioning.CarAppApiLevels
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
@@ -81,6 +82,8 @@ class CarAppConnection(private val context: Context) {
|
||||
|
||||
private var carApp: ICarApp? = null
|
||||
private var appManager: IAppManager? = null
|
||||
/** Only present for an app that asked for it - most apps aren't navigation apps. */
|
||||
private var navigationManager: INavigationManager? = null
|
||||
/** Main thread only, both of these - the handoff is a race between them either way round. */
|
||||
private var surfaceCallback: ISurfaceCallback? = null
|
||||
private var lent: Lent? = null
|
||||
@@ -88,6 +91,9 @@ class CarAppConnection(private val context: Context) {
|
||||
private var binding: ServiceConnection? = null
|
||||
private var component: ComponentName? = null
|
||||
|
||||
/** Whose icon the header shows for the standard app-icon action. */
|
||||
val appPackage: String? get() = component?.packageName
|
||||
|
||||
private var frameWidth = 800
|
||||
private var frameHeight = 480
|
||||
private var frameDensity = 160
|
||||
@@ -120,20 +126,59 @@ class CarAppConnection(private val context: Context) {
|
||||
binding = connection
|
||||
|
||||
LocationKeepAlive.start(context)
|
||||
val bound = runCatching {
|
||||
context.bindService(intent, connection, Context.BIND_AUTO_CREATE or LocationKeepAlive.BIND_INCLUDE_CAPABILITIES)
|
||||
}.getOrElse {
|
||||
GearslipLog.e("bindService threw for ${app.component}", it)
|
||||
false
|
||||
val bind = {
|
||||
var bindError: Throwable? = null
|
||||
val bound = runCatching {
|
||||
context.bindService(intent, connection, Context.BIND_AUTO_CREATE or LocationKeepAlive.BIND_INCLUDE_CAPABILITIES)
|
||||
}.getOrElse {
|
||||
GearslipLog.e("bindService threw for ${app.component}", it)
|
||||
bindError = it
|
||||
false
|
||||
}
|
||||
if (!bound) {
|
||||
binding = null
|
||||
fail(Phase.REJECTED, explainBindFailure(app.component, bindError))
|
||||
} else {
|
||||
GearslipLog.i("host: binding to ${app.component.flattenToShortString()}")
|
||||
}
|
||||
}
|
||||
if (!bound) {
|
||||
binding = null
|
||||
fail(Phase.FAILED, "bindService refused")
|
||||
|
||||
bind()
|
||||
}
|
||||
|
||||
/**
|
||||
* A service that names a permission can only be bound by an app holding it. Some navigation apps
|
||||
* name one that only Google's own Android Auto host holds, and no other app can be granted.
|
||||
*/
|
||||
private fun explainBindFailure(component: ComponentName, error: Throwable?): String {
|
||||
if (error !is SecurityException) return "the app would not let Gearslip connect"
|
||||
val needed = runCatching { context.packageManager.getServiceInfo(component, 0).permission }.getOrNull()
|
||||
return if (needed != null) {
|
||||
"It only accepts Google's own Android Auto: its service requires the permission $needed, which Gearslip can't hold."
|
||||
} else {
|
||||
GearslipLog.i("host: binding to ${app.component.flattenToShortString()}")
|
||||
"It only accepts Google's own Android Auto host."
|
||||
}
|
||||
}
|
||||
|
||||
/** The app's own account of why it said no, in words rather than an object id. */
|
||||
private fun describeFailure(step: String, value: Any?): String {
|
||||
val failure = value as? androidx.car.app.FailureResponse
|
||||
?: return "$step failed: ${value ?: "no reason given"}"
|
||||
val firstLine = failure.stackTrace.lineSequence().firstOrNull { it.isNotBlank() }.orEmpty()
|
||||
GearslipLog.w("host: $step failed: type=${failure.errorType}\n${failure.stackTrace.lineSequence().take(6).joinToString("\n")}")
|
||||
// The Car App Library's own host check: the app keeps a list of hosts it trusts by signature.
|
||||
if (firstLine.contains("Unrecognized host")) {
|
||||
return "It only trusts hosts on its own allow-list (Google's Android Auto and a few others), and Gearslip isn't on it."
|
||||
}
|
||||
val kind = when (failure.errorType) {
|
||||
androidx.car.app.FailureResponse.SECURITY_EXCEPTION -> "It rejected Gearslip as a host"
|
||||
androidx.car.app.FailureResponse.ILLEGAL_STATE_EXCEPTION -> "It reported a state error"
|
||||
androidx.car.app.FailureResponse.INVALID_PARAMETER_EXCEPTION -> "It reported an invalid parameter"
|
||||
else -> "It failed"
|
||||
}
|
||||
return "$kind at $step: ${firstLine.substringAfterLast("Exception: ", firstLine)}"
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
lent?.let { sendSurfaceDestroyed(it) }
|
||||
lent = null
|
||||
@@ -147,6 +192,8 @@ class CarAppConnection(private val context: Context) {
|
||||
binding = null
|
||||
this.carApp = null
|
||||
appManager = null
|
||||
navigationManager = null
|
||||
_panMode.value = false
|
||||
surfaceCallback = null
|
||||
component = null
|
||||
_template.value = null
|
||||
@@ -226,6 +273,37 @@ class CarAppConnection(private val context: Context) {
|
||||
if (appManager == null) GearslipLog.e("host: app manager was ${value?.javaClass?.name}")
|
||||
requestTemplate()
|
||||
}) { app.getManager(CarContext.APP_SERVICE, it) }
|
||||
|
||||
// Best-effort and silent: an app that never asked for NavigationManager (not every
|
||||
// templated app navigates) simply has no binder to hand back, which is normal, not a
|
||||
// failure worth surfacing.
|
||||
runCatching {
|
||||
app.getManager(CarContext.NAVIGATION_SERVICE, object : IOnDoneCallback.Stub() {
|
||||
override fun onSuccess(response: Bundleable?) {
|
||||
val value = response?.let { runCatching { it.get() }.getOrNull() }
|
||||
navigationManager = when (value) {
|
||||
is INavigationManager -> value
|
||||
is IBinder -> INavigationManager.Stub.asInterface(value)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
override fun onFailure(response: Bundleable?) = Unit
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the app itself to stop navigating - the proper end of a route, not just leaving its
|
||||
* screen. Ending only the on-screen guidance (a back press) leaves the app's own
|
||||
* "am I navigating" flag set, which is what stopped a fresh route from starting afterwards;
|
||||
* this is the call [NavigationManagerCallback.onStopNavigation] on the app's side answers,
|
||||
* and it is expected to clear that flag and present its own post-navigation screen.
|
||||
*/
|
||||
fun stopNavigating() {
|
||||
val manager = navigationManager ?: return
|
||||
GearslipLog.i("host: asking the app to stop navigating")
|
||||
runCatching { manager.onStopNavigation(noop("stop navigation")) }
|
||||
.onFailure { GearslipLog.w("host: could not stop navigation: ${it.message}") }
|
||||
}
|
||||
|
||||
/** The app describes its current screen; this is what the host draws. */
|
||||
@@ -244,6 +322,32 @@ class CarAppConnection(private val context: Context) {
|
||||
}) { manager.getTemplate(it) }
|
||||
}
|
||||
|
||||
private val _panMode = MutableStateFlow(false)
|
||||
|
||||
/**
|
||||
* Pan mode is the host's to run: the Pan action has no click handler, tapping it just switches
|
||||
* the screen to the map and its own controls. The app is told only if its template asks.
|
||||
*/
|
||||
val panMode: StateFlow<Boolean> = _panMode
|
||||
|
||||
fun setPanMode(on: Boolean) {
|
||||
if (_panMode.value == on) return
|
||||
_panMode.value = on
|
||||
GearslipLog.i("host: pan mode ${if (on) "on" else "off"}")
|
||||
(_template.value as? androidx.car.app.navigation.model.NavigationTemplate)
|
||||
?.panModeDelegate.panModeChanged(on)
|
||||
}
|
||||
|
||||
/**
|
||||
* The Back action carries no click handler of its own: the app keeps its own screen stack, so
|
||||
* the host reports the press and the app answers with the template it went back to.
|
||||
*/
|
||||
fun backPressed() {
|
||||
val manager = appManager ?: return
|
||||
GearslipLog.i("host: back pressed")
|
||||
call("onBackPressed", onValue = { requestTemplate() }) { manager.onBackPressed(it) }
|
||||
}
|
||||
|
||||
// --- the surface the app draws its map into -------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -498,7 +602,7 @@ class CarAppConnection(private val context: Context) {
|
||||
|
||||
override fun onFailure(response: Bundleable?) {
|
||||
val value = response?.let { runCatching { it.get() }.getOrNull() }
|
||||
main.post { fail(Phase.FAILED, "$name failed: $value") }
|
||||
main.post { fail(Phase.REJECTED, describeFailure(name, value)) }
|
||||
}
|
||||
}
|
||||
runCatching { send(callback) }
|
||||
|
||||
@@ -91,6 +91,17 @@ fun remainingTime(seconds: Long): String {
|
||||
return if (hours > 0) "$hours hr $minutes min" else "$minutes min"
|
||||
}
|
||||
|
||||
/** "1:10 AM" - the clock at the destination, not the phone's own, in case a route crosses a zone. */
|
||||
fun androidx.car.app.model.DateTimeWithZone?.clockTime(): String {
|
||||
val at = this ?: return ""
|
||||
val shifted = java.util.Date(at.timeSinceEpochMillis + at.zoneOffsetSeconds * 1000L)
|
||||
return runCatching {
|
||||
java.text.SimpleDateFormat("h:mm a", java.util.Locale.getDefault()).apply {
|
||||
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||
}.format(shifted)
|
||||
}.getOrDefault("")
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the app something was tapped.
|
||||
*
|
||||
@@ -140,6 +151,27 @@ fun InputCallbackDelegate?.inputSubmitted(text: String) {
|
||||
.onFailure { GearslipLog.w("host: could not submit input: ${it.message}") }
|
||||
}
|
||||
|
||||
/** Tells the app the driver entered or left pan mode, so it can hide its own map controls. */
|
||||
fun androidx.car.app.navigation.model.PanModeDelegate?.panModeChanged(active: Boolean) {
|
||||
val delegate = this ?: return
|
||||
runCatching { delegate.sendPanModeChanged(active, doneCallback("pan mode")) }
|
||||
.onFailure { GearslipLog.w("host: could not send pan mode: ${it.message}") }
|
||||
}
|
||||
|
||||
/** Tells the app a toggle row was switched; it answers with a template showing the new state. */
|
||||
fun androidx.car.app.model.OnCheckedChangeDelegate?.checkedChanged(checked: Boolean) {
|
||||
val delegate = this ?: return
|
||||
runCatching { delegate.sendCheckedChange(checked, doneCallback("toggle")) }
|
||||
.onFailure { GearslipLog.w("host: could not send a toggle: ${it.message}") }
|
||||
}
|
||||
|
||||
/** Tells the app which option of a single-choice list was picked. */
|
||||
fun androidx.car.app.model.OnSelectedDelegate?.selected(index: Int) {
|
||||
val delegate = this ?: return
|
||||
runCatching { delegate.sendSelected(index, doneCallback("selection")) }
|
||||
.onFailure { GearslipLog.w("host: could not send a selection: ${it.message}") }
|
||||
}
|
||||
|
||||
/** The driver asked for fresher content, e.g. by pulling a place list. */
|
||||
fun OnContentRefreshDelegate?.refresh() {
|
||||
val delegate = this ?: return
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package app.seb3thehacker.gearslip.host
|
||||
|
||||
/**
|
||||
* Apps that have been run through Gearslip from start to finish and behave. The launcher marks
|
||||
* them so a driver can pick something that works without finding out at the wheel.
|
||||
*
|
||||
* An app belongs here only after a real session: the app starts, draws, takes touches and
|
||||
* answers its buttons. Apps that connect but show no library, or refuse Gearslip as a host, stay
|
||||
* off the list.
|
||||
*/
|
||||
object KnownApps {
|
||||
|
||||
private val working = setOf(
|
||||
"app.organicmaps", // Organic Maps: map, search, routing, settings
|
||||
"net.osmand.plus", // OsmAnd: map, search, routing, settings
|
||||
"in.krosbits.musicolet", // Musicolet: browse, play, seek, lyrics
|
||||
"com.kododake.aabrowser", // Aardvark: browser template, starts, draws, takes touches
|
||||
"com.vivi.vivimusic", // Vivi Music: browse, play, seek, works well
|
||||
)
|
||||
|
||||
fun works(packageName: String): Boolean = packageName in working
|
||||
|
||||
/**
|
||||
* Registers a car template service but never gives Gearslip a usable screen - left off the
|
||||
* launcher entirely rather than shown as a tile that goes nowhere.
|
||||
*/
|
||||
private val hidden = setOf(
|
||||
"com.google.android.dialer", // Google Phone
|
||||
"com.google.android.googlequicksearchbox", // Google Assistant driving surfaces
|
||||
)
|
||||
|
||||
fun isHidden(packageName: String): Boolean = packageName in hidden
|
||||
|
||||
/** Shown on the launcher with a red X: known broken, but expected enough that hiding it outright would look like a bug. */
|
||||
private val broken = setOf(
|
||||
"com.google.android.apps.messaging", // Google Messages
|
||||
"com.waze", // Waze
|
||||
)
|
||||
|
||||
fun isBroken(packageName: String): Boolean = packageName in broken
|
||||
}
|
||||
@@ -1,15 +1,21 @@
|
||||
package app.seb3thehacker.gearslip.media
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.media.MediaMetadata
|
||||
import android.media.browse.MediaBrowser
|
||||
import android.media.session.MediaController
|
||||
import android.media.session.MediaSession
|
||||
import android.media.session.MediaSessionManager
|
||||
import android.media.session.PlaybackState
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.notify.GearslipNotificationListener
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -28,6 +34,15 @@ class MediaEntry(
|
||||
|
||||
class CustomAction(val id: String, val name: String, val extras: Bundle?)
|
||||
|
||||
/** One entry in the app's own queue - not its library, whatever it has lined up to play next. */
|
||||
class QueueTrack(
|
||||
val queueId: Long,
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val iconUri: Uri?,
|
||||
val iconBitmap: Bitmap?,
|
||||
)
|
||||
|
||||
/** What is playing, as the car's now-playing panel needs it. */
|
||||
class NowPlaying(
|
||||
val title: String = "",
|
||||
@@ -43,10 +58,21 @@ class NowPlaying(
|
||||
val actions: Long = 0,
|
||||
val custom: List<CustomAction> = emptyList(),
|
||||
val error: String? = null,
|
||||
/** Which [QueueTrack.queueId] is playing now, so "up next" can show what follows it. */
|
||||
val activeQueueItemId: Long = MediaSession.QueueItem.UNKNOWN_ID.toLong(),
|
||||
) {
|
||||
val playing get() = state == PlaybackState.STATE_PLAYING || state == PlaybackState.STATE_BUFFERING
|
||||
val hasTrack get() = title.isNotEmpty() || state != PlaybackState.STATE_NONE
|
||||
|
||||
/**
|
||||
* Something is actually playing or paused mid-track. A stopped, errored or empty session, or one
|
||||
* that has not named a track yet, must not keep the player on screen.
|
||||
*/
|
||||
val isActive get() = title.isNotEmpty() && when (state) {
|
||||
PlaybackState.STATE_NONE, PlaybackState.STATE_STOPPED, PlaybackState.STATE_ERROR -> false
|
||||
else -> true
|
||||
}
|
||||
|
||||
fun canDo(action: Long) = actions and action != 0L
|
||||
|
||||
/** The position now, advanced from the last report the way the platform's own UIs do. */
|
||||
@@ -64,6 +90,8 @@ class BrowseState(
|
||||
val entries: List<MediaEntry> = emptyList(),
|
||||
val loading: Boolean = true,
|
||||
val failed: Boolean = false,
|
||||
/** The app connected but shares no library with us, so there is nothing to browse - only to control. */
|
||||
val unavailable: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -75,6 +103,15 @@ class CarMedia(private val context: Context) {
|
||||
|
||||
enum class Phase { IDLE, CONNECTING, READY, REJECTED }
|
||||
|
||||
/** Why a connection was [Phase.REJECTED], so the screen can say something useful. */
|
||||
enum class Rejection { REFUSED, NEEDS_NOTIFICATION_ACCESS }
|
||||
|
||||
private val _rejection = MutableStateFlow(Rejection.REFUSED)
|
||||
val rejection: StateFlow<Rejection> = _rejection.asStateFlow()
|
||||
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
private var connecting: MediaApp? = null
|
||||
|
||||
private val _phase = MutableStateFlow(Phase.IDLE)
|
||||
val phase: StateFlow<Phase> = _phase.asStateFlow()
|
||||
|
||||
@@ -84,6 +121,9 @@ class CarMedia(private val context: Context) {
|
||||
private val _now = MutableStateFlow(NowPlaying())
|
||||
val now: StateFlow<NowPlaying> = _now.asStateFlow()
|
||||
|
||||
private val _queue = MutableStateFlow<List<QueueTrack>>(emptyList())
|
||||
val queue: StateFlow<List<QueueTrack>> = _queue.asStateFlow()
|
||||
|
||||
private var browser: MediaBrowser? = null
|
||||
private var controller: MediaController? = null
|
||||
private var subscribed: String? = null
|
||||
@@ -92,13 +132,14 @@ class CarMedia(private val context: Context) {
|
||||
|
||||
fun connect(app: MediaApp) {
|
||||
disconnect()
|
||||
connecting = app
|
||||
_phase.value = Phase.CONNECTING
|
||||
_browse.value = BrowseState()
|
||||
val mb = MediaBrowser(context, app.component, object : MediaBrowser.ConnectionCallback() {
|
||||
override fun onConnected() = this@CarMedia.onConnected(app)
|
||||
override fun onConnectionFailed() {
|
||||
GearslipLog.w("media: ${app.label} refused the connection")
|
||||
_phase.value = Phase.REJECTED
|
||||
GearslipLog.w("media: ${app.label} refused the connection to its library")
|
||||
attachToSession(app)
|
||||
}
|
||||
override fun onConnectionSuspended() {
|
||||
GearslipLog.w("media: ${app.label} went away")
|
||||
@@ -112,12 +153,52 @@ class CarMedia(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Some apps only let Google's own host browse them. Their playback session is still public, so
|
||||
* playback can be driven from it: what is playing, play and pause, skip, seek. Only the library
|
||||
* is out of reach. Reading another app's session needs Notification access, which the driver
|
||||
* grants once on the phone.
|
||||
*/
|
||||
private fun attachToSession(app: MediaApp, attempt: Int = 0) {
|
||||
if (connecting != app) return
|
||||
val manager = context.getSystemService(MediaSessionManager::class.java)
|
||||
val listener = ComponentName(context, GearslipNotificationListener::class.java)
|
||||
val session = try {
|
||||
manager.getActiveSessions(listener).firstOrNull { it.packageName == app.component.packageName }
|
||||
} catch (e: SecurityException) {
|
||||
GearslipLog.w("media: cannot read ${app.label}'s session without notification access")
|
||||
_rejection.value = Rejection.NEEDS_NOTIFICATION_ACCESS
|
||||
_phase.value = Phase.REJECTED
|
||||
return
|
||||
}
|
||||
if (session == null) {
|
||||
// The app only publishes its session once its service has started; give it a moment.
|
||||
if (attempt < SESSION_RETRIES) {
|
||||
main.postDelayed({ attachToSession(app, attempt + 1) }, SESSION_RETRY_MS)
|
||||
} else {
|
||||
GearslipLog.w("media: ${app.label} has no active session to control")
|
||||
_rejection.value = Rejection.REFUSED
|
||||
_phase.value = Phase.REJECTED
|
||||
}
|
||||
return
|
||||
}
|
||||
GearslipLog.i("media: controlling ${app.label} through its session; its library is not shared")
|
||||
controller = session.also {
|
||||
it.registerCallback(callback)
|
||||
update(it.metadata, it.playbackState)
|
||||
_queue.value = queueOf(it.queue)
|
||||
}
|
||||
_browse.value = BrowseState(loading = false, unavailable = true)
|
||||
_phase.value = Phase.READY
|
||||
}
|
||||
|
||||
private fun onConnected(app: MediaApp) {
|
||||
val mb = browser ?: return
|
||||
GearslipLog.i("media: connected to ${app.label}, root=${mb.root}")
|
||||
controller = MediaController(context, mb.sessionToken).also {
|
||||
it.registerCallback(callback)
|
||||
update(it.metadata, it.playbackState)
|
||||
_queue.value = queueOf(it.queue)
|
||||
}
|
||||
rootId = mb.root
|
||||
path.clear()
|
||||
@@ -128,6 +209,20 @@ class CarMedia(private val context: Context) {
|
||||
private val callback = object : MediaController.Callback() {
|
||||
override fun onMetadataChanged(metadata: MediaMetadata?) = update(metadata, controller?.playbackState)
|
||||
override fun onPlaybackStateChanged(state: PlaybackState?) = update(controller?.metadata, state)
|
||||
override fun onQueueChanged(queue: MutableList<MediaSession.QueueItem>?) {
|
||||
_queue.value = queueOf(queue)
|
||||
}
|
||||
}
|
||||
|
||||
private fun queueOf(items: List<MediaSession.QueueItem>?): List<QueueTrack> = items.orEmpty().map { item ->
|
||||
val d = item.description
|
||||
QueueTrack(
|
||||
queueId = item.queueId,
|
||||
title = d.title?.toString().orEmpty(),
|
||||
subtitle = d.subtitle?.toString().orEmpty(),
|
||||
iconUri = d.iconUri,
|
||||
iconBitmap = d.iconBitmap,
|
||||
)
|
||||
}
|
||||
|
||||
private fun update(metadata: MediaMetadata?, state: PlaybackState?) {
|
||||
@@ -148,6 +243,7 @@ class CarMedia(private val context: Context) {
|
||||
actions = state?.actions ?: 0,
|
||||
custom = state?.customActions.orEmpty().map { CustomAction(it.action, it.name.toString(), it.extras) },
|
||||
error = state?.errorMessage?.toString(),
|
||||
activeQueueItemId = state?.activeQueueItemId ?: MediaSession.QueueItem.UNKNOWN_ID.toLong(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -165,6 +261,8 @@ class CarMedia(private val context: Context) {
|
||||
trail = path.map { it.second },
|
||||
entries = children.map(::entryOf),
|
||||
loading = false,
|
||||
// An empty top level is an app keeping its library to itself, not an empty one.
|
||||
unavailable = path.isEmpty() && children.isEmpty(),
|
||||
)
|
||||
}
|
||||
override fun onError(parentId: String) {
|
||||
@@ -220,8 +318,11 @@ class CarMedia(private val context: Context) {
|
||||
fun previous() = controller?.transportControls?.skipToPrevious()
|
||||
fun seek(ms: Long) = controller?.transportControls?.seekTo(ms)
|
||||
fun custom(action: CustomAction) = controller?.transportControls?.sendCustomAction(action.id, action.extras)
|
||||
fun playQueueItem(track: QueueTrack) = controller?.transportControls?.skipToQueueItem(track.queueId)
|
||||
|
||||
fun disconnect() {
|
||||
connecting = null
|
||||
main.removeCallbacksAndMessages(null)
|
||||
runCatching { controller?.unregisterCallback(callback) }
|
||||
runCatching { subscribed?.let { browser?.unsubscribe(it) } }
|
||||
runCatching { browser?.disconnect() }
|
||||
@@ -230,5 +331,11 @@ class CarMedia(private val context: Context) {
|
||||
subscribed = null
|
||||
_phase.value = Phase.IDLE
|
||||
_now.value = NowPlaying()
|
||||
_queue.value = emptyList()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SESSION_RETRIES = 6
|
||||
const val SESSION_RETRY_MS = 500L
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.media
|
||||
|
||||
import app.seb3thehacker.gearslip.host.AndroidAuto
|
||||
import app.seb3thehacker.gearslip.host.KnownApps
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -33,6 +34,7 @@ object MediaCatalog {
|
||||
.asSequence()
|
||||
.filter { it.serviceInfo.packageName != context.packageName }
|
||||
.filterNot { AndroidAuto.matches(it.serviceInfo.packageName, it.loadLabel(manager).toString()) }
|
||||
.filterNot { KnownApps.isHidden(it.serviceInfo.packageName) }
|
||||
.filter { declaresCar(manager, it.serviceInfo) }
|
||||
.map {
|
||||
val info = it.serviceInfo
|
||||
|
||||
@@ -24,6 +24,14 @@ object PhoneApps {
|
||||
|
||||
private const val ICON_PX = 128
|
||||
|
||||
@Volatile private var cache: List<PhoneApp>? = null
|
||||
|
||||
/** What the last scan found, or null before the first one. */
|
||||
fun cached(): List<PhoneApp>? = cache
|
||||
|
||||
/** Scans and stores the result; [Prefetch] calls this so the screen opens already filled. */
|
||||
fun warm(context: Context) { cache = installed(context) }
|
||||
|
||||
fun installed(context: Context): List<PhoneApp> {
|
||||
val manager = context.packageManager
|
||||
val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
|
||||
@@ -3,7 +3,7 @@ package app.seb3thehacker.gearslip.ui
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.PixelFormat
|
||||
import android.hardware.HardwareBuffer
|
||||
@@ -43,6 +43,7 @@ import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.input.pointer.pointerInteropFilter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
@@ -50,6 +51,7 @@ import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.ScreenProjector
|
||||
import app.seb3thehacker.gearslip.TouchPoint
|
||||
import app.seb3thehacker.gearslip.car.CarEnvironment
|
||||
import app.seb3thehacker.gearslip.car.CarUi
|
||||
|
||||
@@ -69,7 +71,9 @@ fun CarPreviewScreen(onBack: () -> Unit) {
|
||||
val frame by CarEnvironment.frame.collectAsStateWithLifecycle()
|
||||
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
var viewSize by remember { mutableStateOf(0 to 0) }
|
||||
var fullscreen by remember { mutableStateOf(false) }
|
||||
// Tied to how the phone itself is held, not a button: turn it sideways and the preview goes
|
||||
// fullscreen, turn it back and the phone's own chrome (the Back/title above the frame) returns.
|
||||
val fullscreen = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
|
||||
val width = frame.width
|
||||
val height = frame.height
|
||||
@@ -122,8 +126,14 @@ fun CarPreviewScreen(onBack: () -> Unit) {
|
||||
if (viewWidth > 0 && viewHeight > 0) {
|
||||
projector.dispatchTouch(
|
||||
event.actionMasked.toCarAction(),
|
||||
event.x * width / viewWidth,
|
||||
event.y * height / viewHeight,
|
||||
event.actionIndex,
|
||||
List(event.pointerCount) { i ->
|
||||
TouchPoint(
|
||||
event.getPointerId(i),
|
||||
event.getX(i) * width / viewWidth,
|
||||
event.getY(i) * height / viewHeight,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
true
|
||||
@@ -142,7 +152,7 @@ fun CarPreviewScreen(onBack: () -> Unit) {
|
||||
}
|
||||
|
||||
if (fullscreen) {
|
||||
Fullscreen(landscape = width > height, onExit = { fullscreen = false }) {
|
||||
Fullscreen(onBack) {
|
||||
// No fillMax here: left to the bounds alone, aspectRatio takes the whole width or the
|
||||
// whole height, whichever the car's shape allows, and the rest stays black.
|
||||
frameContent(Modifier)
|
||||
@@ -167,7 +177,6 @@ fun CarPreviewScreen(onBack: () -> Unit) {
|
||||
|
||||
Row {
|
||||
TextButton(onClick = onBack) { Text("Back") }
|
||||
TextButton(onClick = { fullscreen = true }) { Text("Fullscreen") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -177,34 +186,32 @@ fun CarPreviewScreen(onBack: () -> Unit) {
|
||||
* Fills the phone screen with [content], centred and letterboxed.
|
||||
*
|
||||
* A car screen is wider than it is tall, so a portrait phone would waste most of its height on
|
||||
* one. While this is showing, the activity is turned to match and the system bars go away - both
|
||||
* are undone on the way out, so the rest of the phone UI is unaffected.
|
||||
* one. This only shows once the phone is already turned sideways (the caller drives it off the
|
||||
* phone's own orientation), so there is no rotation left to force here - just the system bars to
|
||||
* hide while it's up and restore on the way out.
|
||||
*/
|
||||
@Composable
|
||||
private fun Fullscreen(
|
||||
landscape: Boolean,
|
||||
onExit: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
content: @Composable BoxScope.() -> Unit,
|
||||
) {
|
||||
val activity = LocalContext.current.activity()
|
||||
|
||||
DisposableEffect(activity, landscape) {
|
||||
DisposableEffect(activity) {
|
||||
if (activity == null) return@DisposableEffect onDispose { }
|
||||
|
||||
val insets = WindowInsetsControllerCompat(activity.window, activity.window.decorView).apply {
|
||||
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
hide(WindowInsetsCompat.Type.systemBars())
|
||||
}
|
||||
val previousOrientation = activity.requestedOrientation
|
||||
if (landscape) activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
|
||||
onDispose {
|
||||
insets.show(WindowInsetsCompat.Type.systemBars())
|
||||
activity.requestedOrientation = previousOrientation
|
||||
}
|
||||
onDispose { insets.show(WindowInsetsCompat.Type.systemBars()) }
|
||||
}
|
||||
|
||||
BackHandler(onBack = onExit)
|
||||
// Turning the phone back upright is what normally leaves fullscreen; back still leaves the
|
||||
// preview screen entirely rather than doing nothing, in case the phone's own rotation lock
|
||||
// is on and turning it sideways/back isn't an option.
|
||||
BackHandler(onBack = onBack)
|
||||
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(Color.Black),
|
||||
@@ -215,10 +222,10 @@ private fun Fullscreen(
|
||||
// The frame swallows every touch inside it, so leaving needs either the back gesture or
|
||||
// a button of its own. This one sits in the corner the car UI leaves emptiest.
|
||||
FilledTonalIconButton(
|
||||
onClick = onExit,
|
||||
onClick = onBack,
|
||||
modifier = Modifier.align(Alignment.BottomStart).padding(8.dp),
|
||||
) {
|
||||
Icon(Icons.Filled.Close, contentDescription = "Leave fullscreen")
|
||||
Icon(Icons.Filled.Close, contentDescription = "Leave the car preview")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -230,10 +237,11 @@ private tailrec fun Context.activity(): Activity? = when (this) {
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** Only the four actions [ScreenProjector.dispatchTouch] understands reach the car UI. */
|
||||
/** The actions [ScreenProjector.dispatchTouch] understands reach the car UI, second fingers included. */
|
||||
private fun Int.toCarAction(): Int = when (this) {
|
||||
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE,
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL,
|
||||
MotionEvent.ACTION_POINTER_DOWN, MotionEvent.ACTION_POINTER_UP,
|
||||
-> this
|
||||
else -> MotionEvent.ACTION_CANCEL
|
||||
}
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.AppSettings
|
||||
import app.seb3thehacker.gearslip.CertProvider
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Shown when Gearslip has no certificate loaded. Download is the one clear path; importing your
|
||||
* own file is offered as a smaller, secondary option underneath it.
|
||||
*
|
||||
* The app ships no certificates. Skipping generates a self-signed one, which a real head unit
|
||||
* rejects.
|
||||
*/
|
||||
@Composable
|
||||
fun CertSetupScreen(onDone: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var downloadState by remember { mutableStateOf<DownloadState>(DownloadState.Idle) }
|
||||
var showImport by remember { mutableStateOf(false) }
|
||||
var pendingUri by remember { mutableStateOf<Uri?>(null) }
|
||||
var manualProgress by remember { mutableStateOf(false) }
|
||||
|
||||
val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||
if (uri != null) pendingUri = uri
|
||||
}
|
||||
|
||||
val installed = manualProgress || downloadState is DownloadState.Success
|
||||
|
||||
Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 24.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
"Certificate Setup",
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
if (installed) {
|
||||
"Cars accept only Google-issued certificates, and you have one loaded."
|
||||
} else {
|
||||
"Cars accept only Google-issued certificates. Download one to connect."
|
||||
},
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(32.dp))
|
||||
|
||||
if (installed) {
|
||||
Text(
|
||||
if (manualProgress) "Certificate imported." else "Certificate installed.",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Button(onClick = onDone, modifier = Modifier.fillMaxWidth().height(56.dp)) {
|
||||
Text("Continue")
|
||||
}
|
||||
Spacer(Modifier.height(40.dp))
|
||||
return@Column
|
||||
}
|
||||
|
||||
when (downloadState) {
|
||||
is DownloadState.Idle -> {
|
||||
Button(
|
||||
onClick = {
|
||||
downloadState = DownloadState.Loading
|
||||
scope.launch {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
runCatching { CertProvider.downloadAndImport(context) }
|
||||
}
|
||||
downloadState = result.fold(
|
||||
onSuccess = { DownloadState.Success(it.certificate.subjectX500Principal.name) },
|
||||
onFailure = { DownloadState.Error(it.message ?: "Unknown error") },
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
Text("Download certificate")
|
||||
}
|
||||
Text(
|
||||
"Works with most cars built before 2020. Newer cars reject it.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
is DownloadState.Loading -> {
|
||||
Box(Modifier.fillMaxWidth().height(56.dp), contentAlignment = Alignment.Center) {
|
||||
DownloadingRow {
|
||||
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
Text("Downloading…", style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is DownloadState.Success -> Unit // handled above via `installed`
|
||||
|
||||
is DownloadState.Error -> {
|
||||
Surface(color = MaterialTheme.colorScheme.errorContainer, shape = MaterialTheme.shapes.medium) {
|
||||
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
"Download failed",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Text(
|
||||
(downloadState as DownloadState.Error).message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Button(onClick = { downloadState = DownloadState.Idle }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
if (showImport) {
|
||||
OutlinedButton(
|
||||
onClick = { importLauncher.launch(arrayOf("*/*")) },
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
) { Text("Import your own .p12 cert") }
|
||||
Spacer(Modifier.height(16.dp))
|
||||
} else {
|
||||
TextButton(onClick = { showImport = true }) { Text("Import your own certificate instead") }
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
AppSettings.setSkippedCertSetup(context)
|
||||
onDone()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
) {
|
||||
Text("Skip car connection setup")
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(40.dp))
|
||||
}
|
||||
}
|
||||
|
||||
pendingUri?.let { uri ->
|
||||
PasswordDialog(
|
||||
onDismiss = { pendingUri = null },
|
||||
onConfirm = { password, reportError ->
|
||||
scope.launch {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
runCatching { CertProvider.importFrom(context, uri, password) }
|
||||
}
|
||||
result.onSuccess {
|
||||
pendingUri = null
|
||||
manualProgress = true
|
||||
}.onFailure {
|
||||
reportError("Wrong password or not a valid PKCS#12 file with a private key.")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A tight horizontal row for the inline "Downloading…" spinner state. */
|
||||
@Composable
|
||||
private fun DownloadingRow(content: @Composable RowScope.() -> Unit) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
private sealed class DownloadState {
|
||||
data object Idle : DownloadState()
|
||||
data object Loading : DownloadState()
|
||||
data class Success(val subjectName: String) : DownloadState()
|
||||
data class Error(val message: String) : DownloadState()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PasswordDialog(onDismiss: () -> Unit, onConfirm: (String, (String) -> Unit) -> Unit) {
|
||||
var password by remember { mutableStateOf("") }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Certificate password") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it; error = null },
|
||||
singleLine = true,
|
||||
label = { Text("Password") },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
isError = error != null,
|
||||
supportingText = { Text(error ?: "Leave empty if the file has none.") },
|
||||
)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { onConfirm(password) { error = it } }) { Text("Import") } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
@@ -19,12 +19,17 @@ data class CertSummary(
|
||||
val identity = CertProvider.loadSupplied(context)
|
||||
?: return CertSummary(
|
||||
CertProvider.Kind.SELF_SIGNED,
|
||||
"None loaded - a self-signed one is generated, which real head units reject",
|
||||
"None loaded (self-signed; cars reject it)",
|
||||
null, null, null,
|
||||
)
|
||||
val cert = identity.certificate
|
||||
val name = friendlyName(cert.subjectX500Principal.name)
|
||||
val prefix = if (identity.kind == CertProvider.Kind.IMPORTED) "Imported" else "Staged over adb"
|
||||
val prefix = when (identity.kind) {
|
||||
CertProvider.Kind.IMPORTED -> "Imported"
|
||||
CertProvider.Kind.DOWNLOADED -> "Downloaded"
|
||||
CertProvider.Kind.ADB_STAGED -> "Staged over adb"
|
||||
CertProvider.Kind.SELF_SIGNED -> "Self-signed"
|
||||
}
|
||||
return CertSummary(
|
||||
identity.kind,
|
||||
"$prefix: $name",
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* A command to run on a computer, shown so it can be copied rather than retyped. Tapping it puts
|
||||
* it on the clipboard, which is the only way it reaches the computer from here anyway.
|
||||
*/
|
||||
@Composable
|
||||
fun CopyableCommand(command: String) {
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val context = LocalContext.current
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
onClick = {
|
||||
clipboard.setText(AnnotatedString(command))
|
||||
Toast.makeText(context, "Command copied", Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||
) {
|
||||
Column(Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
command,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
"Tap to copy",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,17 +7,32 @@ import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import app.seb3thehacker.gearslip.AppSettings
|
||||
import app.seb3thehacker.gearslip.CertProvider
|
||||
|
||||
private enum class Screen { HOME, LOGS, SETTINGS, CAR_PREVIEW }
|
||||
private enum class Screen { HOME, LOGS, SETTINGS, CAR_PREVIEW, CERT_SETUP, SETUP_GUIDE, HELP }
|
||||
|
||||
/** Three screens and a back stack of depth one: no navigation library needed. */
|
||||
@Composable
|
||||
fun GearslipApp(onDisconnect: () -> Unit) {
|
||||
var screen by rememberSaveable { mutableStateOf(Screen.HOME) }
|
||||
fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val needsCertSetup = remember {
|
||||
!AppSettings.hasSkippedCertSetup(context) && !CertProvider.hasAnyCert(context)
|
||||
}
|
||||
val needsSetupGuide = remember { !AppSettings.hasSeenPermissionsSetup(context) }
|
||||
var screen by rememberSaveable {
|
||||
mutableStateOf(
|
||||
when {
|
||||
needsCertSetup -> Screen.CERT_SETUP
|
||||
needsSetupGuide -> Screen.SETUP_GUIDE
|
||||
else -> Screen.HOME
|
||||
},
|
||||
)
|
||||
}
|
||||
BackHandler(enabled = screen != Screen.HOME) { screen = Screen.HOME }
|
||||
|
||||
when (screen) {
|
||||
@@ -25,11 +40,22 @@ fun GearslipApp(onDisconnect: () -> Unit) {
|
||||
onOpenLogs = { screen = Screen.LOGS },
|
||||
onOpenSettings = { screen = Screen.SETTINGS },
|
||||
onOpenCarPreview = { screen = Screen.CAR_PREVIEW },
|
||||
onOpenHelp = { screen = Screen.HELP },
|
||||
onDisconnect = onDisconnect,
|
||||
)
|
||||
Screen.LOGS -> LogsScreen(onBack = { screen = Screen.HOME })
|
||||
Screen.SETTINGS -> SettingsScreen(onBack = { screen = Screen.HOME })
|
||||
Screen.CAR_PREVIEW -> CarPreviewScreen(onBack = { screen = Screen.HOME })
|
||||
Screen.HELP -> HelpScreen(onBack = { screen = Screen.HOME }, onRequestCallScreening = onRequestCallScreening)
|
||||
Screen.CERT_SETUP -> CertSetupScreen(
|
||||
onDone = {
|
||||
screen = if (AppSettings.hasSeenPermissionsSetup(context)) Screen.HOME else Screen.SETUP_GUIDE
|
||||
},
|
||||
)
|
||||
Screen.SETUP_GUIDE -> SetupGuideScreen(
|
||||
onRequestCallScreening = onRequestCallScreening,
|
||||
onDone = { screen = Screen.HOME },
|
||||
)
|
||||
}
|
||||
|
||||
CompatWarning()
|
||||
@@ -47,9 +73,8 @@ private fun CompatWarning() {
|
||||
title = { Text("Before you plug in") },
|
||||
text = {
|
||||
Text(
|
||||
"Gearslip may not connect on cars or head units newer than about 2020. " +
|
||||
"Older systems are more likely to work; newer firmware often rejects the " +
|
||||
"connection outright. See SPIKE_FINDINGS.md in the project for details.",
|
||||
"Gearslip works with cars built before 2020. " +
|
||||
"Newer firmware often blocks the connection.",
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* One step of the setup list: a sentence that says what to do, and optionally how.
|
||||
*
|
||||
* [command] is for a step that cannot be done on the phone; it is shown as something to copy
|
||||
* rather than to read, because a mistyped package name fails silently. [onClick] is for a step
|
||||
* that can, in one tap, in which case it is shown as a button instead.
|
||||
*/
|
||||
private class Step(
|
||||
val action: String,
|
||||
val detail: String? = null,
|
||||
val command: String? = null,
|
||||
val buttonLabel: String? = null,
|
||||
val onClick: (() -> Unit)? = null,
|
||||
)
|
||||
|
||||
private fun setupSteps(onRequestCallScreening: () -> Unit) = listOf(
|
||||
Step("Use a car from 2020 or earlier.", "Newer head units reject the certificate."),
|
||||
Step(
|
||||
"On GrapheneOS, exempt Gearslip from exploit protections.",
|
||||
"Open Settings, then Apps, then Gearslip, and turn on Exploit protection compatibility mode.",
|
||||
),
|
||||
Step("Disable or uninstall the Android Auto app."),
|
||||
Step("Load a certificate.", "Open Settings, then download or import a certificate."),
|
||||
Step(
|
||||
"Allow the microphone, so music can reach the car.",
|
||||
"Android asks for the microphone before it will let any app pass music along. " +
|
||||
"Gearslip never listens to you: it copies what a music app is playing, and nothing else.",
|
||||
),
|
||||
Step(
|
||||
"Optional: stop Android asking about audio every drive.",
|
||||
"Connect the phone to a computer with USB debugging turned on, and run this command. " +
|
||||
"It tells Android to trust Gearslip with audio from now on. Skip it and everything " +
|
||||
"still works, but you have to tap Start once each time you set off.",
|
||||
command = "adb shell appops set app.seb3thehacker.gearslip PROJECT_MEDIA allow",
|
||||
),
|
||||
Step(
|
||||
"Optional: keep notifications readable while music plays.",
|
||||
"Android treats sending audio to the car like sharing your screen, and hides what your " +
|
||||
"notifications say. Run this command too, and Gearslip switches that off only while " +
|
||||
"music is going to the car, then puts it back.",
|
||||
command = "adb shell pm grant app.seb3thehacker.gearslip android.permission.WRITE_SECURE_SETTINGS",
|
||||
),
|
||||
Step(
|
||||
"Optional: let the car screen answer and decline calls.",
|
||||
"This sets Gearslip as your phone's caller ID and spam-blocking app, the same kind of " +
|
||||
"consent a call-blocking app asks for. Without it, calls still ring, but only the " +
|
||||
"phone itself can answer or decline them.",
|
||||
buttonLabel = "Set up",
|
||||
onClick = onRequestCallScreening,
|
||||
),
|
||||
Step("Plug the phone into the car.", "Gearslip opens by itself when the head unit connects."),
|
||||
)
|
||||
|
||||
/** The full setup reference, behind the help button: what the one-time guide walks through, kept
|
||||
* here too so it can be reread without repeating the guide itself. */
|
||||
@Composable
|
||||
fun HelpScreen(onBack: () -> Unit, onRequestCallScreening: () -> Unit) {
|
||||
val steps = remember(onRequestCallScreening) { setupSteps(onRequestCallScreening) }
|
||||
|
||||
Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 24.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClick = onBack) { Text("‹", style = MaterialTheme.typography.headlineMedium) }
|
||||
Text(
|
||||
"How to connect",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Column(verticalArrangement = Arrangement.spacedBy(18.dp)) {
|
||||
steps.forEachIndexed { index, step ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Text(
|
||||
"${index + 1}",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.width(16.dp),
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(step.action, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold)
|
||||
step.detail?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
step.command?.let { CopyableCommand(it) }
|
||||
if (step.onClick != null) {
|
||||
FilledTonalButton(onClick = step.onClick, modifier = Modifier.padding(top = 4.dp)) {
|
||||
Text(step.buttonLabel ?: "Open")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The small circular "?" button that opens [HelpScreen] from the top right of the home screen. */
|
||||
@Composable
|
||||
fun HelpButton(onClick: () -> Unit) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
) {
|
||||
Box(Modifier.padding(10.dp), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
"?",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,16 +18,21 @@ import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import app.seb3thehacker.gearslip.AppSettings
|
||||
import app.seb3thehacker.gearslip.SessionStatus
|
||||
|
||||
@Composable
|
||||
@@ -35,9 +40,12 @@ fun HomeScreen(
|
||||
onOpenLogs: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenCarPreview: () -> Unit,
|
||||
onOpenHelp: () -> Unit,
|
||||
onDisconnect: () -> Unit,
|
||||
) {
|
||||
val status by SessionStatus.state.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
var debug by remember { mutableStateOf(AppSettings.debugMode(context)) }
|
||||
|
||||
Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding ->
|
||||
Column(
|
||||
@@ -52,19 +60,44 @@ fun HomeScreen(
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
"Gearslip",
|
||||
style = MaterialTheme.typography.displayLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Text(
|
||||
"v${LocalContext.current.appVersionName()}",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Column {
|
||||
Text(
|
||||
"Gearslip",
|
||||
style = MaterialTheme.typography.displayLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Text(
|
||||
"v${LocalContext.current.appVersionName()}",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
HelpButton(onClick = onOpenHelp)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
StatusCard(status)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Debug mode", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
if (debug) "Full protocol logging, live logs and car preview."
|
||||
else "Quiet logging. Warnings and errors are still recorded.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(checked = debug, onCheckedChange = {
|
||||
debug = it
|
||||
AppSettings.setDebugMode(context, it)
|
||||
})
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
@@ -80,7 +113,7 @@ fun HomeScreen(
|
||||
Text("Disconnect")
|
||||
}
|
||||
}
|
||||
if (!live) {
|
||||
if (!live && debug) {
|
||||
FilledTonalButton(
|
||||
onClick = onOpenCarPreview,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
@@ -89,8 +122,10 @@ fun HomeScreen(
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
FilledTonalButton(onClick = onOpenLogs, modifier = Modifier.weight(1f).height(56.dp)) {
|
||||
Text("Live logs")
|
||||
if (debug) {
|
||||
FilledTonalButton(onClick = onOpenLogs, modifier = Modifier.weight(1f).height(56.dp)) {
|
||||
Text("Live logs")
|
||||
}
|
||||
}
|
||||
OutlinedButton(onClick = onOpenSettings, modifier = Modifier.weight(1f).height(56.dp)) {
|
||||
Text("Settings")
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -17,7 +25,6 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -78,20 +85,34 @@ fun LogsScreen(onBack: () -> Unit) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
TextButton(onClick = {
|
||||
scope.launch {
|
||||
val text = withContext(Dispatchers.IO) { GearslipLog.fullText() }
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceContainer) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().navigationBarsPadding().padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
val uri = withContext(Dispatchers.IO) { GearslipLog.shareableFile(context) }
|
||||
val send = Intent(Intent.ACTION_SEND)
|
||||
.setType("text/plain")
|
||||
.putExtra(Intent.EXTRA_SUBJECT, "Gearslip log")
|
||||
.putExtra(Intent.EXTRA_TEXT, text)
|
||||
.putExtra(Intent.EXTRA_STREAM, uri)
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
context.startActivity(Intent.createChooser(send, "Share log"))
|
||||
}
|
||||
}) { Text("Share") }
|
||||
TextButton(onClick = { GearslipLog.clearLive() }) { Text("Clear") }
|
||||
},
|
||||
)
|
||||
},
|
||||
modifier = Modifier.weight(1f).height(56.dp),
|
||||
) { Text("Share") }
|
||||
OutlinedButton(
|
||||
onClick = { GearslipLog.clearLive() },
|
||||
modifier = Modifier.weight(1f).height(56.dp),
|
||||
) { Text("Clear") }
|
||||
}
|
||||
}
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (!follow && lines.isNotEmpty()) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -124,6 +125,26 @@ private fun CertificateSection() {
|
||||
Button(onClick = { picker.launch(arrayOf("*/*")) }, modifier = Modifier.fillMaxWidth().height(52.dp)) {
|
||||
Text("Import certificate (.p12)")
|
||||
}
|
||||
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
runCatching { CertProvider.downloadAndImport(context) }
|
||||
}
|
||||
result.onSuccess {
|
||||
notice = "Certificate downloaded."
|
||||
refresh++
|
||||
}.onFailure {
|
||||
notice = "Download failed: ${it.message}"
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
) {
|
||||
Text("Download certificate")
|
||||
}
|
||||
|
||||
if (summary?.kind == CertProvider.Kind.IMPORTED) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
@@ -134,9 +155,21 @@ private fun CertificateSection() {
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
) { Text("Remove imported certificate") }
|
||||
}
|
||||
|
||||
if (summary?.kind == CertProvider.Kind.DOWNLOADED) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
CertProvider.removeDownloaded(context)
|
||||
notice = "Removed the downloaded certificate."
|
||||
refresh++
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
) { Text("Remove downloaded certificate") }
|
||||
}
|
||||
|
||||
notice?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
Text(
|
||||
"An imported certificate takes priority over one staged over adb. Takes effect on the next connection.",
|
||||
"Changes take effect on the next connection.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.app.AppOpsManager
|
||||
import android.app.NotificationManager
|
||||
import android.app.role.RoleManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.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.material3.Button
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.AppSettings
|
||||
import app.seb3thehacker.gearslip.call.CarCalls
|
||||
import app.seb3thehacker.gearslip.car.CarSettings
|
||||
import app.seb3thehacker.gearslip.car.Prefetch
|
||||
import app.seb3thehacker.gearslip.host.AndroidAuto
|
||||
import app.seb3thehacker.gearslip.notify.GearslipNotificationListener
|
||||
|
||||
/**
|
||||
* One page of the guide. [permissions] drives a system permission dialog; [command] is an adb
|
||||
* command to copy; [actionLabel] with [onAction] is a button that does something else (opens a
|
||||
* settings screen) without by itself finishing the page. Everything is skippable: none of this
|
||||
* can be verified as done, only offered.
|
||||
*/
|
||||
private class GuidePage(
|
||||
val title: String,
|
||||
val explanation: String,
|
||||
val permissions: Array<String>? = null,
|
||||
val command: String? = null,
|
||||
val actionLabel: String? = null,
|
||||
val onAction: (() -> Unit)? = null,
|
||||
)
|
||||
|
||||
/** True once every permission in [permissions] is already granted - nothing left for this page to do. */
|
||||
private fun granted(context: Context, permissions: Array<String>): Boolean =
|
||||
permissions.all { context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }
|
||||
|
||||
/** Whether Gearslip already holds the hidden `PROJECT_MEDIA` app-op an adb command grants -
|
||||
* checked the same way the platform checks it, since there is no public permission for it. */
|
||||
private fun hasProjectMediaOp(context: Context): Boolean {
|
||||
val ops = context.getSystemService(AppOpsManager::class.java) ?: return false
|
||||
val mode = runCatching {
|
||||
ops.unsafeCheckOpNoThrow("android:project_media", android.os.Process.myUid(), context.packageName)
|
||||
}.getOrDefault(AppOpsManager.MODE_IGNORED)
|
||||
return mode == AppOpsManager.MODE_ALLOWED
|
||||
}
|
||||
|
||||
private fun hasCallScreeningRole(context: Context): Boolean {
|
||||
val roleManager = context.getSystemService(RoleManager::class.java) ?: return false
|
||||
return runCatching { roleManager.isRoleHeld(RoleManager.ROLE_CALL_SCREENING) }.getOrDefault(false)
|
||||
}
|
||||
|
||||
/** Whether the driver has already granted Gearslip's [GearslipNotificationListener] access. */
|
||||
private fun hasNotificationAccess(context: Context): Boolean {
|
||||
val manager = context.getSystemService(NotificationManager::class.java) ?: return false
|
||||
val component = ComponentName(context, GearslipNotificationListener::class.java)
|
||||
return runCatching { manager.isNotificationListenerAccessGranted(component) }.getOrDefault(false)
|
||||
}
|
||||
|
||||
private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<GuidePage> = buildList {
|
||||
// --- Permissions: skipped outright once already granted, nothing left to ask -----
|
||||
if (CarSettings.pipeAudio.value && !granted(context, arrayOf(Manifest.permission.RECORD_AUDIO))) {
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Microphone",
|
||||
explanation = "Android will not pass a music app's audio to the car without this " +
|
||||
"permission. Gearslip does not listen to you: it copies what is already " +
|
||||
"playing, nothing else.",
|
||||
permissions = arrayOf(Manifest.permission.RECORD_AUDIO),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (!granted(context, arrayOf(Manifest.permission.ACCESS_FINE_LOCATION))) {
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Location",
|
||||
explanation = "The car's map and nearby places need to know where you are. " +
|
||||
"Without it, the dashboard has no map card.",
|
||||
permissions = arrayOf(Manifest.permission.ACCESS_FINE_LOCATION),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (!granted(context, arrayOf(Manifest.permission.READ_CALENDAR))) {
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Calendar",
|
||||
explanation = "Gearslip reads your next event to show an agenda card on the car " +
|
||||
"screen. It only reads; it never edits or deletes anything.",
|
||||
permissions = arrayOf(Manifest.permission.READ_CALENDAR),
|
||||
),
|
||||
)
|
||||
}
|
||||
val callPermissions = arrayOf(
|
||||
Manifest.permission.READ_PHONE_STATE,
|
||||
Manifest.permission.READ_CALL_LOG,
|
||||
Manifest.permission.READ_CONTACTS,
|
||||
Manifest.permission.ANSWER_PHONE_CALLS,
|
||||
Manifest.permission.CALL_PHONE,
|
||||
)
|
||||
if (!granted(context, callPermissions)) {
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Phone and contacts",
|
||||
explanation = "When a call comes in, Gearslip matches the number against your " +
|
||||
"contacts, so the car screen can show a name instead of just digits. The " +
|
||||
"same permissions let the car screen's Phone app dial a number or call " +
|
||||
"someone from your contacts.",
|
||||
permissions = callPermissions,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (!hasNotificationAccess(context)) {
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Notification access",
|
||||
explanation = "The car screen shows your phone's notifications and lets you " +
|
||||
"reply from there. Android only allows that once you grant Gearslip access, " +
|
||||
"from the list this opens - it needs this to work at all.",
|
||||
actionLabel = "Open notification access",
|
||||
onAction = {
|
||||
context.startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// --- GrapheneOS ------------------------------------------------------------------
|
||||
add(
|
||||
GuidePage(
|
||||
title = "GrapheneOS exploit protection",
|
||||
explanation = "On GrapheneOS only: open Gearslip's app settings and turn on Exploit " +
|
||||
"protection compatibility mode. Gearslip's native USB and video code trips " +
|
||||
"GrapheneOS's hardened memory checks otherwise. Skip this on any other Android.",
|
||||
actionLabel = "Open app settings",
|
||||
onAction = {
|
||||
context.startActivity(
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)),
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
// --- Android Auto ------------------------------------------------------------------
|
||||
val androidAutoPackage = AndroidAuto.installedPackage(context)
|
||||
?.takeIf { AndroidAuto.isActive(context, it) }
|
||||
if (androidAutoPackage != null) {
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Remove Android Auto",
|
||||
explanation = "Android Auto is still installed. It claims the same USB " +
|
||||
"connection Gearslip needs, so the two fight over it. Disable or uninstall " +
|
||||
"it from the app info screen this opens.",
|
||||
actionLabel = "Open Android Auto's app info",
|
||||
onAction = {
|
||||
context.startActivity(
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", androidAutoPackage, null)),
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// --- Default handler ---------------------------------------------------------------
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Make Gearslip the default",
|
||||
explanation = "Android has no separate default-app setting for a USB accessory like " +
|
||||
"the car. It just launches whichever single app matches - which is Gearslip, " +
|
||||
"now that Android Auto is out of the way. If a chooser still appears when you " +
|
||||
"plug in, pick Gearslip and tap Always so Android stops asking.",
|
||||
),
|
||||
)
|
||||
|
||||
// --- Optional: each is skipped once its actual effect is already in place --------
|
||||
if (!hasProjectMediaOp(context)) {
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Optional: trust audio every drive",
|
||||
explanation = "Connect the phone to a computer with USB debugging on, and run this " +
|
||||
"command. It tells Android to trust Gearslip with audio from now on. Skip it " +
|
||||
"and everything still works, but you tap Start once each time you set off.",
|
||||
command = "adb shell appops set app.seb3thehacker.gearslip PROJECT_MEDIA allow",
|
||||
),
|
||||
)
|
||||
}
|
||||
if (!granted(context, arrayOf(Manifest.permission.WRITE_SECURE_SETTINGS))) {
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Optional: keep notifications readable",
|
||||
explanation = "Android treats sending audio to the car like sharing your screen, and " +
|
||||
"hides what your notifications say. This command switches that off only while " +
|
||||
"music is going to the car, then puts it back.",
|
||||
command = "adb shell pm grant app.seb3thehacker.gearslip android.permission.WRITE_SECURE_SETTINGS",
|
||||
),
|
||||
)
|
||||
}
|
||||
if (!hasCallScreeningRole(context)) {
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Optional: answer and decline calls",
|
||||
explanation = "This sets Gearslip as your phone's caller ID and spam-blocking app, " +
|
||||
"the same kind of consent a call-blocking app asks for. Without it, calls still " +
|
||||
"ring, but only the phone itself can answer or decline them.",
|
||||
actionLabel = "Set up",
|
||||
onAction = onRequestCallScreening,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The first-run guide, one page at a time: permissions, then GrapheneOS's exploit-protection
|
||||
* toggle, then removing Android Auto if it is still installed, then the optional extras. Nothing
|
||||
* here can be verified as done except a permission grant, so every other page is a "Next" a
|
||||
* driver takes on trust, with "Skip" sitting right next to it.
|
||||
*/
|
||||
@Composable
|
||||
fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val pages = remember { pages(context, onRequestCallScreening) }
|
||||
var index by remember { mutableIntStateOf(0) }
|
||||
|
||||
// Only ours to handle once there is a previous page to return to; at the first page, the
|
||||
// system back press falls through to whatever the screen beneath this one does with it.
|
||||
BackHandler(enabled = index > 0) { index -= 1 }
|
||||
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions(),
|
||||
) { result ->
|
||||
if (result.values.any { it }) {
|
||||
Prefetch.warm(context, force = true)
|
||||
CarCalls.start(context)
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
|
||||
val page = pages.getOrNull(index)
|
||||
if (page == null) {
|
||||
AppSettings.setSeenPermissionsSetup(context)
|
||||
onDone()
|
||||
return
|
||||
}
|
||||
|
||||
val alreadyGranted = page.permissions?.all {
|
||||
context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED
|
||||
} ?: false
|
||||
|
||||
Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
LinearProgressIndicator(
|
||||
progress = { (index + 1f) / pages.size },
|
||||
modifier = Modifier.fillMaxWidth().height(10.dp).clip(MaterialTheme.shapes.extraLarge),
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
"${index + 1} of ${pages.size}",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(48.dp))
|
||||
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceContainerLow, shape = MaterialTheme.shapes.extraLarge) {
|
||||
Column(Modifier.fillMaxWidth().padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
page.title,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
page.explanation,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
page.command?.let { CopyableCommand(it) }
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
|
||||
when {
|
||||
page.permissions != null && alreadyGranted -> {
|
||||
Text(
|
||||
"Already allowed.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
PrimaryRow(showBack = index > 0, onBack = { index -= 1 }) {
|
||||
Button(onClick = { index += 1 }, modifier = it) { Text("Next") }
|
||||
}
|
||||
}
|
||||
page.permissions != null -> {
|
||||
PrimaryRow(showBack = index > 0, onBack = { index -= 1 }) {
|
||||
Button(onClick = { launcher.launch(page.permissions) }, modifier = it) { Text("Allow") }
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
TextButton(onClick = { index += 1 }) { Text("Skip for now") }
|
||||
}
|
||||
page.onAction != null -> {
|
||||
FilledTonalButton(
|
||||
onClick = page.onAction,
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
) { Text(page.actionLabel ?: "Open") }
|
||||
Spacer(Modifier.height(8.dp))
|
||||
PrimaryRow(showBack = index > 0, onBack = { index -= 1 }) {
|
||||
Button(onClick = { index += 1 }, modifier = it) { Text("Next") }
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
PrimaryRow(showBack = index > 0, onBack = { index -= 1 }) {
|
||||
Button(onClick = { index += 1 }, modifier = it) { Text("Next") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The bottom action row: a lower-emphasis Back on the left once there is a previous page, and the
|
||||
* page's own primary button - passed [content] so the caller keeps the button's own label and
|
||||
* click behavior - filling the rest of the width.
|
||||
*/
|
||||
@Composable
|
||||
private fun PrimaryRow(showBack: Boolean, onBack: () -> Unit, content: @Composable (Modifier) -> Unit) {
|
||||
if (showBack) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = onBack,
|
||||
modifier = Modifier.weight(1f).height(52.dp),
|
||||
) { Text("Back") }
|
||||
content(Modifier.weight(1f).height(52.dp))
|
||||
}
|
||||
} else {
|
||||
content(Modifier.fillMaxWidth().height(52.dp))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<!-- Only the log copies made for sharing; nothing else in the app is exposed. -->
|
||||
<cache-path name="shared_logs" path="shared/" />
|
||||
</paths>
|
||||
@@ -0,0 +1,94 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Parses the ServiceDiscoveryResponse the 2018 Uconnect actually sent, rebuilt field for field
|
||||
* from the decoded tree in `archive/spike/car-run-2026-09-16-run2.log`.
|
||||
*
|
||||
* The car offers two audio sinks - media on channel 5 and guidance on channel 6 - alongside the
|
||||
* video sink on channel 1 and a microphone source on channel 3. Picking the right one out of that
|
||||
* set is the whole job of [ServiceDiscovery.findAudioServices], and getting it wrong is silent:
|
||||
* audio simply never arrives.
|
||||
*/
|
||||
class ServiceDiscoveryAudioTest {
|
||||
|
||||
private fun varint(value: Long): ByteArray = Protobuf.varint(value)
|
||||
|
||||
private fun varintField(field: Int, value: Long) = Protobuf.varintField(field, value)
|
||||
|
||||
private fun lenField(field: Int, body: ByteArray): ByteArray =
|
||||
varint(((field shl 3) or 2).toLong()) + varint(body.size.toLong()) + body
|
||||
|
||||
/** AudioConfiguration { sampling_rate = 1; bits = 2; channels = 3 } */
|
||||
private fun audioConfig(rate: Int, bits: Int, channels: Int) =
|
||||
varintField(1, rate.toLong()) + varintField(2, bits.toLong()) + varintField(3, channels.toLong())
|
||||
|
||||
/** Service { id = 1; media_sink_service = 3 } carrying an audio sink. */
|
||||
private fun audioSink(id: Int, codec: Int, audioType: Int, config: ByteArray) =
|
||||
lenField(
|
||||
1,
|
||||
varintField(1, id.toLong()) +
|
||||
lenField(3, varintField(1, codec.toLong()) + varintField(2, audioType.toLong()) + lenField(3, config)),
|
||||
)
|
||||
|
||||
/** Channel 1: the H.264 video sink, which must not be mistaken for an audio one. */
|
||||
private fun videoSink(): ByteArray {
|
||||
val videoConfig = varintField(1, 2L) + varintField(2, 2L) + varintField(3, 0L) +
|
||||
varintField(4, 12L) + varintField(5, 240L) + varintField(6, 0L) + varintField(8, 10_000L)
|
||||
return lenField(
|
||||
1,
|
||||
varintField(1, 1L) + lenField(3, varintField(1, 3L) + lenField(4, videoConfig)),
|
||||
)
|
||||
}
|
||||
|
||||
/** Channel 3: the car's microphone, a media *source* (field 5), not a sink. */
|
||||
private fun micSource(): ByteArray = lenField(
|
||||
1,
|
||||
varintField(1, 3L) + lenField(5, varintField(1, 1L) + lenField(2, audioConfig(16_000, 16, 1))),
|
||||
)
|
||||
|
||||
private fun uconnectResponse(): ByteArray =
|
||||
videoSink() +
|
||||
micSource() +
|
||||
audioSink(id = 5, codec = 1, audioType = 3, config = audioConfig(48_000, 16, 2)) +
|
||||
audioSink(id = 6, codec = 1, audioType = 1, config = audioConfig(16_000, 16, 1)) +
|
||||
Protobuf.stringField(2, "Uconnect") +
|
||||
Protobuf.stringField(3, "Dodge Challenger") +
|
||||
Protobuf.stringField(4, "18")
|
||||
|
||||
@Test
|
||||
fun `finds both of the car's audio sinks and neither the video sink nor the microphone`() {
|
||||
val sinks = ServiceDiscovery.findAudioServices(uconnectResponse())
|
||||
|
||||
assertEquals(listOf(5, 6), sinks.map { it.serviceId })
|
||||
assertEquals(listOf("MEDIA", "GUIDANCE"), sinks.map { it.streamName })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the media sink is PCM at 48kHz stereo`() {
|
||||
val media = ServiceDiscovery.findAudioServices(uconnectResponse()).first { it.streamType == 3 }
|
||||
|
||||
assertEquals("AUDIO_PCM", media.codecName)
|
||||
assertEquals(1, media.configs.size)
|
||||
assertEquals(48_000, media.configs[0].sampleRate)
|
||||
assertEquals(16, media.configs[0].bits)
|
||||
assertEquals(2, media.configs[0].channels)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the guidance sink is mono at 16kHz, so it must not be used for music`() {
|
||||
val guidance = ServiceDiscovery.findAudioServices(uconnectResponse()).first { it.streamType == 1 }
|
||||
|
||||
assertEquals(16_000, guidance.configs[0].sampleRate)
|
||||
assertEquals(1, guidance.configs[0].channels)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the video sink is still found alongside the audio ones`() {
|
||||
assertNotNull(ServiceDiscovery.findVideoService(uconnectResponse()))
|
||||
assertEquals(1, ServiceDiscovery.findVideoService(uconnectResponse())!!.serviceId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
# Copy to keystore.properties and fill in. That file is gitignored; this one is not, so never
|
||||
# put real passwords here.
|
||||
#
|
||||
# storeFile is resolved from the repository root.
|
||||
storeFile=gearslip-release.jks
|
||||
storePassword=
|
||||
keyAlias=gearslip
|
||||
keyPassword=
|
||||
@@ -25,4 +25,5 @@ dependencyResolutionManagement {
|
||||
|
||||
rootProject.name = "Gearslip"
|
||||
include(":gearslip")
|
||||
// include(":ksdump")
|
||||
|
||||
Reference in new issue
Block a user