Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ac389d2fd | ||
|
|
95fddf5a55 | ||
|
|
206dd67d98 | ||
|
|
817b413eb1 | ||
|
|
03f27c4dda | ||
|
|
42b151034a | ||
|
|
7a774b4091 | ||
|
|
2b8b221d1b | ||
|
|
dce8ae9041 | ||
|
|
7c6f36cdbb | ||
|
|
71d8af5128 | ||
|
|
4172cf1a0a | ||
|
|
bcd9a87174 | ||
|
|
fed735b5a2 | ||
|
|
97555396c0 | ||
|
|
d42bc0d00a | ||
|
|
c33875c8f8 | ||
|
|
a70dea7bd5 | ||
|
|
c07d73cc92 | ||
|
|
962f5ff2c4 | ||
|
|
c7fa32d393 | ||
|
|
3c415fb953 | ||
|
|
75f43c3e3d | ||
|
|
07e398cf79 | ||
|
|
cc78020eda | ||
|
|
1faf418798 | ||
|
|
ef1b811920 | ||
|
|
fa89017521 | ||
|
|
88c85cc871 | ||
|
|
86e61bcc1d | ||
|
|
9ddab16645 | ||
|
|
0a5d0792d6 | ||
|
|
d302caf3a4 | ||
|
|
ea64329f2f | ||
|
|
44d9de2498 | ||
|
|
7991731d4b | ||
|
|
776207eac7 | ||
|
|
296ae063e7 | ||
|
|
4310e7b70d | ||
|
|
0d6b8baf64 | ||
|
|
b1e7138e62 | ||
|
|
16c949b414 | ||
|
|
4a40f782f7 | ||
|
|
e982e75155 | ||
|
|
13139923b2 | ||
|
|
bf2213e4e6 | ||
|
|
bbe734bb14 | ||
|
|
7733a4042e | ||
|
|
5a8bd5a8ac | ||
|
|
b13abee9e0 | ||
|
|
8d61e3245a | ||
|
|
f9eadefc2f | ||
|
|
303958f2b2 | ||
|
|
253cc5cad5 | ||
|
|
3f25122fb6 | ||
|
|
a1a7eca5cb | ||
|
|
588d13c3a8 | ||
|
|
a1dc9a6519 | ||
|
|
3e2e410115 | ||
|
|
08a7af4476 | ||
|
|
dc8544794e | ||
|
|
aadca6019e | ||
|
|
1e3d4f5188 | ||
|
|
83256224d4 | ||
|
|
cae1b06d05 | ||
|
|
db90c72584 | ||
|
|
96821e27e3 | ||
|
|
89ab0f9d63 | ||
|
|
4fb612f602 | ||
|
|
0234cbdc10 | ||
|
|
167933862a | ||
|
|
9bf0911f39 | ||
|
|
4acd1bb569 | ||
|
|
726ca3eddd | ||
|
|
89dd28bd62 | ||
|
|
e9d4203d58 | ||
|
|
327d6fd40a | ||
|
|
37d56be59a | ||
|
|
40313843d1 | ||
|
|
60afbf1dd2 | ||
|
|
8b40f1576d | ||
|
|
14ef6676b9 | ||
|
|
9dca6d302c | ||
|
|
4ea466452b | ||
|
|
d0971bb62d | ||
|
|
fa0530499e | ||
|
|
d5b5fb8533 | ||
|
|
ad0b3807a8 | ||
|
|
b672f820b2 | ||
|
|
c45bfe597d | ||
|
|
a7f6440994 | ||
|
|
f6f72a9e66 | ||
|
|
a5ad7f4384 | ||
|
|
b82a09289c | ||
|
|
6705ade9ae | ||
|
|
df986cae6b | ||
|
|
734dae4836 | ||
|
|
966522cac4 | ||
|
|
cbb0e74cf1 | ||
|
|
406d97e288 | ||
|
|
36d8f987fa | ||
|
|
00d62caa03 | ||
|
|
f4a251d24f | ||
|
|
0a6da250ee | ||
|
|
fea63a092f | ||
|
|
d8694f1b97 | ||
|
|
497a4a1481 | ||
|
|
4cf9b3d9b1 | ||
|
|
0db0c64b6e | ||
|
|
f262b67e83 | ||
|
|
b3cacf0630 | ||
|
|
2e0cee654a | ||
|
|
a0cb34eb87 | ||
|
|
106b0c3987 | ||
|
|
a00f5c4896 | ||
|
|
f6ec5c7a8e | ||
|
|
2f49559600 | ||
|
|
57803988d4 | ||
|
|
bcde51ad56 | ||
|
|
cc522bb0b7 | ||
|
|
e4c2a72fad | ||
|
|
0627a32df7 | ||
|
|
5df2dace77 | ||
|
|
b428c54dd2 | ||
|
|
c0735ed65e | ||
|
|
89dbbee5ef | ||
|
|
fa8c13a6ec | ||
|
|
2912d24c32 | ||
|
|
f3ee10ce0c | ||
|
|
5805ca5471 | ||
|
|
10743b36a4 | ||
|
|
5da51873f6 | ||
|
|
8e41706239 | ||
|
|
8f0c4c4145 | ||
|
|
43193eaa8f | ||
|
|
92bee5e911 | ||
|
|
e13bb2af24 | ||
|
|
4b50be2e73 | ||
|
|
3573999d36 | ||
|
|
a071ee072e | ||
|
|
55c6924eb3 | ||
|
|
8193d97453 | ||
|
|
660b840278 | ||
|
|
614459d9f7 | ||
|
|
aafa6ae593 | ||
|
|
95b12c7225 | ||
|
|
bfbabfe8fc | ||
|
|
d8e4dcf228 | ||
|
|
7feb42211e | ||
|
|
27262719b1 | ||
|
|
a29706f2e7 | ||
|
|
feb104f2eb | ||
|
|
c6f0547943 | ||
|
|
ee1f5d2aa6 | ||
|
|
878b0f64cc | ||
|
|
298ce1cd1f | ||
|
|
2b6ce5c7ce | ||
|
|
04e99562e3 | ||
|
|
9439b2d7d0 | ||
|
|
96b93513dd | ||
|
|
d114108f19 | ||
|
|
67d88ae7b9 | ||
|
|
bfe9723457 | ||
|
|
55b497190f | ||
|
|
65c630585a | ||
|
|
560c6efab0 | ||
|
|
d4d9289034 | ||
|
|
41da785965 | ||
|
|
cb30a3de69 | ||
|
|
4aac815b2e | ||
|
|
d915b0ce45 | ||
|
|
746b0b7325 | ||
|
|
de370751a0 | ||
|
|
e1948a823f | ||
|
|
738b99ff9e | ||
|
|
7959b27d38 | ||
|
|
83c33eeea3 | ||
|
|
b168eb7c04 | ||
|
|
f4b8391fa3 | ||
|
|
d1f67bb64d | ||
|
|
7d02ac8b75 | ||
|
|
6ff59be6ab | ||
|
|
dbeaa9cf5e | ||
|
|
7bfb8c8669 | ||
|
|
3fbabfa0ed | ||
|
|
0fd7568536 | ||
|
|
02618dbcb9 | ||
|
|
dc9514dcfa | ||
|
|
74f557524f | ||
|
|
324112ca8b | ||
|
|
15d6721705 | ||
|
|
1f9ef552c1 | ||
|
|
608d72b856 | ||
|
|
08eef32e59 | ||
|
|
894712c38d | ||
|
|
ef1b2e1be7 | ||
|
|
93a1ff7485 | ||
|
|
8a2dae4173 | ||
|
|
a1a0546b74 | ||
|
|
c616f0203a | ||
|
|
08ac6fe117 | ||
|
|
46c2e92684 | ||
|
|
b2a95adb87 | ||
|
|
9d629c392b | ||
|
|
0eea09542f | ||
|
|
5c72a18f06 | ||
|
|
5cc1f518c7 | ||
|
|
3f393fd56f | ||
|
|
703d8d69e8 | ||
|
|
89e5bbd2bb |
No files matched your search
@@ -1,20 +1,13 @@
|
||||
Gearslip 0.3.12 gives the car screen a fresh look and fixes the wireless loop 0.3.02 caused on some head units.
|
||||
Gearslip 0.3.40 is a test build for a few cars that still don't connect.
|
||||
|
||||
## New
|
||||
- Navigation cards sit down the left side, with a bigger turn card that shows your lane. The map keeps the right side.
|
||||
- The car screen and the phone app take their colors from your phone's Material You palette.
|
||||
- The car's settings sit on the phone's home screen, each group on its own screen.
|
||||
- Put the home screen's player left or right of the map.
|
||||
- The browser shows the page title, and it has an address bar, forward, reload, and a start page.
|
||||
- If Gearslip lacks call permissions, the car's Phone screen asks for them on the phone.
|
||||
|
||||
## Changed
|
||||
- Gearslip uses Car App Library 1.8. Map apps show the right turn arrows and route times, and Vela works fully.
|
||||
- Setup ends with recommended apps, then the button picker, then usage notes.
|
||||
If your car works on 0.3.39, you don't need this one. If you're following #18, #39 or #57, please try it and attach a log to your issue: open Gearslip, tap Logs, then Share log.
|
||||
|
||||
## Fixed
|
||||
- Some head units, such as ZLink adapters, sent their Wi-Fi details twice. Gearslip rejoined the network each time and dropped the connection. It now ignores the repeat.
|
||||
- Gearslip no longer crashes when a wireless connection ends.
|
||||
- Opening a second music app without playing anything keeps the player on the song that's still playing.
|
||||
- Vela's speed sign is full size again.
|
||||
- RF Sentinel and other non-media car apps get their own tile in the launcher.
|
||||
- A car that holds back its screen is asked for it again with the detail older cars expect. This is for the 2017 Pilot (#57) and other cars on the oldest Android Auto version.
|
||||
- Music no longer carries on from the phone's speaker after you turn the car off. An app that started playing again by itself is paused a second time (#39).
|
||||
- A liked song lights up the whole like button, as shuffle and repeat do. Before, only the heart filled in for apps such as Metrolist.
|
||||
|
||||
## Testing
|
||||
- GM cars open two more channels, Bluetooth and Wi-Fi projection, and log what the car sends on them. This is to find out why the 2021 Bolt hangs up about 20 seconds in (#18). If you drive a GM car, type your phone's Bluetooth address under Settings > Connection > Phone's Bluetooth address. You'll find it in Android's Settings under About phone.
|
||||
|
||||
Help translate Gearslip on [Weblate](https://hosted.weblate.org/engage/gearslip/).
|
||||
@@ -0,0 +1,18 @@
|
||||
name: Translations
|
||||
|
||||
# Weblate is the only thing that writes translated strings: it pushes straight to experimental,
|
||||
# never through a pull request. A PR that edits them collides with Weblate's own copy, and
|
||||
# Weblate locks itself until someone resets it. Edit the English strings.xml only.
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "gearslip/src/main/res/values-*/strings.xml"
|
||||
|
||||
jobs:
|
||||
weblate-only:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Translations go through Weblate
|
||||
run: |
|
||||
echo "::error::This pull request edits a translation. Please make translation changes on Weblate instead: https://hosted.weblate.org/engage/gearslip/ (it can upload a whole strings.xml under Files > Upload translation). See CONTRIBUTING.md."
|
||||
exit 1
|
||||
@@ -23,6 +23,7 @@ and some don't say.
|
||||
| Jeep Cherokee | 2019 | Uconnect 8.4 (Delphi) |
|
||||
| MG | 2019 | Desay SV |
|
||||
| Ram 1500 | 2018 | Uconnect 8.4 (Panasonic) |
|
||||
| Renault Clio V | 2019-2024 | Easy Link |
|
||||
| Škoda | 2018 | MIB Standard2 (TechniSat) |
|
||||
| Subaru Outback | 2020 | Denso |
|
||||
| Toyota RAV4 | 2020 | Pioneer T1 |
|
||||
|
||||
@@ -88,6 +88,9 @@ We are not required to use or include any Contribution.
|
||||
Most contributors sign on GitHub: the CLA Assistant bot comments on your first pull request,
|
||||
and you sign there with one click. Your GitHub account then serves as your signature.
|
||||
|
||||
Translators sign on Weblate: it shows this agreement before your first translation, and
|
||||
accepting it there serves as your signature.
|
||||
|
||||
To sign by hand instead, fill in the following and send it to us on Matrix. The address is
|
||||
on the [Seb3thehacker GitHub profile](https://github.com/Seb3thehacker).
|
||||
|
||||
|
||||
@@ -38,6 +38,24 @@ branch on `experimental`, and open your pull request against it.
|
||||
- Gearslip asks for no adb, root, or Shizuku. A feature must work with ordinary Android
|
||||
permissions and on-device consent dialogs.
|
||||
|
||||
## Translating
|
||||
|
||||
Translations happen on [Weblate](https://hosted.weblate.org/engage/gearslip/), never in pull
|
||||
requests. Pick your language, or start a new one, and translate in the browser. Weblate
|
||||
sends the finished work to this repository as a pull request.
|
||||
|
||||
Don't edit the translated `values-*/strings.xml` files by hand. Weblate keeps its own copy of
|
||||
every language, so a change made in git collides with Weblate's next update, and the pull
|
||||
request that made it gets closed. If a code change adds or rewords text, change only the
|
||||
English file, `values/strings.xml`; Weblate picks up the new strings for translators.
|
||||
|
||||
Keep the tone of the English: short, plain, and friendly. Leave placeholders such as `%1$s`
|
||||
and `%1$d` in place; Gearslip fills them with names and numbers. Untranslated strings show
|
||||
in English, so a partial translation is still useful.
|
||||
|
||||
Before your first translation, Weblate asks you to accept the
|
||||
[Contributor License Agreement](CLA.md). That counts as signing it.
|
||||
|
||||
## Writing
|
||||
|
||||
Text that users read, such as screens, release notes, and docs, follows Strunk's
|
||||
|
||||
@@ -9,13 +9,28 @@ An Android Auto alternative built for everyone.
|
||||
|
||||
[<img src="https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png" alt="Get it on Obtainium" height="80">](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://github.com/twinksoftware/gearslip)
|
||||
|
||||
[Fix or add your language on Weblate](https://hosted.weblate.org/engage/gearslip/).
|
||||
Translations go through Weblate only; pull requests that edit them get closed.
|
||||
|
||||
[](https://hosted.weblate.org/engage/gearslip/)
|
||||
|
||||
Questions, ideas?
|
||||
[Join the discussion on Matrix](https://matrix.to/#/!mf0_mgBd5KqKpc9FfX_wHp1jFCcwAtPrndIn_oGIyMk?via=matrix.org).
|
||||
|
||||
> [!WARNING]
|
||||
> Gearslip is **alpha software**. It can drop the connection, freeze the car screen, or fail
|
||||
> to start, and a new release can break a car that worked before. Set it up while parked, and
|
||||
> keep another way to navigate.
|
||||
|
||||
> [!WARNING]
|
||||
> On GrapheneOS, make sure **USB-C port control** isn't set to **Charging-only**. That setting
|
||||
> blocks USB data, so the car never sees the phone, even with a data cable. Set it to allow
|
||||
> data transfer.
|
||||
|
||||
## Which cars work
|
||||
|
||||
Most cars work. Starting around 2020, some cars added newer security standards that reject
|
||||
Gearslip's certificate, and Gearslip can't run in those cars. [CARS.md](CARS.md) lists the
|
||||
Most cars work. Starting around 2020, some cars added newer security checks that reject
|
||||
the aasdk certificate, and in those cars Gearslip needs a certificate of your own. [CARS.md](CARS.md) lists the
|
||||
cars drivers have confirmed.
|
||||
|
||||
## What Gearslip is
|
||||
@@ -29,14 +44,14 @@ screen-sharing mode.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
|  |  |
|
||||
| Your car apps in one place. A red X marks the ones that don't work yet. | Vela Maps navigating, with the player beside it. |
|
||||
|  |  |
|
||||
| Your car apps in one place. | Vela Maps navigating, with the player beside it. |
|
||||
|  |  |
|
||||
| Metrolist in Gearslip's player. | A new message, read aloud at a tap, over the map. |
|
||||
|  |  |
|
||||
| The weather, your week, and what you missed. | Browse the web while parked. |
|
||||
|  |  |
|
||||
| Lyrics light up word by word as the song plays. | Browse the web while parked. |
|
||||
|  |  |
|
||||
| Ask for a place, a song, or an app. | Pick a route to coffee nearby. Each one shows its time and distance. |
|
||||
| Ask for a place, a song, or an app. | Pick a route to a coffee shop. Each one shows its time and distance. |
|
||||
|
||||
## Features
|
||||
|
||||
@@ -44,9 +59,10 @@ screen-sharing mode.
|
||||
call goes out. When a call comes in, answer or decline it on the car screen. During a
|
||||
call, the nav bar shows who you're talking to, and you mute or hang up from there.
|
||||
- **Navigation.** Gearslip hosts real Car App Library map apps on the car screen.
|
||||
Organic Maps, Vela, OsmAnd, Flitsmeister, MAPS.ME, and MapQuest work today. While you
|
||||
Organic Maps, Vela, OsmAnd, Waze, Flitsmeister, MAPS.ME, and MapQuest work today. While you
|
||||
navigate, a big turn card on the left shows the arrow, the distance, and your lane, and
|
||||
the map keeps the right side.
|
||||
the map keeps the right side. Turns from Google Maps or Waze running on the phone show in
|
||||
the nav bar, and on the instrument cluster in cars with a screen there.
|
||||
- **Music.** Browse, play, and queue tracks from a car-enabled media app, with a Now
|
||||
Playing view, an Up Next queue, and lyrics. Type a search, and apps that accept one
|
||||
play the best match.
|
||||
@@ -63,11 +79,15 @@ screen-sharing mode.
|
||||
- **Notifications.** Only messages, missed calls, alarms, and reminders interrupt you.
|
||||
The rest wait on the dashboard.
|
||||
- **Your apps, your order.** The launcher groups apps by kind: phone, maps, music,
|
||||
messaging, and the rest. Press and hold an app to move it.
|
||||
messaging, and the rest. Press and hold an app to move it, and hide the ones you don't
|
||||
use from the Apps card on the phone.
|
||||
- **Car controls.** Steering wheel buttons, control knobs, and D-pads all work.
|
||||
- **Your phone's colors.** The car screen and the phone app take their colors from your
|
||||
phone's Material You palette, and every card and button matches.
|
||||
- **Themes.** Choose flat buttons or skeuomorphic ones that look like real keys.
|
||||
- **Small screens.** Car display density makes text and buttons bigger in car apps.
|
||||
- **Your language.** Gearslip comes in 19 languages besides English, some only partly
|
||||
translated. Pick one in Settings. Voice commands understand English only for now.
|
||||
- **Settings on the phone.** The car's settings sit on the phone's home screen too, so you
|
||||
can set things up before you drive. A change on either screen shows on both.
|
||||
|
||||
@@ -81,22 +101,28 @@ screen-sharing mode.
|
||||
then one permission at a time, each with a plain reason and an Allow or Skip. A help
|
||||
screen inside the car answers anything setup didn't cover.
|
||||
- **Templated apps work.** Gearslip renders the Car App Library's templates on the car
|
||||
screen, and it draws its own keyboard for search, sign-in, and text fields. It has been
|
||||
tested against several real navigation and media apps.
|
||||
screen, and it draws its own keyboard for search, sign-in, and text fields. Alerts, such
|
||||
as a map app asking whether a hazard is still there, show on the car screen too. It has
|
||||
been tested against several real navigation and media apps.
|
||||
- **Gearslip can be a head unit's home screen.** On a head unit that runs Android, or a
|
||||
spare phone or tablet on the dash, turn on Use as launcher in Settings > Connection. The
|
||||
car screen fills the display, and an All apps page opens everything else.
|
||||
- **Gearslip works with [LIVI](https://github.com/f-io/LIVI),** the open-source head unit
|
||||
used in homemade car dashboards.
|
||||
- **Some apps refuse Gearslip.** Spotify, Waze, and Google Messages use a version of
|
||||
- **Some apps refuse Gearslip.** Spotify and Google Messages use a version of
|
||||
Google's car library that accepts only Android Auto itself. Spotify still plays through
|
||||
Gearslip's own player. TIDAL lets only approved apps browse its library.
|
||||
- **Calls use the car's Bluetooth,** as they do in Android Auto. Pair the phone with the
|
||||
car to hear calls through its speakers.
|
||||
- **Music reaches the car over Bluetooth or USB.** Setup asks which. Bluetooth is the
|
||||
default: Android never asks for permission, and your map app's voice reaches the car
|
||||
too. USB sounds a little clearer, with no delay, but each drive Android asks you to
|
||||
share your screen, which lets Gearslip send the sound. Gearslip never reads the screen; capturing playback is
|
||||
the only way an ordinary app can pass another app's audio along.
|
||||
- **Music reaches the car over Bluetooth or through Gearslip.** Setup asks which.
|
||||
Bluetooth is the default: Android never asks for permission, and your map app's voice
|
||||
reaches the car too. Through Gearslip, the sound travels over the car connection, cable
|
||||
or wireless. It sounds a little clearer, with no delay, but each drive Android asks you
|
||||
to share your screen, which lets Gearslip send the sound. Gearslip never reads the
|
||||
screen; capturing playback is the only way an ordinary app can pass another app's audio
|
||||
along.
|
||||
- **Gearslip needs no adb, root, or Shizuku.** Every feature relies on an ordinary
|
||||
Android permission or an on-device consent dialog. For USB audio, setup offers two
|
||||
Android permission or an on-device consent dialog. For audio through Gearslip, setup offers two
|
||||
optional adb commands: one removes the audio dialog for good, and the other keeps
|
||||
notifications visible while audio plays.
|
||||
- **Updates come from GitHub.** Once a day, Gearslip checks this repository's latest
|
||||
@@ -126,7 +152,7 @@ turn them on, in setup or in Settings.
|
||||
those boxes cover: the Android version and whether the phone runs GrapheneOS, the phone
|
||||
model, whether a car connected that day, and each car's head unit, model, year, screen
|
||||
size, and connection result, with whether that drive used the cable or wireless and
|
||||
whether music went over USB or Bluetooth. If you tick it, how each drive went: how far
|
||||
whether music went through Gearslip or over Bluetooth. If you tick it, how each drive went: how far
|
||||
the connection got, how long it lasted, and how it ended. Settings shows the exact note. Untick a phone detail, and
|
||||
the server drops the copy it kept.
|
||||
- **What they leave out.** Your name, accounts, location, contacts, messages, and
|
||||
@@ -153,6 +179,11 @@ Bug reports from real cars help most, and pull requests are welcome.
|
||||
[CONTRIBUTING.md](CONTRIBUTING.md) explains what to put in a report, which branch to work
|
||||
on, and the one-time agreement you sign before your first pull request merges.
|
||||
|
||||
To help translate Gearslip into your language, join
|
||||
[Gearslip on Weblate](https://hosted.weblate.org/engage/gearslip/). No coding needed. Please
|
||||
don't send translations as pull requests: Weblate holds its own copy of every language, and
|
||||
changes made in git collide with it.
|
||||
|
||||
## Building
|
||||
|
||||
```
|
||||
|
||||
|
Before Width: | Height: | Size: 523 KiB After Width: | Height: | Size: 402 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 502 KiB |
|
Before Width: | Height: | Size: 373 KiB After Width: | Height: | Size: 313 KiB |
|
Before Width: | Height: | Size: 351 KiB After Width: | Height: | Size: 449 KiB |
|
Before Width: | Height: | Size: 397 KiB After Width: | Height: | Size: 420 KiB |
|
Before Width: | Height: | Size: 586 KiB After Width: | Height: | Size: 392 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 107 KiB |
@@ -38,9 +38,9 @@ android {
|
||||
applicationId = "app.seb3thehacker.gearslip"
|
||||
minSdk = 31
|
||||
targetSdk = 37
|
||||
versionCode = 12
|
||||
versionCode = 15
|
||||
// -PgearslipVersionName=0.1.99-test makes a build that thinks it's older, to try the updater.
|
||||
versionName = project.findProperty("gearslipVersionName")?.toString() ?: "0.3.12-alpha"
|
||||
versionName = project.findProperty("gearslipVersionName")?.toString() ?: "0.3.40-alpha"
|
||||
manifestPlaceholders["appLabel"] = "Gearslip"
|
||||
// Where opted-in phones send their daily usage note: the stats-worker/ Worker's address,
|
||||
// set as gearslipStatsUrl in gradle.properties. Left empty, nothing is ever sent.
|
||||
@@ -74,6 +74,9 @@ android {
|
||||
applicationIdSuffix = ".dev"
|
||||
signingConfigs.findByName("release")?.let { signingConfig = it }
|
||||
manifestPlaceholders["appLabel"] = "Gearslip Debug"
|
||||
// English (XA) and Arabic (XB) under the phone's languages: stretched, accented and
|
||||
// right-to-left text, which shows what's still hardcoded and what doesn't fit.
|
||||
isPseudoLocalesEnabled = true
|
||||
// Shrunk with R8 like a release, so a debug install behaves like what ships (a keep
|
||||
// rule missing for release shows up here first). -PnoR8 skips it for a quicker build.
|
||||
if (!project.hasProperty("noR8")) {
|
||||
@@ -88,6 +91,18 @@ android {
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
// Lists each language that has a translation in the app's locale config, so Android 13+
|
||||
// offers it under the system's per-app languages. English is the unqualified default
|
||||
// (res/resources.properties).
|
||||
androidResources {
|
||||
generateLocaleConfig = true
|
||||
}
|
||||
|
||||
lint {
|
||||
// Weblate fills languages in a string at a time; the rest fall back to English.
|
||||
disable += "MissingTranslation"
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
targetCompatibility = JavaVersion.VERSION_21
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||
<!-- Keeps a wireless start going with the phone locked; see WirelessKeepAlive. -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
|
||||
|
||||
<!--
|
||||
Wireless Android Auto: Bluetooth reaches the paired car's Android Auto service, which
|
||||
@@ -126,6 +128,7 @@
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:label="${appLabel}"
|
||||
android:supportsRtl="true"
|
||||
android:manageSpaceActivity="app.seb3thehacker.gearslip.ui.ManageSpaceActivity"
|
||||
android:theme="@style/Theme.Gearslip">
|
||||
|
||||
<provider
|
||||
@@ -157,11 +160,37 @@
|
||||
|
||||
</activity>
|
||||
|
||||
<!--
|
||||
Launcher mode: the car UI as this device's home screen, for a head unit that runs Android
|
||||
or a tablet on the dash. Off until Settings turns it on (LauncherMode), so phones never
|
||||
list Gearslip as a home app.
|
||||
-->
|
||||
<activity
|
||||
android:name=".LauncherActivity"
|
||||
android:enabled="false"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:excludeFromRecents="true"
|
||||
android:screenOrientation="sensorLandscape"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|uiMode|density|locale|layoutDirection|fontScale">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.HOME" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!--
|
||||
Answers the car's USB connection on GearslipActivity's behalf. Kept apart from the
|
||||
activity so Settings can switch off just this (AppSettings.setGearslipEnabled) and leave
|
||||
the head unit to Android Auto, without disabling the screen the switch is on.
|
||||
-->
|
||||
<!-- App info › Storage › Manage space: clears lyrics, voice models and logs one at a time. -->
|
||||
<activity
|
||||
android:name=".ui.ManageSpaceActivity"
|
||||
android:exported="true"
|
||||
android:label="Gearslip storage" />
|
||||
|
||||
<activity
|
||||
android:name=".PermissionActivity"
|
||||
android:exported="false"
|
||||
@@ -197,6 +226,11 @@
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="location" />
|
||||
|
||||
<service
|
||||
android:name=".wireless.WirelessKeepAlive"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="connectedDevice" />
|
||||
|
||||
<!--
|
||||
Holds the capture session that carries a media app's audio to the car. It also claims
|
||||
the microphone, so voice replies can hear the driver with the phone locked.
|
||||
|
||||
@@ -10,6 +10,26 @@ object Changelog {
|
||||
class Release(val versionCode: Int, val versionName: String, val changes: List<String>)
|
||||
|
||||
val releases = listOf(
|
||||
Release(
|
||||
15, "0.3.40-alpha",
|
||||
listOf(
|
||||
"A liked song lights up the whole like button, as shuffle and repeat do.",
|
||||
"Music no longer carries on from the phone after you turn the car off.",
|
||||
"Cars that hold back their screen are asked for it again in a way older cars understand.",
|
||||
),
|
||||
),
|
||||
Release(
|
||||
14, "0.3.39-alpha",
|
||||
listOf(
|
||||
"Turns from Google Maps or Waze on the phone show in the nav bar and on the car's cluster.",
|
||||
"Alerts from car apps show here, such as Waze asking whether a hazard is still there.",
|
||||
"Hide apps you don't use in the car from the Apps card on the phone.",
|
||||
"Make text in car apps bigger with Car display density, under Screen.",
|
||||
"Music playing through Gearslip comes back after a call.",
|
||||
"Maps keep your position while the phone is locked.",
|
||||
"Wireless keeps trying until the car answers.",
|
||||
),
|
||||
),
|
||||
Release(
|
||||
9, "0.3.00-alpha",
|
||||
listOf(
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import app.seb3thehacker.gearslip.car.ClusterScreen
|
||||
|
||||
/**
|
||||
* Gearslip's picture on the car's instrument-cluster screen: a second video sink, for a car that
|
||||
* offers one, showing the turn card ([ClusterScreen]). The same exchange as the main screen's,
|
||||
* on its own channel, with its own encoder and its own private display:
|
||||
*
|
||||
* -> CHANNEL_OPEN_REQUEST <- CHANNEL_OPEN_RESPONSE
|
||||
* -> Setup { H.264 } <- Config { status, max_unacked, configuration_indices }
|
||||
* -> VideoFocusRequest <- VideoFocusNotification { projected }
|
||||
* -> Start, CodecConfig, Data... <- Ack
|
||||
*
|
||||
* No touch: a cluster has none. Experimental, behind [car.CarSettings.clusterVideoEnabled], and
|
||||
* untested in a car: none in the notes has a cluster sink.
|
||||
*/
|
||||
internal class ClusterVideo(
|
||||
private val context: Context,
|
||||
private val service: ServiceDiscovery.VideoService,
|
||||
private val send: (messageId: Int, body: ByteArray) -> Unit,
|
||||
) {
|
||||
private val log = GearslipLog.tagged("CLUSTER")
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
|
||||
val channelId get() = service.serviceId
|
||||
|
||||
private var configIndex = 0
|
||||
@Volatile private var started = false
|
||||
@Volatile private var running = true
|
||||
@Volatile private var source: VideoSource? = null
|
||||
private var projector: ScreenProjector? = null
|
||||
|
||||
private val ackLock = Object()
|
||||
@Volatile private var inFlight = 0
|
||||
@Volatile private var maxUnacked = 1
|
||||
@Volatile private var lastAckAt = 0L
|
||||
private var frames = 0
|
||||
|
||||
fun onMessage(messageId: Int, body: ByteArray) {
|
||||
when (messageId) {
|
||||
GearslipRunner.MSG_CHANNEL_OPEN_RESPONSE -> {
|
||||
val status = Protobuf.readInt32Field(body, 1) ?: 0
|
||||
log.i("<- ChannelOpenResponse: status=$status")
|
||||
if (status != 0) return
|
||||
send(GearslipRunner.MSG_MEDIA_SETUP, Protobuf.varintField(1, GearslipRunner.CODEC_H264_BP.toLong()))
|
||||
log.i("-> Setup(codec=VIDEO_H264_BP)")
|
||||
}
|
||||
|
||||
GearslipRunner.MSG_MEDIA_CONFIG -> {
|
||||
val fields = Wire.fields(body)
|
||||
Wire.varint(fields, 2)?.toInt()?.let { maxUnacked = maxOf(1, it) }
|
||||
val offered = fields.filter { it.number == 3 && it.wireType == 0 }.map { it.varint.toInt() }
|
||||
// The cluster gets the smallest picture it offers: a turn card needs no more.
|
||||
configIndex = offered.minByOrNull { service.configs.getOrNull(it)?.pixelCount() ?: Int.MAX_VALUE } ?: 0
|
||||
log.i("<- Config status=${Wire.varint(fields, 1)} max_unacked=$maxUnacked offered=$offered - using $configIndex")
|
||||
val focus = Protobuf.varintField(2, GearslipRunner.VIDEO_FOCUS_PROJECTED.toLong()) + Protobuf.varintField(3, 1L)
|
||||
send(GearslipRunner.MSG_VIDEO_FOCUS_REQUEST, focus)
|
||||
log.i("-> VideoFocusRequest(mode=PROJECTED)")
|
||||
}
|
||||
|
||||
GearslipRunner.MSG_VIDEO_FOCUS_NOTIFICATION -> {
|
||||
val mode = Wire.varint(Wire.fields(body), 1)?.toInt() ?: GearslipRunner.VIDEO_FOCUS_PROJECTED
|
||||
log.i("<- VideoFocusNotification: mode=$mode")
|
||||
val projected = mode == GearslipRunner.VIDEO_FOCUS_PROJECTED || mode == GearslipRunner.VIDEO_FOCUS_PROJECTED_NO_INPUT
|
||||
if (projected && !started) startStream()
|
||||
}
|
||||
|
||||
GearslipRunner.MSG_MEDIA_ACK -> {
|
||||
val acked = Wire.varint(Wire.fields(body), 2)?.toInt() ?: 1
|
||||
synchronized(ackLock) {
|
||||
inFlight = maxOf(0, inFlight - maxOf(1, acked))
|
||||
lastAckAt = System.currentTimeMillis()
|
||||
ackLock.notifyAll()
|
||||
}
|
||||
}
|
||||
|
||||
else -> log.i("<- unhandled message id=$messageId (${body.size} bytes)")
|
||||
}
|
||||
}
|
||||
|
||||
private fun startStream() {
|
||||
val config = service.configs.getOrNull(configIndex) ?: service.configs.firstOrNull() ?: return
|
||||
val (width, height) = config.pixelSize() ?: run {
|
||||
log.w("unsupported cluster resolution ${config.resolutionName}")
|
||||
return
|
||||
}
|
||||
started = true
|
||||
send(
|
||||
GearslipRunner.MSG_MEDIA_START,
|
||||
Protobuf.varintField(1, GearslipRunner.SESSION_ID.toLong()) + Protobuf.varintField(2, configIndex.toLong()),
|
||||
)
|
||||
log.i("-> Start(config=$configIndex -> $config)")
|
||||
lastAckAt = System.currentTimeMillis()
|
||||
val video = VideoSource(
|
||||
width = width,
|
||||
height = height,
|
||||
frameRate = FRAME_RATE,
|
||||
mode = VideoSource.Mode.SURFACE,
|
||||
wireless = true, // a small share of the link: the main screen comes first
|
||||
onCodecConfig = { csd ->
|
||||
send(GearslipRunner.MSG_MEDIA_CODEC_CONFIG, csd)
|
||||
log.i("-> CodecConfig (${csd.size} bytes)")
|
||||
},
|
||||
onFrame = { data, pts, _ -> sendFrame(data, pts) },
|
||||
)
|
||||
source = video
|
||||
runCatching { video.start() }.onFailure {
|
||||
log.e("cluster encoder failed to start", it)
|
||||
return
|
||||
}
|
||||
val surface = video.inputSurface ?: return
|
||||
val density = config.density.takeIf { it > 0 } ?: 160
|
||||
main.post {
|
||||
if (!running) return@post
|
||||
projector = ScreenProjector(context).also {
|
||||
it.start(surface, width, height, density, name = "gearslip-cluster") {
|
||||
// The margins the car crops off are kept clear, as on the main screen.
|
||||
ClusterScreen(config.widthMargin, config.heightMargin)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** As on the main screen: wait for the car's acks rather than drop a frame. */
|
||||
private fun sendFrame(data: ByteArray, presentationTimeUs: Long) {
|
||||
synchronized(ackLock) {
|
||||
while (running && inFlight >= maxUnacked) {
|
||||
val since = System.currentTimeMillis() - lastAckAt
|
||||
if (since > GearslipRunner.ACK_STALL_MS) {
|
||||
inFlight = 0
|
||||
lastAckAt = System.currentTimeMillis()
|
||||
break
|
||||
}
|
||||
ackLock.wait(GearslipRunner.ACK_STALL_MS - since + 1)
|
||||
}
|
||||
}
|
||||
if (!running) return
|
||||
val timestamp = ByteArray(8)
|
||||
for (i in 0 until 8) timestamp[i] = ((presentationTimeUs shr ((7 - i) * 8)) and 0xFF).toByte()
|
||||
synchronized(ackLock) { inFlight++ }
|
||||
runCatching { send(GearslipRunner.MSG_MEDIA_DATA, timestamp + data) }.onFailure { running = false }
|
||||
if (++frames <= 2) log.i("-> Data frame #$frames (${data.size} bytes)")
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
running = false
|
||||
synchronized(ackLock) { ackLock.notifyAll() }
|
||||
main.post {
|
||||
projector?.stop()
|
||||
projector = null
|
||||
}
|
||||
source?.stop()
|
||||
source = null
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// A turn card changes a few times a minute; the encoder repeats the last frame between.
|
||||
const val FRAME_RATE = 15
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,10 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
|
||||
/** Android's one-time "Allow" for a car to connect by itself; see [WirelessAuto]. */
|
||||
private var approvingCar: String? = null
|
||||
|
||||
/** Whether the phone showed Gearslip when the car's intent arrived; see [stepAside]. */
|
||||
private var shownOnPhone = false
|
||||
private var stepAsideOnVideo = false
|
||||
private val carApproval = registerForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult(),
|
||||
) { result ->
|
||||
@@ -223,6 +227,16 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
shownOnPhone = true
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
shownOnPhone = false
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
handleIntent(intent)
|
||||
@@ -296,7 +310,8 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
val target = surface ?: return
|
||||
if (screenProjector != null) return
|
||||
val projector = ScreenProjector(this).also { screenProjector = it }
|
||||
projector.start(target, frameWidth, frameHeight, frameDensity) { CarUi() }
|
||||
// The frame's density, not frameDensity: that stays the car's own, for the phone mirror.
|
||||
projector.start(target, frameWidth, frameHeight, CarEnvironment.frame.value.densityDpi) { CarUi() }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -320,7 +335,10 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
}
|
||||
|
||||
private fun handleIntent(intent: Intent?) {
|
||||
// Debug builds only: opens the audio choice for a look, without turning down a real prompt.
|
||||
if (BuildConfig.DEBUG && intent?.getBooleanExtra("show_audio_choice", false) == true) offerBluetooth.value = true
|
||||
if (intent?.action == app.seb3thehacker.gearslip.wireless.WirelessAuto.ACTION_CAR_NEARBY) {
|
||||
stepAsideOnVideo = !shownOnPhone
|
||||
connectToNearbyCar(intent.getStringExtra(app.seb3thehacker.gearslip.wireless.WirelessAuto.EXTRA_ADDRESS))
|
||||
return
|
||||
}
|
||||
@@ -338,6 +356,7 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
return
|
||||
}
|
||||
GearslipLog.i("accessory attached by the system - permission is implicit on this path")
|
||||
stepAsideOnVideo = !shownOnPhone
|
||||
start(accessory)
|
||||
}
|
||||
|
||||
@@ -399,9 +418,11 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
identityProvider = { CertProvider.load(this) },
|
||||
projection = this,
|
||||
vehicleProfileFor = { info -> VehicleProfiles.find(VehicleProfiles.load(this), info) },
|
||||
phoneIcons = { PhoneIcons.pngs(this) },
|
||||
)
|
||||
this.runner = runner
|
||||
worker = Thread { runner.run() }.apply { name = "gearslip"; start() }
|
||||
holdLocation()
|
||||
}
|
||||
|
||||
/** A wireless session: the same runner, on the socket [WirelessLink] opened to the car. */
|
||||
@@ -421,9 +442,25 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
projection = this,
|
||||
vehicleProfileFor = { info -> VehicleProfiles.find(VehicleProfiles.load(this), info) },
|
||||
wireless = true,
|
||||
phoneIcons = { PhoneIcons.pngs(this) },
|
||||
)
|
||||
this.runner = runner
|
||||
worker = Thread { runner.run() }.apply { name = "gearslip"; start() }
|
||||
holdLocation()
|
||||
}
|
||||
|
||||
/** Whether this session holds [app.seb3thehacker.gearslip.host.LocationKeepAlive]. */
|
||||
private var holdingLocation = false
|
||||
|
||||
/**
|
||||
* Android lets a foreground service started from the background use location only in the few
|
||||
* seconds after one of the app's screens opens. The car just opened this one, so take the
|
||||
* location service now: a map bound later, with the phone already locked, got no position.
|
||||
*/
|
||||
private fun holdLocation() {
|
||||
if (holdingLocation) return
|
||||
holdingLocation = true
|
||||
app.seb3thehacker.gearslip.host.LocationKeepAlive.acquire(this) {}
|
||||
}
|
||||
|
||||
private fun describe(accessory: UsbAccessory) {
|
||||
@@ -443,10 +480,27 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
wirelessSocket = null
|
||||
if (stopWireless) app.seb3thehacker.gearslip.wireless.WirelessLink.stop(this)
|
||||
runner = null
|
||||
if (holdingLocation) {
|
||||
holdingLocation = false
|
||||
app.seb3thehacker.gearslip.host.LocationKeepAlive.release(this)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Projection ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The car opens Gearslip on the phone, since Android hands a USB car only to an activity and
|
||||
* auto-connect has to start one too. Once the car's screen is up, go back to whatever the
|
||||
* phone showed, as Home would; the session carries on as it does with the phone locked. A
|
||||
* question for the driver keeps Gearslip in front, and so does the phone already showing it.
|
||||
*/
|
||||
private fun stepAside() {
|
||||
stepAsideOnVideo = false
|
||||
if (offerBluetooth.value || micBlockedNotice.value || approvingCar != null) return
|
||||
GearslipLog.i("the car's screen is up - moving Gearslip off the phone's screen")
|
||||
moveTaskToBack(true)
|
||||
}
|
||||
|
||||
override fun onSurfaceReady(surface: Surface, width: Int, height: Int, densityDpi: Int) {
|
||||
runOnUiThread {
|
||||
this.surface = surface
|
||||
@@ -456,6 +510,7 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
CarEnvironment.setDisplay(width, height, densityDpi)
|
||||
CarEnvironment.start(this)
|
||||
showCarUi()
|
||||
if (stepAsideOnVideo) stepAside()
|
||||
app.seb3thehacker.gearslip.car.CarKeys.focusSink = { code, down ->
|
||||
runOnUiThread { screenProjector?.dispatchKey(code, down) }
|
||||
}
|
||||
@@ -472,6 +527,10 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStill() {
|
||||
screenProjector?.redraw()
|
||||
}
|
||||
|
||||
override fun onProjectionStopped() {
|
||||
// Before the car UI goes, while the media connection is still up.
|
||||
app.seb3thehacker.gearslip.car.CarServices.pausePhoneMedia()
|
||||
|
||||
@@ -47,6 +47,8 @@ object GearslipLog {
|
||||
val version: StateFlow<Int> = liveVersion
|
||||
|
||||
fun init(context: Context) {
|
||||
// The phone app and the launcher both start here; one file per process.
|
||||
if (file != null) return
|
||||
val dir = context.getExternalFilesDir(null) ?: return
|
||||
val name = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
|
||||
file = File(dir, "gearslip-$name.log")
|
||||
@@ -54,9 +56,65 @@ object GearslipLog {
|
||||
SessionReport.init(context)
|
||||
// One file per launch adds up; the newest are the ones anyone asks for.
|
||||
io.execute { logFiles(dir).drop(KEEP_FILES).forEach { it.delete() } }
|
||||
lastExit(context, dir)
|
||||
}
|
||||
|
||||
private const val KEEP_FILES = 30
|
||||
private const val KEY_EXIT_SEEN = "log_exit_seen"
|
||||
|
||||
/**
|
||||
* Why Android ended the last run, when the crash handler can't have seen it: a native crash,
|
||||
* a freeze (ANR), or the memory killer. Written here and onto the end of that run's own log,
|
||||
* which is the one a driver shares after a drive that went wrong.
|
||||
*/
|
||||
private fun lastExit(context: Context, dir: File) {
|
||||
val am = context.getSystemService(android.app.ActivityManager::class.java) ?: return
|
||||
val seen = AppSettings.getString(context, KEY_EXIT_SEEN, "0").toLongOrNull() ?: 0L
|
||||
val exits = runCatching { am.getHistoricalProcessExitReasons(null, 0, 5) }.getOrDefault(emptyList())
|
||||
.filter { it.timestamp > seen }
|
||||
exits.firstOrNull()?.let { AppSettings.putString(context, KEY_EXIT_SEEN, it.timestamp.toString()) }
|
||||
val parse = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US)
|
||||
for (exit in exits.reversed()) {
|
||||
val why = EXIT_REASONS[exit.reason] ?: continue
|
||||
val at = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date(exit.timestamp))
|
||||
val detail = buildString {
|
||||
append("$at, $why")
|
||||
exit.description?.takeIf { it.isNotBlank() }?.let { append(" ($it)") }
|
||||
append(", memory ${exit.pss / 1024} MB")
|
||||
if (exit.reason == android.app.ApplicationExitInfo.REASON_ANR) {
|
||||
anrMainThread(exit)?.let { append("\n").append(it) }
|
||||
}
|
||||
}
|
||||
w("Android ended an earlier run at $detail")
|
||||
// The run that died: the newest log that started before it ended.
|
||||
val run = logFiles(dir).filter { it != file }.firstOrNull { f ->
|
||||
val started = runCatching { parse.parse(f.name.removePrefix("gearslip-").removeSuffix(".log")) }.getOrNull()
|
||||
started != null && started.time <= exit.timestamp
|
||||
} ?: continue
|
||||
io.execute { runCatching { run.appendText("--- $EXIT_MARK $detail\n") } }
|
||||
}
|
||||
}
|
||||
|
||||
/** The main thread's stack from an ANR trace: what Gearslip was stuck doing. */
|
||||
private fun anrMainThread(exit: android.app.ApplicationExitInfo): String? = runCatching {
|
||||
exit.traceInputStream?.bufferedReader()?.useLines { lines ->
|
||||
lines.dropWhile { !it.startsWith("\"main\"") }.take(ANR_LINES).joinToString("\n")
|
||||
}?.takeIf { it.isNotBlank() }
|
||||
}.getOrNull()
|
||||
|
||||
private const val ANR_LINES = 25
|
||||
private const val EXIT_MARK = "Android ended this run:"
|
||||
|
||||
/** Exit reasons worth a line; leaving, updating and Java crashes (already logged) aren't. */
|
||||
private val EXIT_REASONS = mapOf(
|
||||
android.app.ApplicationExitInfo.REASON_CRASH_NATIVE to "a native crash",
|
||||
android.app.ApplicationExitInfo.REASON_ANR to "the app stopped responding (ANR)",
|
||||
android.app.ApplicationExitInfo.REASON_LOW_MEMORY to "the phone ran low on memory",
|
||||
android.app.ApplicationExitInfo.REASON_EXCESSIVE_RESOURCE_USAGE to "it used too much of the phone's resources",
|
||||
android.app.ApplicationExitInfo.REASON_INITIALIZATION_FAILURE to "it failed to start",
|
||||
android.app.ApplicationExitInfo.REASON_SIGNALED to "the system killed it",
|
||||
android.app.ApplicationExitInfo.REASON_DEPENDENCY_DIED to "something it depends on died",
|
||||
)
|
||||
|
||||
private fun logFiles(dir: File): List<File> =
|
||||
dir.listFiles { f -> f.isFile && f.name.startsWith("gearslip-") && f.name.endsWith(".log") }
|
||||
@@ -77,7 +135,7 @@ object GearslipLog {
|
||||
PastLog(
|
||||
file = f,
|
||||
started = runCatching { parse.parse(f.name.removePrefix("gearslip-").removeSuffix(".log")) }.getOrNull(),
|
||||
crashed = "FATAL uncaught" in text,
|
||||
crashed = "FATAL uncaught" in text || EXIT_MARK in text,
|
||||
car = Regex("""car="([^"]+)"""").findAll(text).lastOrNull()?.groupValues?.get(1),
|
||||
)
|
||||
}
|
||||
@@ -173,7 +231,11 @@ object GearslipLog {
|
||||
* Records an uncaught exception in the log file before the process dies, then lets the
|
||||
* system's own handler run. Without it the file ends mid-sentence and the reason is lost.
|
||||
*/
|
||||
@Volatile private var crashHandlerInstalled = false
|
||||
|
||||
fun installCrashHandler() {
|
||||
if (crashHandlerInstalled) return
|
||||
crashHandlerInstalled = true
|
||||
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
runCatching {
|
||||
|
||||
@@ -48,6 +48,8 @@ class GearslipRunner(
|
||||
private val vehicleProfileFor: (ServiceDiscovery.HeadUnitInfo) -> VehicleProfile? = { null },
|
||||
/** Over Wi-Fi rather than USB: the video is sent smaller so the link keeps up. */
|
||||
private val wireless: Boolean = false,
|
||||
/** Gearslip's icon as 32, 64 and 128 px PNGs, for the car's phone tile. See [PhoneIcons]. */
|
||||
private val phoneIcons: () -> List<ByteArray> = { emptyList() },
|
||||
) {
|
||||
private val log = GearslipLog.tagged("PROTO")
|
||||
|
||||
@@ -56,6 +58,8 @@ class GearslipRunner(
|
||||
private val parser = Frames.Parser()
|
||||
private val assembler = Frames.Assembler()
|
||||
private lateinit var identityTask: FutureTask<PhoneTls>
|
||||
/** The certificate in use, to tell a wrong date on the car from a certificate that's really out of date. */
|
||||
@Volatile private var phoneCert: java.security.cert.X509Certificate? = null
|
||||
|
||||
/** Sends every message, in order, so no other thread ever blocks on the USB link. See [send]. */
|
||||
private val writer = java.util.concurrent.Executors.newSingleThreadExecutor {
|
||||
@@ -96,11 +100,15 @@ class GearslipRunner(
|
||||
@Volatile private var inputKeycodes: List<Int> = emptyList()
|
||||
@Volatile private var sensorChannelId: Int = -1
|
||||
@Volatile private var mediaStatus: CarMediaStatus? = null
|
||||
@Volatile private var phoneStatus: app.seb3thehacker.gearslip.call.CarPhoneStatus? = null
|
||||
@Volatile private var clusterVideo: ClusterVideo? = null
|
||||
@Volatile private var clusterNav: ClusterNav? = null
|
||||
private var sensorTypes: List<Int> = emptyList()
|
||||
@Volatile private var audioLink: AudioLink? = null
|
||||
@Volatile private var guidanceLink: AudioLink? = null
|
||||
@Volatile private var vendorChannelId = -1
|
||||
/** The car's Wi-Fi projection channel, opened in GM cars only (see [startVendorChannel]); -1 when closed. */
|
||||
@Volatile private var wifiChannelId = -1
|
||||
private var audioMessagesSeen = 0
|
||||
private var touchWidth = 0
|
||||
private var touchHeight = 0
|
||||
@@ -126,6 +134,7 @@ class GearslipRunner(
|
||||
identityTask = FutureTask {
|
||||
val identity = identityProvider()
|
||||
val cert = identity.certificate
|
||||
phoneCert = cert
|
||||
log.i("phone certificate source: ${identity.source}")
|
||||
SessionReport.certificate(identity.source.toString(), cert.issuerX500Principal.toString())
|
||||
log.i(" subject = ${cert.subjectX500Principal}")
|
||||
@@ -156,7 +165,8 @@ class GearslipRunner(
|
||||
} catch (t: Throwable) {
|
||||
if (stopped) return
|
||||
log.e("transport failed in state $state", t)
|
||||
SessionReport.fail(SessionReport.Category.USB, "transport failed", t, state.name)
|
||||
val link = if (wireless) SessionReport.Category.WIFI else SessionReport.Category.USB
|
||||
SessionReport.fail(link, "transport failed", t, state.name)
|
||||
reportDisconnect()
|
||||
} finally {
|
||||
running = false
|
||||
@@ -184,12 +194,17 @@ class GearslipRunner(
|
||||
guidanceLink?.close()
|
||||
guidanceLink = null
|
||||
CarSensors.clear()
|
||||
app.seb3thehacker.gearslip.car.CarData.clear()
|
||||
CarMic.current = null
|
||||
CarBluetooth.onCarDisconnected()
|
||||
BluetoothAudioCheck.onCarDisconnected()
|
||||
LocationCheck.onCarDisconnected()
|
||||
mediaStatus?.stop()
|
||||
mediaStatus = null
|
||||
phoneStatus?.stop()
|
||||
phoneStatus = null
|
||||
clusterVideo?.stop()
|
||||
clusterVideo = null
|
||||
clusterNav?.stop()
|
||||
clusterNav = null
|
||||
lastSensorNight = null
|
||||
@@ -237,12 +252,18 @@ class GearslipRunner(
|
||||
clusterNav?.onMessage(messageId, body)
|
||||
} else if (frame.channel == mediaStatus?.channelId) {
|
||||
mediaStatus?.onMessage(messageId, body)
|
||||
} else if (frame.channel == phoneStatus?.channelId) {
|
||||
phoneStatus?.onMessage(messageId, body)
|
||||
} else if (frame.channel == clusterVideo?.channelId) {
|
||||
clusterVideo?.onMessage(messageId, body)
|
||||
} else if (frame.channel == guidanceLink?.channelId) {
|
||||
guidanceLink?.onMessage(messageId, body)
|
||||
} else if (frame.channel == CarBluetooth.channelId) {
|
||||
CarBluetooth.onMessage(messageId, body)
|
||||
} else if (frame.channel == vendorChannelId) {
|
||||
onVendorMessage(messageId, body)
|
||||
} else if (frame.channel == wifiChannelId) {
|
||||
onWifiProjectionMessage(messageId, body)
|
||||
} else if (frame.channel == CarMic.current?.channelId) {
|
||||
CarMic.current?.onMessage(messageId, body)
|
||||
} else if (frame.channel == audioLink?.channelId) {
|
||||
@@ -272,6 +293,7 @@ class GearslipRunner(
|
||||
MSG_AUTH_COMPLETE -> onAuthComplete(body)
|
||||
MSG_SERVICE_DISCOVERY_RESPONSE -> onServiceDiscoveryResponse(body)
|
||||
MSG_PING_REQUEST -> onPingRequest(body)
|
||||
MSG_PING_RESPONSE -> onPingResponse()
|
||||
MSG_BYEBYE_REQUEST -> onByeByeRequest(body)
|
||||
MSG_UNEXPECTED_MESSAGE, MSG_FRAMING_ERROR -> onProtocolError(messageId)
|
||||
AudioLink.MSG_FOCUS_RESPONSE -> {
|
||||
@@ -393,8 +415,7 @@ class GearslipRunner(
|
||||
}
|
||||
-2, -23, -24 -> {
|
||||
val reason = when (status) {
|
||||
-23 -> "The phone's certificate is not valid yet."
|
||||
-24 -> "The phone's certificate has expired."
|
||||
-23, -24 -> certificateDates(status)
|
||||
else -> "The head unit refused the phone's certificate."
|
||||
}
|
||||
SessionReport.fail(SessionReport.Category.CERTIFICATE, "AuthComplete status $status", state.name, "auth $status")
|
||||
@@ -435,11 +456,44 @@ class GearslipRunner(
|
||||
if (authenticationFailed) running = false
|
||||
}
|
||||
|
||||
/**
|
||||
* The car checks the certificate's dates against its own clock. When they're fine by the
|
||||
* phone's clock, the car's clock is the one that's wrong, which a flat battery or a car
|
||||
* that never got the time from GPS leaves behind.
|
||||
*/
|
||||
private fun certificateDates(status: Int): String {
|
||||
val cert = phoneCert ?: return if (status == -23) "The phone's certificate is not valid yet." else "The phone's certificate has expired."
|
||||
val now = java.util.Date()
|
||||
val day = java.text.DateFormat.getDateInstance(java.text.DateFormat.LONG)
|
||||
return when {
|
||||
status == -23 && now.before(cert.notBefore) ->
|
||||
"The phone's certificate is not valid until ${day.format(cert.notBefore)}."
|
||||
status == -24 && now.after(cert.notAfter) ->
|
||||
"The phone's certificate expired on ${day.format(cert.notAfter)}. Get a new one under Certificate in Settings."
|
||||
else -> {
|
||||
val side = if (status == -23) "behind" else "ahead"
|
||||
log.w("the certificate's dates are fine by the phone's clock, so the car's clock is $side")
|
||||
"The car's clock looks wrong: the certificate is valid today, but the car thinks it " +
|
||||
(if (status == -23) "isn't valid yet" else "has expired") +
|
||||
". Set the car's date and time, then connect again."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a real phone sends here: its icon at three sizes (fields 1 to 3), a label, its model,
|
||||
* and a session ID that's new each connection (field 6). A 2017 Hyundai on protocol 1.0
|
||||
* never answered the request when it had only the label and model (issue #47).
|
||||
*/
|
||||
private fun sendServiceDiscoveryRequest() {
|
||||
val body = Protobuf.stringField(4, "Gearslip") + // label_text
|
||||
Protobuf.stringField(5, Build.MODEL) // device_name
|
||||
val icons = runCatching(phoneIcons).getOrDefault(emptyList())
|
||||
var body = ByteArray(0)
|
||||
icons.take(3).forEachIndexed { i, png -> body += Protobuf.bytesField(i + 1, png) } // small, medium, large icon
|
||||
body += Protobuf.stringField(4, "Gearslip") + // label_text
|
||||
Protobuf.stringField(5, Build.MODEL) + // device_name
|
||||
Protobuf.bytesField(6, Protobuf.stringField(1, java.util.UUID.randomUUID().toString())) // session ID
|
||||
send(MSG_SERVICE_DISCOVERY_REQUEST, body, encrypted = true)
|
||||
log.i("-> ServiceDiscoveryRequest (encrypted)")
|
||||
log.i("-> ServiceDiscoveryRequest (encrypted, ${icons.size} icons, ${body.size} bytes)")
|
||||
}
|
||||
|
||||
private fun onServiceDiscoveryResponse(body: ByteArray) {
|
||||
@@ -458,10 +512,12 @@ class GearslipRunner(
|
||||
"possible against THIS unit - portability to newer units is still unproven.",
|
||||
)
|
||||
state = State.DONE
|
||||
startPhonePings()
|
||||
val info = ServiceDiscovery.findHeadUnitInfo(body)
|
||||
log.i("head unit: $info")
|
||||
SessionReport.headUnit(info.toString())
|
||||
SessionReport.headUnitInfo = info
|
||||
app.seb3thehacker.gearslip.car.CarData.onModel(info.carModel, info.carYear)
|
||||
vehicleProfile = runCatching { vehicleProfileFor(info) }.getOrNull()
|
||||
val profile = vehicleProfile
|
||||
SessionReport.profile(profile?.name)
|
||||
@@ -512,6 +568,8 @@ class GearslipRunner(
|
||||
startSensorChannel(serviceDiscoveryResponse)
|
||||
startMicChannel(serviceDiscoveryResponse)
|
||||
startMediaStatusChannel(serviceDiscoveryResponse)
|
||||
startPhoneStatusChannel(serviceDiscoveryResponse)
|
||||
startClusterVideo(serviceDiscoveryResponse)
|
||||
startClusterNavChannel(serviceDiscoveryResponse)
|
||||
startBluetooth(serviceDiscoveryResponse)
|
||||
startVendorChannel(serviceDiscoveryResponse)
|
||||
@@ -563,6 +621,8 @@ class GearslipRunner(
|
||||
// The driver picked the radio or another source on the car: pause the phone's music
|
||||
// as Android Auto does, instead of letting it play on to no one.
|
||||
onTakenBack = { CarServices.pausePhoneMedia("the car switched to another source") },
|
||||
onPausedForNow = { CarServices.pausePhoneMediaForNow() },
|
||||
onGivenBack = { CarServices.resumePhoneMedia() },
|
||||
)
|
||||
if (CarSettings.pipeAudio.value) {
|
||||
openChannel(media.serviceId)
|
||||
@@ -592,7 +652,9 @@ class GearslipRunner(
|
||||
tag = "guidance",
|
||||
sessionId = GUIDANCE_SESSION_ID,
|
||||
asksFocus = false,
|
||||
onReady = { DirectionsVoice.attach(it) { audioLink?.streaming == true } },
|
||||
onReady = {
|
||||
DirectionsVoice.attach(it, musicIsOurs = { audioLink?.streaming == true }, musicSink = { audioLink?.takeIf { link -> link.isReady } })
|
||||
},
|
||||
onClose = { DirectionsVoice.detach(it) },
|
||||
)
|
||||
openChannel(guidance.serviceId)
|
||||
@@ -628,6 +690,32 @@ class GearslipRunner(
|
||||
openChannel(channel)
|
||||
}
|
||||
|
||||
/** The turn card on the instrument cluster, for a car with a cluster screen: experimental, off by default. */
|
||||
private fun startClusterVideo(serviceDiscoveryResponse: ByteArray) {
|
||||
val sink = ServiceDiscovery.findClusterVideoService(serviceDiscoveryResponse) ?: return
|
||||
log.i("cluster video sink: channel=${sink.serviceId} display=${sink.displayId} type=${sink.displayType}")
|
||||
if (!CarSettings.experimentalFeaturesEnabled.value || !CarSettings.clusterVideoEnabled.value) {
|
||||
log.i("cluster screen is off in the car's Settings (experimental) - leaving that sink alone")
|
||||
return
|
||||
}
|
||||
val context = app.seb3thehacker.gearslip.car.CarEnvironment.appContext ?: return
|
||||
clusterVideo = ClusterVideo(context, sink) { id, body -> send(id, body, encrypted = true, channel = sink.serviceId) }
|
||||
openChannel(sink.serviceId)
|
||||
}
|
||||
|
||||
/** Calls, for the car's own screens: the cluster, the steering wheel display. */
|
||||
private fun startPhoneStatusChannel(serviceDiscoveryResponse: ByteArray) {
|
||||
val channel = ServiceDiscovery.findPhoneStatusChannel(serviceDiscoveryResponse) ?: run {
|
||||
log.i("no phone status channel - the car's own screens won't show calls")
|
||||
return
|
||||
}
|
||||
log.i("phone status channel: $channel")
|
||||
phoneStatus = app.seb3thehacker.gearslip.call.CarPhoneStatus(channel) { id, body ->
|
||||
send(id, body, encrypted = true, channel = channel)
|
||||
}
|
||||
openChannel(channel)
|
||||
}
|
||||
|
||||
/** Bluetooth pairing, for calls: the channel only opens if the driver asks to pair. */
|
||||
/**
|
||||
* GM's own channel ("com.gm.vip"), opened in GM cars only. Issue #18: a 2021 Bolt ends every
|
||||
@@ -642,6 +730,23 @@ class GearslipRunner(
|
||||
vendorChannelId = gm.serviceId
|
||||
log.i("-> ChannelOpenRequest on GM's channel ${gm.serviceId}")
|
||||
openChannel(gm.serviceId)
|
||||
// Opening GM's channel and pinging the car didn't stop the Bolt hanging up, so a GM car
|
||||
// also gets the two channels Gearslip had never opened: Bluetooth and Wi-Fi projection.
|
||||
// Only what arrives on them is logged, by message id and size, until one turns out to matter.
|
||||
CarBluetooth.announce("a GM car, issue #18")
|
||||
ServiceDiscovery.findWifiProjectionService(serviceDiscoveryResponse)?.let { channel ->
|
||||
wifiChannelId = channel
|
||||
log.i("-> ChannelOpenRequest on the Wi-Fi projection channel $channel (a GM car, issue #18)")
|
||||
openChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onWifiProjectionMessage(messageId: Int, body: ByteArray) {
|
||||
if (messageId == MSG_CHANNEL_OPEN_RESPONSE) {
|
||||
log.i("<- Wi-Fi projection channel open: status=${Protobuf.readInt32Field(body, 1) ?: 0}")
|
||||
} else {
|
||||
log.i("<- Wi-Fi projection message id=$messageId (${body.size} bytes)")
|
||||
}
|
||||
}
|
||||
|
||||
private fun onVendorMessage(messageId: Int, body: ByteArray) {
|
||||
@@ -766,6 +871,7 @@ class GearslipRunner(
|
||||
|
||||
MSG_SENSOR_EVENT_INDICATION -> {
|
||||
CarSensors.onEvent(body)
|
||||
app.seb3thehacker.gearslip.car.CarData.onEvent(body)
|
||||
logSensorEvent(body)
|
||||
onNightSensor(body)
|
||||
}
|
||||
@@ -954,11 +1060,8 @@ class GearslipRunner(
|
||||
"${videoConfigs.getOrNull(selectedConfigIndex ?: -1)?.resolutionName ?: "?"} " +
|
||||
"(head unit offered ${offered.size}), max_unacked=$maxUnacked",
|
||||
)
|
||||
// VideoFocusRequestNotification { mode = 2; reason = 3 }
|
||||
val focus = Protobuf.varintField(2, VIDEO_FOCUS_PROJECTED.toLong()) +
|
||||
Protobuf.varintField(3, 1L)
|
||||
send(MSG_VIDEO_FOCUS_REQUEST, focus, encrypted = true, channel = videoChannelId)
|
||||
log.i("-> VideoFocusRequest(mode=PROJECTED)")
|
||||
requestProjectedFocus()
|
||||
if (CarAudio.openSink != null) watchForWithheldScreen()
|
||||
}
|
||||
|
||||
MSG_VIDEO_FOCUS_NOTIFICATION -> {
|
||||
@@ -1111,6 +1214,7 @@ class GearslipRunner(
|
||||
onFrame = { data, presentationTimeUs, keyFrame ->
|
||||
sendVideoFrame(data, presentationTimeUs, keyFrame)
|
||||
},
|
||||
onStill = { projection?.onStill() },
|
||||
).also { source ->
|
||||
runCatching {
|
||||
source.start()
|
||||
@@ -1234,6 +1338,49 @@ class GearslipRunner(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the car for its screen; the answer comes to [onVideoFocus]. [withDisplay] names the
|
||||
* display's channel too, as the request for the car's own screen does (see [requestNativeFocus]):
|
||||
* the field is deprecated, but a car older than that may ignore a request without it.
|
||||
*/
|
||||
private fun requestProjectedFocus(withDisplay: Boolean = false) {
|
||||
// VideoFocusRequestNotification { disp_channel_id = 1; mode = 2; reason = 3 }. Reason 1
|
||||
// means "the phone's screen is off", which a 2020 Civic may have taken as a reason to
|
||||
// keep its own screen (issue #43); 0 is UNKNOWN.
|
||||
val display = if (withDisplay) Protobuf.varintField(1, videoChannelId.toLong()) else ByteArray(0)
|
||||
val focus = display + Protobuf.varintField(2, VIDEO_FOCUS_PROJECTED.toLong()) +
|
||||
Protobuf.varintField(3, 0L)
|
||||
send(MSG_VIDEO_FOCUS_REQUEST, focus, encrypted = true, channel = videoChannelId)
|
||||
log.i(if (withDisplay) "-> VideoFocusRequest(display=$videoChannelId, mode=PROJECTED)" else "-> VideoFocusRequest(mode=PROJECTED)")
|
||||
}
|
||||
|
||||
/**
|
||||
* Some older head units keep their own screen until every channel the phone was offered is
|
||||
* open: a 2017 Pilot, a 2020 Civic and a 2024 Mirage (issues #43, #49, #57) never showed
|
||||
* ours with the media channel closed, and the Mirage worked on 0.3.01, which opened it. With
|
||||
* music over Bluetooth that channel waits for the car to switch source, so if the screen
|
||||
* hasn't come a few seconds after asking, open it and ask again. Cars that hand the screen
|
||||
* straight over keep their own source as before.
|
||||
* The 2017 Pilot (protocol 1.0) still kept its screen with the channel open, so the second
|
||||
* request also names the display's channel, as cars of the Pilot's age may need.
|
||||
*/
|
||||
private fun watchForWithheldScreen() {
|
||||
Thread({
|
||||
Thread.sleep(WITHHELD_SCREEN_MS)
|
||||
if (!running || videoStarted) return@Thread
|
||||
val open = CarAudio.openSink
|
||||
if (open != null) {
|
||||
log.w("no screen from the car after ${WITHHELD_SCREEN_MS / 1000}s - opening the media channel and asking again")
|
||||
open()
|
||||
// Give the car the channel's open response before it hears the request again.
|
||||
Thread.sleep(1_000)
|
||||
} else {
|
||||
log.w("no screen from the car after ${WITHHELD_SCREEN_MS / 1000}s - asking again")
|
||||
}
|
||||
if (running && !videoStarted) requestProjectedFocus(withDisplay = true)
|
||||
}, "gearslip-focus-fallback").apply { isDaemon = true }.start()
|
||||
}
|
||||
|
||||
/** Android Auto's Exit: asks the car for its own interface, leaving the session running. */
|
||||
private fun requestNativeFocus() {
|
||||
if (!videoStarted) return
|
||||
@@ -1266,6 +1413,38 @@ class GearslipRunner(
|
||||
log.i("touch -> frame: ${draw + encode}ms (draw ${draw}ms, encode and wait ${encode}ms)")
|
||||
}
|
||||
|
||||
/**
|
||||
* Android Auto pings the car too, not only answers its pings; either side may. A 2021 Bolt
|
||||
* hangs up 25 seconds into every drive while its own pings get answered and GM's channel is
|
||||
* open (issue #18), so the one thing left that a real phone sends and Gearslip didn't is this.
|
||||
*/
|
||||
private fun startPhonePings() {
|
||||
Thread({
|
||||
while (running && state == State.DONE) {
|
||||
runCatching {
|
||||
send(MSG_PING_REQUEST, Protobuf.varintField(1, System.nanoTime() / 1_000), encrypted = true)
|
||||
}.onFailure { return@Thread }
|
||||
pingsSent++
|
||||
if (pingsSent == 1) log.i("-> PingRequest (Gearslip pings the car once a second)")
|
||||
try {
|
||||
Thread.sleep(PHONE_PING_MS)
|
||||
} catch (e: InterruptedException) {
|
||||
return@Thread
|
||||
}
|
||||
}
|
||||
}, "gearslip-ping").apply { isDaemon = true }.start()
|
||||
}
|
||||
|
||||
@Volatile private var pingsSent = 0
|
||||
private var pingsAnswered = 0
|
||||
|
||||
private fun onPingResponse() {
|
||||
pingsAnswered++
|
||||
if (pingsAnswered == 1 || pingsAnswered % PING_LOG_EVERY == 0) {
|
||||
log.i("<- PingResponse ($pingsAnswered of $pingsSent answered)")
|
||||
}
|
||||
}
|
||||
|
||||
private fun onPingRequest(body: ByteArray) {
|
||||
// The timestamp is an int64 and has to come back unchanged. Read as an int32 it was cut
|
||||
// short, and a car that checks the echo (an Audi MIB2+) gave up at its second ping.
|
||||
@@ -1359,6 +1538,9 @@ class GearslipRunner(
|
||||
SessionReport.print()
|
||||
}
|
||||
|
||||
private val startedAt = System.currentTimeMillis()
|
||||
private var toldStuck = false
|
||||
|
||||
private fun startWatchdog() {
|
||||
Thread {
|
||||
var lastFrames = 0
|
||||
@@ -1370,6 +1552,11 @@ class GearslipRunner(
|
||||
val idle = System.currentTimeMillis() - lastProgress
|
||||
if (idle > 10_000) log.w("stalled in state $state for ${idle / 1000}s")
|
||||
}
|
||||
if (!videoStarted && !toldStuck && System.currentTimeMillis() - startedAt > STUCK_MS) {
|
||||
toldStuck = true
|
||||
log.w("still not on the car's screen after ${STUCK_MS / 1000}s, in state $state")
|
||||
SessionStatus.stillConnecting(if (wireless) SessionStatus.Link.WIRELESS else SessionStatus.Link.CABLE)
|
||||
}
|
||||
|
||||
// Stream health. Without this a drive cannot tell "streaming fine" from
|
||||
// "stalled after three frames" - the per-frame logging is deliberately sparse.
|
||||
@@ -1458,10 +1645,13 @@ class GearslipRunner(
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
internal companion object {
|
||||
/** The stats window adaptive bitrate reads, matching the watchdog's sleep. */
|
||||
const val ADAPT_WINDOW_MS = 5_000L
|
||||
|
||||
/** A car shows its screen within seconds; this long without one, Home says what to try. */
|
||||
const val STUCK_MS = 45_000L
|
||||
|
||||
// protobuf/aap_protobuf/service/control/ControlMessageType.proto:7-17
|
||||
const val MSG_VERSION_REQUEST = 1
|
||||
const val MSG_VERSION_RESPONSE = 2
|
||||
@@ -1471,6 +1661,8 @@ class GearslipRunner(
|
||||
const val MSG_SERVICE_DISCOVERY_RESPONSE = 6
|
||||
const val MSG_PING_REQUEST = 11
|
||||
const val MSG_PING_RESPONSE = 12
|
||||
private const val PHONE_PING_MS = 1_000L
|
||||
private const val PING_LOG_EVERY = 60
|
||||
const val MSG_BYEBYE_REQUEST = 15
|
||||
const val MSG_BYEBYE_RESPONSE = 16
|
||||
const val MSG_VOICE_SESSION_NOTIFICATION = 17
|
||||
@@ -1515,6 +1707,7 @@ class GearslipRunner(
|
||||
const val ACTION_UP = 1
|
||||
|
||||
const val ACK_STALL_MS = 2_000L
|
||||
const val WITHHELD_SCREEN_MS = 5_000L
|
||||
const val AUDIO_TRACE_MESSAGES = 6
|
||||
const val GUIDANCE_SESSION_ID = 1
|
||||
const val PERIODIC_KEYFRAME_MS = 5_000L
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import app.seb3thehacker.gearslip.car.CarEnvironment
|
||||
import app.seb3thehacker.gearslip.car.CarKeys
|
||||
import app.seb3thehacker.gearslip.car.CarSettings
|
||||
import app.seb3thehacker.gearslip.car.CarUi
|
||||
import app.seb3thehacker.gearslip.car.LauncherMode
|
||||
import app.seb3thehacker.gearslip.car.Prefetch
|
||||
import app.seb3thehacker.gearslip.ui.CarFrame
|
||||
import app.seb3thehacker.gearslip.ui.GearslipTheme
|
||||
import app.seb3thehacker.gearslip.ui.rememberCarProjector
|
||||
|
||||
/**
|
||||
* The home screen in launcher mode (see [LauncherMode]): the car UI, filling this device's own
|
||||
* screen. It runs for as long as the activity lives, not only while it's in front, so a map
|
||||
* keeps guiding and music keeps playing while another app is open over it.
|
||||
*/
|
||||
class LauncherActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
GearslipLog.init(this)
|
||||
GearslipLog.installCrashHandler()
|
||||
CarSettings.init(this)
|
||||
CarKeys.init(this)
|
||||
Prefetch.warm(this)
|
||||
CarEnvironment.setPhoneTheme(resources.configuration)
|
||||
|
||||
// A dash screen is something to glance at: it stays on, with the system bars out of the way.
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
WindowInsetsControllerCompat(window, window.decorView).apply {
|
||||
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
hide(WindowInsetsCompat.Type.systemBars())
|
||||
}
|
||||
// Back moves back in the car UI. A home screen has nowhere else to go back to.
|
||||
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
CarKeys.onKey(KeyEvent.KEYCODE_BACK, down = true, longPress = false)
|
||||
}
|
||||
})
|
||||
|
||||
setContent {
|
||||
GearslipTheme {
|
||||
LauncherScreen(onOpenGearslip = { startActivity(Intent(this, GearslipActivity::class.java)) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Home pressed while Gearslip is already the home screen: back to the car UI's Home. */
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
CarKeys.onKey(KeyEvent.KEYCODE_HOME, down = true, longPress = false)
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
CarEnvironment.setPhoneTheme(newConfig)
|
||||
}
|
||||
|
||||
/** A head unit's knob, D-pad and hard keys work the car UI the way a car's do. */
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
if (event.keyCode != KeyEvent.KEYCODE_BACK && event.repeatCount == 0 &&
|
||||
CarKeys.onKey(event.keyCode, event.action == KeyEvent.ACTION_DOWN, event.isLongPress)
|
||||
) return true
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LauncherScreen(onOpenGearslip: () -> Unit) {
|
||||
val status by SessionStatus.state.collectAsStateWithLifecycle()
|
||||
// Plugged into a car, this device is the phone and the car UI belongs on the car's screen.
|
||||
val inCar = status.phase == SessionStatus.Phase.CONNECTING || status.phase == SessionStatus.Phase.PROJECTING
|
||||
if (inCar) {
|
||||
InCarNotice(onOpenGearslip)
|
||||
return
|
||||
}
|
||||
BoxWithConstraints(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||
val px = with(LocalDensity.current) { maxWidth.roundToPx() / 2 * 2 to maxHeight.roundToPx() / 2 * 2 }
|
||||
val (width, height) = px
|
||||
if (width <= 0 || height <= 0) return@BoxWithConstraints
|
||||
// Car-like text and buttons: 160dpi for every 480 pixels of height, as the car preview uses.
|
||||
// This screen stands in for the car's, so hosted apps size to it and the density setting applies.
|
||||
val density = remember(width, height) {
|
||||
CarEnvironment.setDisplay(width, height, (160 * height / 480).coerceIn(120, 640))
|
||||
CarEnvironment.frame.value.densityDpi
|
||||
}
|
||||
// Before the projector starts, so the car UI's first screen already knows what to hide.
|
||||
DisposableEffect(Unit) {
|
||||
LauncherMode.setActive(true)
|
||||
onDispose { LauncherMode.setActive(false) }
|
||||
}
|
||||
val projector = rememberCarProjector(width, height, density, "gearslip-launcher") { CarUi() }
|
||||
DisposableEffect(projector) {
|
||||
CarKeys.focusSink = { code, down -> projector.dispatchKey(code, down) }
|
||||
onDispose { CarKeys.focusSink = null }
|
||||
}
|
||||
CarFrame(projector, width, height, Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InCarNotice(onOpenGearslip: () -> Unit) {
|
||||
Column(
|
||||
Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.launcher_in_car),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Button(onClick = onOpenGearslip) { Text(stringResource(R.string.launcher_open_gearslip)) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
/**
|
||||
* Gearslip's own icon as the PNGs a phone hands the car in its ServiceDiscoveryRequest, at the
|
||||
* 32, 64 and 128 px a real phone sends. Cars draw the phone's tile on their home screen from
|
||||
* them, and older head units may wait for them before answering at all.
|
||||
*/
|
||||
object PhoneIcons {
|
||||
|
||||
private val SIZES = intArrayOf(32, 64, 128)
|
||||
|
||||
@Volatile private var cached: List<ByteArray>? = null
|
||||
|
||||
/** The three PNGs, smallest first; empty if the icon can't be drawn. */
|
||||
fun pngs(context: Context): List<ByteArray> = cached ?: runCatching {
|
||||
val icon = context.packageManager.getApplicationIcon(context.applicationInfo).mutate()
|
||||
SIZES.map { size ->
|
||||
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||
icon.setBounds(0, 0, size, size)
|
||||
icon.draw(Canvas(bitmap))
|
||||
val png = ByteArrayOutputStream()
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, png)
|
||||
bitmap.recycle()
|
||||
png.toByteArray()
|
||||
}
|
||||
}.onFailure { GearslipLog.w("phone icons: couldn't draw them (${it.message})") }
|
||||
.getOrDefault(emptyList())
|
||||
.also { if (it.isNotEmpty()) cached = it }
|
||||
}
|
||||
@@ -24,6 +24,12 @@ interface Projection {
|
||||
fun onTouch(action: Int, actionIndex: Int, points: List<TouchPoint>)
|
||||
|
||||
fun onProjectionStopped()
|
||||
|
||||
/**
|
||||
* The screen has been still for a moment and the encoder has sent nothing since. Draw it once
|
||||
* more, so the car gets a fresh frame (see [VideoSource]'s onStill).
|
||||
*/
|
||||
fun onStill() {}
|
||||
}
|
||||
|
||||
/** One finger on the car's screen: a stable [id] for as long as it stays down, and where it is. */
|
||||
|
||||
@@ -223,6 +223,11 @@ class ScreenProjector(private val context: Context) {
|
||||
* Points the display at another surface, or none, without restarting what it shows. The
|
||||
* preview's SurfaceView comes and goes as the phone turns; the car UI on it carries on.
|
||||
*/
|
||||
/** Draws the screen again as it is, which hands the display's consumer a new frame. */
|
||||
fun redraw() {
|
||||
root?.let { it.post { it.invalidate() } }
|
||||
}
|
||||
|
||||
fun setSurface(surface: Surface?) {
|
||||
virtualDisplay?.surface = surface
|
||||
}
|
||||
|
||||
@@ -51,8 +51,17 @@ object ServiceDiscovery {
|
||||
"$resolutionName @ $frameRateName, density=$density, margin=${widthMargin}x$heightMargin"
|
||||
}
|
||||
|
||||
/** [displayId] is MediaSinkService field 6: 0, the default, is the main screen. */
|
||||
class VideoService(val serviceId: Int, val codecType: Int, val configs: List<VideoConfig>, val displayId: Int = 0) {
|
||||
/**
|
||||
* [displayId] is MediaSinkService field 6: 0, the default, is the main screen. [displayType]
|
||||
* is field 7 (0 main, 1 instrument cluster, 2 auxiliary), or null when the car leaves it out.
|
||||
*/
|
||||
class VideoService(
|
||||
val serviceId: Int,
|
||||
val codecType: Int,
|
||||
val configs: List<VideoConfig>,
|
||||
val displayId: Int = 0,
|
||||
val displayType: Int? = null,
|
||||
) {
|
||||
val codecName: String get() = CODECS[codecType] ?: "unknown($codecType)"
|
||||
}
|
||||
|
||||
@@ -116,6 +125,12 @@ object ServiceDiscovery {
|
||||
*/
|
||||
fun findMediaStatusChannel(response: ByteArray): Int? = findChannelWith(response, 9)
|
||||
|
||||
/**
|
||||
* The channel the car takes call status on (PhoneStatusService, Service field 10), for its
|
||||
* cluster and phone screens. It carries no settings. Null when the car has none.
|
||||
*/
|
||||
fun findPhoneStatusChannel(response: ByteArray): Int? = findChannelWith(response, 10)
|
||||
|
||||
class NavStatusService(val serviceId: Int, val minimumIntervalMs: Int)
|
||||
|
||||
/** The cluster advertises its minimum update interval in NavigationStatusService field 1. */
|
||||
@@ -185,6 +200,13 @@ object ServiceDiscovery {
|
||||
VendorService(id, name)
|
||||
}
|
||||
|
||||
/** The channel of the car's Wi-Fi projection service (Service field 14), or null. */
|
||||
fun findWifiProjectionService(response: ByteArray): Int? =
|
||||
Wire.allBytes(Wire.fields(response), 1).firstNotNullOfOrNull { channel ->
|
||||
val service = Wire.fields(channel)
|
||||
if (Wire.bytes(service, 14) == null) null else Wire.varint(service, 1)?.toInt()
|
||||
}
|
||||
|
||||
fun findBluetoothService(response: ByteArray): BluetoothService? {
|
||||
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
|
||||
val service = Wire.fields(channel)
|
||||
@@ -277,6 +299,19 @@ object ServiceDiscovery {
|
||||
return sinks.firstOrNull { it.displayId == 0 } ?: sinks.firstOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* The instrument cluster's video sink: one marked as a cluster, or, from a car that marks
|
||||
* none, a second sink beside the main screen's. Null when the car has no cluster screen.
|
||||
*/
|
||||
fun findClusterVideoService(response: ByteArray): VideoService? {
|
||||
val sinks = videoServices(response)
|
||||
val main = findVideoService(response) ?: return null
|
||||
return sinks.firstOrNull { it.displayType == DISPLAY_TYPE_CLUSTER && it.serviceId != main.serviceId }
|
||||
?: sinks.firstOrNull { it.displayType == null && it.serviceId != main.serviceId && it.displayId != main.displayId }
|
||||
}
|
||||
|
||||
private const val DISPLAY_TYPE_CLUSTER = 1
|
||||
|
||||
private fun videoServices(response: ByteArray): List<VideoService> = buildList {
|
||||
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
|
||||
val service = Wire.fields(channel)
|
||||
@@ -298,7 +333,7 @@ object ServiceDiscovery {
|
||||
heightMargin = Wire.varint(f, 4)?.toInt() ?: 0,
|
||||
)
|
||||
}
|
||||
add(VideoService(id, codecType, configs, Wire.varint(sinkFields, 6)?.toInt() ?: 0))
|
||||
add(VideoService(id, codecType, configs, Wire.varint(sinkFields, 6)?.toInt() ?: 0, Wire.varint(sinkFields, 7)?.toInt()))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ object SessionReport {
|
||||
enum class Category(val label: String) {
|
||||
NONE("none"),
|
||||
USB("USB transport"),
|
||||
WIFI("Wi-Fi transport"),
|
||||
NO_HANDSHAKE("head unit never started talking"),
|
||||
TLS("TLS handshake failed"),
|
||||
CERTIFICATE("certificate rejected by the head unit"),
|
||||
@@ -218,7 +219,7 @@ object SessionReport {
|
||||
// switching the car off at the end shows up as a USB or ByeBye "failure" that isn't one.
|
||||
val reached = carScreen.isNotEmpty()
|
||||
val outcome = when {
|
||||
reached && category in setOf(Category.NONE, Category.USB, Category.BYEBYE) -> "connected"
|
||||
reached && category in setOf(Category.NONE, Category.USB, Category.WIFI, Category.BYEBYE) -> "connected"
|
||||
reached -> "connected, then ${category.label}"
|
||||
category == Category.NONE -> "ended before video"
|
||||
else -> category.label
|
||||
|
||||
@@ -14,7 +14,10 @@ object SessionStatus {
|
||||
|
||||
enum class Phase { IDLE, CONNECTING, PROJECTING, FAILED, DISCONNECTED }
|
||||
|
||||
data class Snapshot(val phase: Phase, val headline: String, val detail: String)
|
||||
/** [stuckOn] is set when connecting has gone on too long: the cable or Wi-Fi, for what to try. */
|
||||
data class Snapshot(val phase: Phase, val headline: String, val detail: String, val stuckOn: Link? = null)
|
||||
|
||||
enum class Link { CABLE, WIRELESS }
|
||||
|
||||
private val flow = MutableStateFlow(
|
||||
Snapshot(
|
||||
@@ -30,6 +33,12 @@ object SessionStatus {
|
||||
flow.value = Snapshot(Phase.CONNECTING, "Connecting", stage)
|
||||
}
|
||||
|
||||
/** Still connecting after [GearslipRunner]'s patience ran out: Home says what to try. */
|
||||
fun stillConnecting(link: Link) {
|
||||
val now = flow.value
|
||||
if (now.phase == Phase.CONNECTING) flow.value = now.copy(stuckOn = link)
|
||||
}
|
||||
|
||||
fun projecting() {
|
||||
flow.value = Snapshot(Phase.PROJECTING, "Projecting", "Streaming to the head unit.")
|
||||
}
|
||||
|
||||
@@ -27,6 +27,12 @@ class VideoSource(
|
||||
private val wireless: Boolean = false,
|
||||
private val onCodecConfig: (ByteArray) -> Unit,
|
||||
private val onFrame: (data: ByteArray, presentationTimeUs: Long, keyFrame: Boolean) -> Unit,
|
||||
/**
|
||||
* SURFACE mode, nothing encoded for [STILL_MS]. The repeat setting below should cover a still
|
||||
* screen, but on a Pixel 10 it didn't: a LuChi box got 11 frames, then none, and stayed black
|
||||
* (issue #58). Whoever draws the screen can draw it again, and that is a frame.
|
||||
*/
|
||||
private val onStill: () -> Unit = {},
|
||||
) {
|
||||
private val log = GearslipLog.tagged("VIDEO")
|
||||
|
||||
@@ -170,6 +176,8 @@ class VideoSource(
|
||||
private fun pump(encoder: MediaCodec) {
|
||||
val info = MediaCodec.BufferInfo()
|
||||
val frameIntervalUs = 1_000_000L / frameRate
|
||||
var lastOutputAt = 0L
|
||||
var lastNudgeAt = 0L
|
||||
|
||||
while (running) {
|
||||
// SURFACE mode has no input buffers to fill - frames arrive from the display.
|
||||
@@ -208,8 +216,16 @@ class VideoSource(
|
||||
}
|
||||
}
|
||||
encoder.releaseOutputBuffer(outIndex, false)
|
||||
lastOutputAt = System.currentTimeMillis()
|
||||
outIndex = encoder.dequeueOutputBuffer(info, 0)
|
||||
}
|
||||
if (mode == Mode.SURFACE && lastOutputAt > 0) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastOutputAt > STILL_MS && now - lastNudgeAt > STILL_MS) {
|
||||
lastNudgeAt = now
|
||||
onStill()
|
||||
}
|
||||
}
|
||||
|
||||
if (mode != Mode.SURFACE) Thread.sleep(maxOf(1L, frameIntervalUs / 2000))
|
||||
}
|
||||
@@ -256,6 +272,7 @@ class VideoSource(
|
||||
const val MIME = MediaFormat.MIMETYPE_VIDEO_AVC
|
||||
const val TIMEOUT_US = 10_000L
|
||||
const val SURFACE_TIMEOUT_US = 100_000L
|
||||
private const val STILL_MS = 1_000L
|
||||
|
||||
/**
|
||||
* 800x480 at 30fps gets 8 Mbps: about the most that size can show, enough that a moving
|
||||
|
||||
@@ -87,8 +87,17 @@ internal class AacEncoder(private val sampleRate: Int, private val channels: Int
|
||||
runCatching { codec.release() }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val INPUT_WAIT_US = 10_000L
|
||||
val FREQUENCIES = listOf(96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350)
|
||||
companion object {
|
||||
private const val INPUT_WAIT_US = 10_000L
|
||||
private val FREQUENCIES = listOf(96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350)
|
||||
|
||||
/** The two-byte AudioSpecificConfig for AAC-LC: object type 2, rate index, channel count. */
|
||||
fun audioSpecificConfig(sampleRate: Int, channels: Int): ByteArray {
|
||||
val freq = FREQUENCIES.indexOf(sampleRate).let { if (it < 0) 3 else it }
|
||||
return byteArrayOf(
|
||||
((2 shl 3) or (freq shr 1)).toByte(),
|
||||
(((freq and 1) shl 7) or (channels shl 3)).toByte(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,9 @@ class AudioLink(
|
||||
private val onClose: (AudioLink) -> Unit = { CarAudio.detach(it) },
|
||||
/** The car took the sound back after giving it, as when the driver switches to the radio. */
|
||||
private val onTakenBack: () -> Unit = {},
|
||||
/** The car wants the speakers for a moment (a call, its own voice), and then [onGivenBack]. */
|
||||
private val onPausedForNow: () -> Unit = {},
|
||||
private val onGivenBack: () -> Unit = {},
|
||||
) : CarAudio.Link {
|
||||
|
||||
private var configIndex = 0
|
||||
@@ -43,6 +46,8 @@ class AudioLink(
|
||||
@Volatile private var started = false
|
||||
@Volatile private var focusGranted = false
|
||||
@Volatile private var focusRequested = false
|
||||
/** The car took the speakers for a moment, and we paused the phone's music until it's done. */
|
||||
@Volatile private var pausedForNow = false
|
||||
@Volatile private var inFlight = 0
|
||||
// The Uconnect asks for 4 on its video channel; assume the same until its Config says otherwise.
|
||||
@Volatile private var maxUnacked = 4
|
||||
@@ -52,6 +57,10 @@ class AudioLink(
|
||||
private var bytes = 0L
|
||||
private var dropped = 0
|
||||
@Volatile private var configTimer: Thread? = null
|
||||
/** Underflows the car reported this drive, and the silence still to slip in ahead of the next chunk. */
|
||||
private var underflows = 0
|
||||
@Volatile private var padMs = 0
|
||||
@Volatile private var pendingPadMs = 0
|
||||
private val ackLock = Object()
|
||||
/** Only for a sink that takes AAC and nothing else; PCM sinks get the capture as it is. */
|
||||
@Volatile private var aac: AacEncoder? = null
|
||||
@@ -66,6 +75,9 @@ class AudioLink(
|
||||
/** Sending audio now, which for media means the car has handed us the speakers. */
|
||||
val streaming get() = started
|
||||
|
||||
/** The car has set the sink up, so a stream can start. */
|
||||
val isReady get() = ready
|
||||
|
||||
@Volatile private var holdsSpeakers = false
|
||||
@Volatile private var focusAnswer: CountDownLatch? = null
|
||||
@Volatile private var answerDueBy = 0L
|
||||
@@ -104,6 +116,21 @@ class AudioLink(
|
||||
GearslipLog.i("$tag: -> AudioFocusRequest(RELEASE)")
|
||||
}
|
||||
|
||||
/**
|
||||
* A spoken turn on the music channel while no music plays (see DirectionsVoice): the stream
|
||||
* runs for the turn only, under ducking focus like the directions channel's, so the radio
|
||||
* comes back after it rather than giving way to Android Auto for good.
|
||||
*/
|
||||
fun startForTurn() {
|
||||
askForSpeakers()
|
||||
startStream()
|
||||
}
|
||||
|
||||
fun stopForTurn() {
|
||||
synchronized(startLock) { stopLocked(release = false) }
|
||||
handBackSpeakers()
|
||||
}
|
||||
|
||||
fun opened(status: Int) {
|
||||
GearslipLog.i("$tag: <- ChannelOpenResponse: status=$status")
|
||||
if (status != 0) {
|
||||
@@ -180,6 +207,8 @@ class AudioLink(
|
||||
}
|
||||
}
|
||||
|
||||
MSG_UNDERFLOW -> onUnderflow()
|
||||
|
||||
MSG_ACK -> {
|
||||
val acked = Wire.varint(Wire.fields(body), 2)?.toInt() ?: 1
|
||||
synchronized(ackLock) {
|
||||
@@ -207,7 +236,7 @@ class AudioLink(
|
||||
fun onFocus(state: Int) {
|
||||
val name = FOCUS_STATES[state] ?: "unknown($state)"
|
||||
GearslipLog.i("$tag: <- AudioFocusResponse state=$name")
|
||||
if (!asksFocus) {
|
||||
if (!asksFocus || expectsFocusAnswer()) {
|
||||
// Directions speak whatever the answer: a lost turn is worse than a loud one.
|
||||
answerDueBy = 0L
|
||||
focusAnswer?.countDown()
|
||||
@@ -216,11 +245,54 @@ class AudioLink(
|
||||
val had = focusGranted
|
||||
focusGranted = state == FOCUS_STATE_GAIN || state == FOCUS_STATE_GAIN_TRANSIENT ||
|
||||
state == FOCUS_STATE_GAIN_MEDIA_ONLY
|
||||
if (focusGranted) startStream() else stop()
|
||||
// A ducking request leaves the music playing; a loss means another source has the speakers.
|
||||
if (had && (state == FOCUS_STATE_LOSS || state == FOCUS_STATE_LOSS_TRANSIENT)) onTakenBack()
|
||||
when {
|
||||
focusGranted -> {
|
||||
startStream()
|
||||
if (pausedForNow) {
|
||||
pausedForNow = false
|
||||
onGivenBack()
|
||||
}
|
||||
}
|
||||
// A call or the car's own voice. Releasing focus here meant the car never handed it
|
||||
// back, so the music stayed paused after the call: keep the claim and wait for GAIN.
|
||||
state == FOCUS_STATE_LOSS_TRANSIENT -> {
|
||||
synchronized(startLock) { stopLocked(release = false) }
|
||||
if (had) {
|
||||
pausedForNow = true
|
||||
onPausedForNow()
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
stop()
|
||||
// A ducking request leaves the music playing; a loss means another source has the speakers.
|
||||
if (state == FOCUS_STATE_LOSS && (had || pausedForNow)) {
|
||||
pausedForNow = false
|
||||
onTakenBack()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The car ran out of sound to play (AudioUnderflowNotification, no body): its buffer emptied
|
||||
* before the next chunk arrived, which the driver hears as a gap. Capture runs in real time, so
|
||||
* the car's buffer only ever holds what the window lets through. A little silence slipped in
|
||||
* ahead of the next chunk puts it that far ahead, so the next slow patch is soaked up rather
|
||||
* than heard. Capped, as each step adds a little delay to the music.
|
||||
*/
|
||||
private fun onUnderflow() {
|
||||
underflows++
|
||||
val step = if (started && padMs < MAX_PAD_MS) PAD_STEP_MS else 0
|
||||
padMs += step
|
||||
pendingPadMs += step
|
||||
if (underflows <= 3 || underflows % 20 == 0) {
|
||||
GearslipLog.w("$tag: <- AudioUnderflow ($underflows this drive) - running ${padMs}ms ahead now")
|
||||
}
|
||||
}
|
||||
|
||||
/** Silence for [ms], whole frames of this sink's format. */
|
||||
private fun silence(ms: Int): ByteArray = ByteArray(sampleRate * ms / 1000 * channels * 2)
|
||||
|
||||
override fun start() {
|
||||
if (!ready || started) return
|
||||
if (asksFocus && !focusRequested) {
|
||||
@@ -251,6 +323,12 @@ class AudioLink(
|
||||
MSG_START,
|
||||
Protobuf.varintField(1, sessionId.toLong()) + Protobuf.varintField(2, configIndex.toLong()),
|
||||
)
|
||||
// Raw AAC-LC has no header of its own: the car needs the codec config before the first frame.
|
||||
if (service.codecType == ServiceDiscovery.CODEC_AAC_LC) {
|
||||
val asc = AacEncoder.audioSpecificConfig(sampleRate, channels)
|
||||
sendOnChannel(MSG_CODEC_CONFIG, asc)
|
||||
GearslipLog.hex("$tag: -> CodecConfig (AudioSpecificConfig)", asc)
|
||||
}
|
||||
inFlight = 0
|
||||
lastAckAt = System.currentTimeMillis()
|
||||
if (aacSink && aac == null) {
|
||||
@@ -267,25 +345,39 @@ class AudioLink(
|
||||
synchronized(startLock) { stopLocked() }
|
||||
}
|
||||
|
||||
private fun stopLocked() {
|
||||
/** [release] false keeps our claim on the speakers, so the car hands them back on its own. */
|
||||
private fun stopLocked(release: Boolean = true) {
|
||||
if (!started) return
|
||||
started = false
|
||||
synchronized(ackLock) { ackLock.notifyAll() }
|
||||
runCatching {
|
||||
sendOnChannel(MSG_STOP, Protobuf.varintField(1, sessionId.toLong()))
|
||||
if (focusRequested) sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_RELEASE.toLong()))
|
||||
if (release && focusRequested) sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_RELEASE.toLong()))
|
||||
}
|
||||
focusRequested = false
|
||||
if (release) focusRequested = false
|
||||
focusGranted = false
|
||||
aac?.release()
|
||||
aac = null
|
||||
GearslipLog.i("$tag: -> Stop, AudioFocusRequest(RELEASE) after $chunks chunks / $bytes bytes")
|
||||
GearslipLog.i("$tag: -> Stop${if (release) ", AudioFocusRequest(RELEASE)" else ""} after $chunks chunks / $bytes bytes")
|
||||
chunks = 0
|
||||
bytes = 0
|
||||
// A new stream starts with the car's buffer empty again: earn the lead back if it's needed.
|
||||
padMs = 0
|
||||
pendingPadMs = 0
|
||||
}
|
||||
|
||||
override fun write(pcm: ByteArray, size: Int) {
|
||||
if (!started) return
|
||||
val pad = pendingPadMs
|
||||
if (pad > 0) {
|
||||
pendingPadMs = 0
|
||||
val quiet = silence(pad)
|
||||
writeNow(quiet, quiet.size)
|
||||
}
|
||||
writeNow(pcm, size)
|
||||
}
|
||||
|
||||
private fun writeNow(pcm: ByteArray, size: Int) {
|
||||
val encoder = aac
|
||||
if (encoder == null) {
|
||||
send(pcm.copyOf(size))
|
||||
@@ -364,12 +456,16 @@ class AudioLink(
|
||||
const val MSG_FOCUS_RESPONSE = 19
|
||||
private const val MSG_CHANNEL_OPEN_RESPONSE = 8
|
||||
private const val MSG_DATA = 0
|
||||
private const val MSG_CODEC_CONFIG = 1
|
||||
private const val MSG_SETUP = 32768
|
||||
private const val MSG_START = 32769
|
||||
private const val MSG_STOP = 32770
|
||||
private const val MSG_CONFIG = 32771
|
||||
private const val MSG_ACK = 32772
|
||||
private const val MSG_SETUP_ANSWER = 255
|
||||
private const val MSG_UNDERFLOW = 32779 // 0x800B, car to phone, no body
|
||||
private const val PAD_STEP_MS = 60
|
||||
private const val MAX_PAD_MS = 240
|
||||
private const val STATUS_READY = 2
|
||||
private const val FOCUS_GAIN = 1
|
||||
private const val FOCUS_GAIN_TRANSIENT_MAY_DUCK = 3
|
||||
|
||||
@@ -137,6 +137,59 @@ object CarAudio {
|
||||
|
||||
internal fun deliver(pcm: ByteArray, size: Int, peak: Float) {
|
||||
_level.value = peak
|
||||
link?.write(pcm, size)
|
||||
val voice = synchronized(voiceLock) { spoken }
|
||||
if (voice == null) link?.write(pcm, size) else link?.write(mixVoice(pcm, size), size)
|
||||
}
|
||||
|
||||
private val voiceLock = Any()
|
||||
private var spoken: ByteArray? = null
|
||||
private var spokenAt = 0
|
||||
private var spokenDone: java.util.concurrent.CountDownLatch? = null
|
||||
|
||||
/**
|
||||
* Mixes a spoken turn into the music from the next chunk on, with the music turned down
|
||||
* under it, and waits until it has all gone out. [pcm] is 16-bit, in the sink's own format.
|
||||
* False when no music is flowing to carry it, so the caller sends it some other way.
|
||||
*/
|
||||
fun speakOverMusic(pcm: ByteArray): Boolean {
|
||||
if (_capture.value != Capture.ON || (link as? AudioLink)?.streaming != true) return false
|
||||
val done = java.util.concurrent.CountDownLatch(1)
|
||||
synchronized(voiceLock) {
|
||||
spokenDone?.countDown()
|
||||
spoken = pcm
|
||||
spokenAt = 0
|
||||
spokenDone = done
|
||||
}
|
||||
val ms = pcm.size * 1000L / (2 * channels * sampleRate)
|
||||
if (!done.await(ms + VOICE_SLACK_MS, java.util.concurrent.TimeUnit.MILLISECONDS)) {
|
||||
// Capture stopped mid-turn; the rest of it is dropped rather than played late.
|
||||
synchronized(voiceLock) { if (spokenDone === done) spoken = null }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun mixVoice(pcm: ByteArray, size: Int): ByteArray = synchronized(voiceLock) {
|
||||
val voice = spoken ?: return pcm
|
||||
val out = pcm.copyOf(size)
|
||||
var i = 0
|
||||
while (i + 1 < size) {
|
||||
val music = (out[i].toInt() and 0xFF) or (out[i + 1].toInt() shl 8)
|
||||
val word = if (spokenAt + 1 < voice.size) (voice[spokenAt].toInt() and 0xFF) or (voice[spokenAt + 1].toInt() shl 8) else 0
|
||||
val mixed = (music * DUCK_PERCENT / 100 + word).coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt())
|
||||
out[i] = mixed.toByte()
|
||||
out[i + 1] = (mixed shr 8).toByte()
|
||||
i += 2
|
||||
spokenAt += 2
|
||||
}
|
||||
if (spokenAt >= voice.size) {
|
||||
spoken = null
|
||||
spokenDone?.countDown()
|
||||
spokenDone = null
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/** How loud the music stays under a spoken turn. */
|
||||
private const val DUCK_PERCENT = 30
|
||||
private const val VOICE_SLACK_MS = 2_000L
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package app.seb3thehacker.gearslip.call
|
||||
|
||||
import android.os.SystemClock
|
||||
import android.view.KeyEvent
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.Protobuf
|
||||
import app.seb3thehacker.gearslip.Wire
|
||||
import app.seb3thehacker.gearslip.car.CarKeys
|
||||
|
||||
/**
|
||||
* Tells the car about calls, so its own screens show them: the instrument cluster, the steering
|
||||
* wheel display, the car's phone page. Android Auto does the same over the car's
|
||||
* PhoneStatusService (Service field 10):
|
||||
*
|
||||
* -> PhoneStatusUpdate { repeated PhoneCall calls = 1 }
|
||||
* PhoneCall { state = 1 (1 in call, 4 incoming, 6 muted); duration_seconds = 2;
|
||||
* number = 3; name = 4 }
|
||||
* <- PhoneStatusInput { input = 1 { action = 1 (7 call) }; caller = 2; name = 3 }
|
||||
*
|
||||
* Read once a second from [CarCalls]. An update goes when the call changes, and every second
|
||||
* while one is going, so the car's timer counts along; no calls goes once, as an empty list.
|
||||
* The car's own call button answers through [CarKeys], like the steering wheel's.
|
||||
*/
|
||||
class CarPhoneStatus(
|
||||
val channelId: Int,
|
||||
private val sendOnChannel: (messageId: Int, body: ByteArray) -> Unit,
|
||||
) {
|
||||
@Volatile private var running = false
|
||||
private var sent: String? = null
|
||||
|
||||
fun onMessage(messageId: Int, body: ByteArray) {
|
||||
when (messageId) {
|
||||
MSG_CHANNEL_OPEN_RESPONSE -> {
|
||||
val status = Protobuf.readInt32Field(body, 1)
|
||||
GearslipLog.i("phone status: <- ChannelOpenResponse: status=$status")
|
||||
if (status == 0) start()
|
||||
}
|
||||
MSG_INPUT -> onInput(body)
|
||||
else -> GearslipLog.i("phone status: <- unhandled message id=$messageId (${body.size} bytes)")
|
||||
}
|
||||
}
|
||||
|
||||
private fun start() {
|
||||
if (running) return
|
||||
running = true
|
||||
Thread({
|
||||
while (running) {
|
||||
runCatching { tick() }.onFailure { GearslipLog.w("phone status: ${it.message}") }
|
||||
Thread.sleep(TICK_MS)
|
||||
}
|
||||
}, "gearslip-phone-status").start()
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
running = false
|
||||
}
|
||||
|
||||
private fun tick() {
|
||||
val call = when (val state = CarCalls.state.value) {
|
||||
CallUiState.None -> null
|
||||
is CallUiState.Ringing -> Call(STATE_INCOMING, 0, state.caller)
|
||||
is CallUiState.Active -> Call(
|
||||
if (CarCalls.muted.value) STATE_MUTED else STATE_IN_CALL,
|
||||
((SystemClock.uptimeMillis() - state.since) / 1000).coerceAtLeast(0),
|
||||
state.caller,
|
||||
)
|
||||
}
|
||||
// Only a call that's going changes from one second to the next.
|
||||
val key = call?.let { "${it.state}|${it.caller?.number}|${it.caller?.name}|${if (it.state == STATE_INCOMING) 0 else it.seconds}" } ?: ""
|
||||
if (key == sent) return
|
||||
val changed = call?.state != lastState
|
||||
sent = key
|
||||
lastState = call?.state
|
||||
val body = call?.let { Protobuf.bytesField(1, it.encode()) } ?: ByteArray(0)
|
||||
sendOnChannel(MSG_STATUS, body)
|
||||
if (changed) GearslipLog.i("phone status: -> ${call?.let { STATE_NAMES[it.state] } ?: "no calls"}")
|
||||
}
|
||||
|
||||
private var lastState: Int? = null
|
||||
|
||||
private class Call(val state: Int, val seconds: Long, val caller: Caller?) {
|
||||
fun encode(): ByteArray {
|
||||
var body = Protobuf.varintField(1, state.toLong()) + Protobuf.varintField(2, seconds)
|
||||
caller?.number?.takeIf { it.isNotBlank() }?.let { body += Protobuf.stringField(3, it) }
|
||||
caller?.name?.takeIf { it.isNotBlank() }?.let { body += Protobuf.stringField(4, it) }
|
||||
return body
|
||||
}
|
||||
}
|
||||
|
||||
/** The car's own phone buttons. Only "call" does anything: it answers, as the wheel's does. */
|
||||
private fun onInput(body: ByteArray) {
|
||||
val action = Wire.bytes(Wire.fields(body), 1)?.let { Wire.varint(Wire.fields(it), 1)?.toInt() }
|
||||
GearslipLog.i("phone status: <- input action=${action ?: "?"}")
|
||||
if (action == ACTION_CALL) CarKeys.onKey(KeyEvent.KEYCODE_CALL, down = true, longPress = false)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TICK_MS = 1_000L
|
||||
private const val MSG_CHANNEL_OPEN_RESPONSE = 8
|
||||
private const val MSG_STATUS = 32769 // 0x8001
|
||||
private const val MSG_INPUT = 32770 // 0x8002
|
||||
private const val STATE_IN_CALL = 1
|
||||
private const val STATE_INCOMING = 4
|
||||
private const val STATE_MUTED = 6
|
||||
private const val ACTION_CALL = 7
|
||||
private val STATE_NAMES = mapOf(STATE_IN_CALL to "in a call", STATE_INCOMING to "ringing", STATE_MUTED to "in a call, muted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.mirror.PhoneApp
|
||||
import app.seb3thehacker.gearslip.mirror.PhoneApps
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Launcher mode's app drawer: every app on the device, A to Z, each opened full screen. Gearslip
|
||||
* comes first, for its own settings, which the car UI only partly covers.
|
||||
*/
|
||||
@Composable
|
||||
fun AllAppsScreen() {
|
||||
val context = LocalContext.current
|
||||
val apps by produceState(initialValue = PhoneApps.cached() ?: emptyList(), context) {
|
||||
value = PhoneApps.cached() ?: withContext(Dispatchers.IO) { PhoneApps.installed(context).also { PhoneApps.warm(context) } }
|
||||
}
|
||||
val gearslip = remember(context) {
|
||||
val label = context.packageManager.getApplicationLabel(context.applicationInfo).toString()
|
||||
val icon = runCatching { context.packageManager.getApplicationIcon(context.packageName).toBitmap(ICON_PX, ICON_PX).asImageBitmap() }
|
||||
PhoneApp(context.packageName, label, icon.getOrNull())
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(start = 20.dp, end = 20.dp, top = 12.dp)) {
|
||||
ScreenHeader(stringResource(R.string.app_all_apps))
|
||||
Spacer(Modifier.height(12.dp))
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 108.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
item {
|
||||
PhoneAppTile(gearslip) { LauncherMode.launch(context, context.packageName) }
|
||||
}
|
||||
items(apps.sortedBy { it.label.lowercase() }) { app ->
|
||||
PhoneAppTile(app) { LauncherMode.launch(context, app.packageName) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val ICON_PX = 128
|
||||
@@ -61,7 +61,7 @@ object AssistantCommands {
|
||||
|
||||
private suspend fun allEntries(context: Context): List<Entry> {
|
||||
val vehicleData = CarSettings.experimentalFeaturesEnabled.value && CarSettings.carSensorsEnabled.value
|
||||
val builtIn = BuiltInApps.entries.filter { it.builtIn !== BuiltInApps.VehicleData || vehicleData }
|
||||
val builtIn = BuiltInApps.entries(LauncherMode.active.value, LauncherMode.canCall(context)).filter { it.builtIn !== BuiltInApps.VehicleData || vehicleData }
|
||||
return builtIn + (LauncherCache.entries ?: withContext(Dispatchers.IO) { LauncherCache.load(context) })
|
||||
}
|
||||
|
||||
@@ -95,7 +95,12 @@ object AssistantCommands {
|
||||
heard: String,
|
||||
): String? {
|
||||
// "use Organic Maps to get to X", "open OsmAnd and navigate to X": the app comes first.
|
||||
var text = heard
|
||||
// "take me home", "go home": no "to", so no trigger would catch them.
|
||||
var text = if (Regex("^(?:(?:take|bring|get|drive) me|go|drive|head|get|directions|go back|head back) home$").matches(heard)) {
|
||||
"navigate to home"
|
||||
} else {
|
||||
heard
|
||||
}
|
||||
var leadingApp: String? = null
|
||||
Regex("^(?:use|using|with|open|in|on) (.+?) (?:to |and )(.+)$").find(text)?.let { m ->
|
||||
if (after(m.groupValues[2], NAVIGATE_TRIGGERS) != null) {
|
||||
@@ -135,6 +140,20 @@ object AssistantCommands {
|
||||
val (place, nearby) = plainPlace(destination)
|
||||
destination = place
|
||||
if (destination.isBlank()) return "Where to?"
|
||||
// "home", "work", or a name from Saved places: its address is the search.
|
||||
var spoken: String? = null
|
||||
SavedPlaces.match(context, destination)?.let { (slot, saved) ->
|
||||
val name = when (slot) {
|
||||
SavedPlaces.HOME -> "home"
|
||||
SavedPlaces.WORK -> "work"
|
||||
else -> saved.label
|
||||
}
|
||||
if (saved.address.isBlank()) {
|
||||
return "You haven't saved a $name address yet. Add it under Saved places in Gearslip's settings on your phone."
|
||||
}
|
||||
destination = saved.address
|
||||
spoken = if (slot == SavedPlaces.HOME) "home" else "to $name"
|
||||
}
|
||||
|
||||
val chosen = chosenApp ?: when {
|
||||
navApps.size == 1 -> navApps.first()
|
||||
@@ -144,6 +163,7 @@ object AssistantCommands {
|
||||
|
||||
CarServices.connectNav(template, frame, destination)
|
||||
navigator.home()
|
||||
spoken?.let { return "Navigating $it with ${chosen.label}." }
|
||||
return if (nearby) "Finding the nearest $destination with ${chosen.label}." else "Navigating to $destination with ${chosen.label}."
|
||||
}
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ object BluetoothAudioCheck {
|
||||
if (!armed) return
|
||||
val context = appContext ?: return
|
||||
if (CarSettings.pipeAudio.value) {
|
||||
set(false, "USB audio is on")
|
||||
set(false, "audio goes through Gearslip")
|
||||
return
|
||||
}
|
||||
if (!CarSettings.bluetoothWarning.value) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -45,32 +47,28 @@ internal fun BluetoothPairOverlay() {
|
||||
val locationOff by LocationCheck.off.collectAsState()
|
||||
val locationDismissed by LocationCheck.carDismissed.collectAsState()
|
||||
if (missing && bluetoothAudio && warn && !dismissed) PairCard(
|
||||
title = "Bluetooth isn't connected",
|
||||
body = "Music plays over Bluetooth, but your phone isn't connected to the car. " +
|
||||
"Connect it from the car's Bluetooth menu or your phone's.",
|
||||
secondary = "Don't show again" to BluetoothAudioCheck::neverAgain,
|
||||
primary = "OK" to BluetoothAudioCheck::dismissOnCar,
|
||||
title = stringResource(R.string.home_bt_title),
|
||||
body = stringResource(R.string.overlay_bt_body),
|
||||
secondary = stringResource(R.string.overlay_dont_show) to BluetoothAudioCheck::neverAgain,
|
||||
primary = stringResource(R.string.overlay_ok) to BluetoothAudioCheck::dismissOnCar,
|
||||
) else if (locationOff && !locationDismissed) PairCard(
|
||||
title = "Location is off",
|
||||
body = "Map apps can't show where you are or give directions. Turn on location " +
|
||||
"in your phone's quick settings when you're parked.",
|
||||
secondary = "Don't show again" to LocationCheck::neverAgain,
|
||||
primary = "OK" to LocationCheck::dismissOnCar,
|
||||
title = stringResource(R.string.overlay_location_off),
|
||||
body = stringResource(R.string.overlay_location_body),
|
||||
secondary = stringResource(R.string.overlay_dont_show) to LocationCheck::neverAgain,
|
||||
primary = stringResource(R.string.overlay_ok) to LocationCheck::dismissOnCar,
|
||||
)
|
||||
}
|
||||
CarBluetooth.Prompt.ASK -> PairCard(
|
||||
title = "Pair with this car?",
|
||||
body = (if (bluetoothAudio) "Calls and music play" else "Calls play") +
|
||||
" through the car's speakers once your phone and the car are paired. Your phone " +
|
||||
"then asks you to confirm a code.",
|
||||
secondary = "Not now" to CarBluetooth::notNow,
|
||||
primary = "Pair" to CarBluetooth::pair,
|
||||
title = stringResource(R.string.overlay_pair_title),
|
||||
body = stringResource(if (bluetoothAudio) R.string.overlay_pair_body_music else R.string.overlay_pair_body),
|
||||
secondary = stringResource(R.string.worked_not_now) to CarBluetooth::notNow,
|
||||
primary = stringResource(R.string.overlay_pair) to CarBluetooth::pair,
|
||||
)
|
||||
CarBluetooth.Prompt.PAIRING -> PairCard(
|
||||
title = "Confirm on your phone",
|
||||
body = "Check the code on your phone and tap Pair. The car may ask too.",
|
||||
title = stringResource(R.string.overlay_confirm_title),
|
||||
body = stringResource(R.string.overlay_confirm_body),
|
||||
secondary = null,
|
||||
primary = "Hide" to CarBluetooth::hide,
|
||||
primary = stringResource(R.string.carset_hide) to CarBluetooth::hide,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -63,7 +65,7 @@ private fun IncomingCall(s: CallUiState.Ringing, onAnswer: () -> Unit, onDecline
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
"Incoming call",
|
||||
stringResource(R.string.call_incoming),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -76,13 +78,13 @@ private fun IncomingCall(s: CallUiState.Ringing, onAnswer: () -> Unit, onDecline
|
||||
)
|
||||
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)
|
||||
CallButton(Icons.Filled.Close, stringResource(R.string.call_decline), MaterialTheme.colorScheme.error, enabled = s.canDecline, onClick = onDecline)
|
||||
CallButton(Icons.Filled.Call, stringResource(R.string.call_answer), MaterialTheme.colorScheme.primary, enabled = true, onClick = onAnswer)
|
||||
}
|
||||
if (!s.canDecline) {
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Text(
|
||||
"Already ringing out loud - answer here, or let it ring.",
|
||||
stringResource(R.string.call_ringing),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.car.app.model.Action
|
||||
import androidx.car.app.model.Alert
|
||||
import androidx.car.app.model.AlertCallback
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.LiveRegionMode
|
||||
import androidx.compose.ui.semantics.liveRegion
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.car.theme.GsColors
|
||||
import app.seb3thehacker.gearslip.car.theme.GsIconBox
|
||||
import app.seb3thehacker.gearslip.host.cancel
|
||||
import app.seb3thehacker.gearslip.host.click
|
||||
import app.seb3thehacker.gearslip.host.color
|
||||
import app.seb3thehacker.gearslip.host.dismissed
|
||||
import app.seb3thehacker.gearslip.host.text
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* The one alert on the car screen, from whichever hosted app sent it: Waze asking whether a
|
||||
* hazard is still there, say. As in Android Auto, a newer alert replaces the one showing, and it
|
||||
* shows over any screen, since a question about the road matters while music is up too.
|
||||
*/
|
||||
@MainThread
|
||||
internal object CarAlerts {
|
||||
class Shown(val owner: Any, val alert: Alert)
|
||||
|
||||
private val current = MutableStateFlow<Shown?>(null)
|
||||
val shown: StateFlow<Shown?> = current.asStateFlow()
|
||||
|
||||
fun show(owner: Any, alert: Alert) {
|
||||
val old = current.value
|
||||
current.value = Shown(owner, alert)
|
||||
GearslipLog.i("host: alert ${alert.id} \"${alert.title.text()}\" with ${alert.actions.size} buttons")
|
||||
if (old != null && !(old.owner === owner && old.alert.id == alert.id)) old.alert.callbackDelegate.dismissed()
|
||||
}
|
||||
|
||||
/** The app took its alert down; a no-op unless that alert is the one showing. */
|
||||
fun dismiss(owner: Any, id: Int) {
|
||||
val shown = current.value ?: return
|
||||
if (shown.owner !== owner || shown.alert.id != id) return
|
||||
current.value = null
|
||||
shown.alert.callbackDelegate.dismissed()
|
||||
}
|
||||
|
||||
/** The driver tapped one of its buttons: the app gets the tap, and the alert goes. */
|
||||
fun tapped(shown: Shown, action: Action) {
|
||||
action.onClickDelegate.click("alert button")
|
||||
if (current.value !== shown) return
|
||||
current.value = null
|
||||
shown.alert.callbackDelegate.dismissed()
|
||||
}
|
||||
|
||||
fun cancelled(shown: Shown, reason: Int) {
|
||||
if (current.value !== shown) return
|
||||
current.value = null
|
||||
shown.alert.callbackDelegate.cancel(reason)
|
||||
}
|
||||
|
||||
/** The app disconnected: its alert goes with it, and there is no one left to tell. */
|
||||
fun clear(owner: Any) {
|
||||
if (current.value?.owner === owner) current.value = null
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun CarAlertOverlay(modifier: Modifier = Modifier) {
|
||||
val current by CarAlerts.shown.collectAsState()
|
||||
val shown = current ?: return
|
||||
val alert = shown.alert
|
||||
val timed = alert.durationMillis in 1 until Alert.DURATION_SHOW_INDEFINITELY
|
||||
// What's left of the alert's time, drawn as the bar along its foot.
|
||||
val left = remember(shown) { Animatable(1f) }
|
||||
LaunchedEffect(shown) {
|
||||
if (!timed) return@LaunchedEffect
|
||||
left.animateTo(0f, tween(alert.durationMillis.coerceAtMost(Int.MAX_VALUE.toLong()).toInt(), easing = LinearEasing))
|
||||
CarAlerts.cancelled(shown, AlertCallback.REASON_TIMEOUT)
|
||||
}
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val dark = scheme.surface.luminanceIsDark()
|
||||
Surface(
|
||||
modifier
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
.widthIn(max = 640.dp)
|
||||
.fillMaxWidth()
|
||||
.semantics { liveRegion = LiveRegionMode.Polite },
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = scheme.surfaceContainerHigh,
|
||||
shadowElevation = 8.dp,
|
||||
) {
|
||||
Column {
|
||||
Row(Modifier.padding(start = 20.dp, end = 12.dp, top = 16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (alert.icon != null) {
|
||||
CarGlyph(alert.icon, Modifier.size(40.dp))
|
||||
Spacer(Modifier.width(16.dp))
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
alert.title.text(),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
alert.subtitle?.text()?.takeIf { it.isNotEmpty() }?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = scheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
// With no timer and no buttons, nothing else would ever take it away.
|
||||
if (!timed || alert.actions.isEmpty()) {
|
||||
GsIconBox(
|
||||
onClick = { CarAlerts.cancelled(shown, AlertCallback.REASON_USER_ACTION) },
|
||||
modifier = Modifier.size(56.dp),
|
||||
colors = GsColors(Color.Transparent, scheme.onSurface),
|
||||
shape = CircleShape,
|
||||
) {
|
||||
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (alert.actions.isNotEmpty()) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
alert.actions.take(2).forEach { action ->
|
||||
val custom = action.backgroundColor?.type?.let { it != androidx.car.app.model.CarColor.TYPE_DEFAULT } == true
|
||||
GsIconBox(
|
||||
onClick = { CarAlerts.tapped(shown, action) },
|
||||
modifier = Modifier.weight(1f).height(64.dp),
|
||||
colors = GsColors(
|
||||
action.backgroundColor.color(dark, scheme.surfaceContainerHighest),
|
||||
if (custom) Color.White else scheme.onSurface,
|
||||
),
|
||||
enabled = action.isEnabled,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (action.icon != null) {
|
||||
CarGlyph(action.icon, Modifier.size(28.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
Text(action.title.text(), style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
if (timed) {
|
||||
Box(Modifier.fillMaxWidth().height(4.dp).background(scheme.surfaceContainerHighest)) {
|
||||
Box(Modifier.fillMaxWidth(left.value).height(4.dp).clip(RoundedCornerShape(2.dp)).background(scheme.primary))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
@@ -11,6 +13,8 @@ import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
||||
import kotlin.math.hypot
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -73,10 +77,28 @@ fun CarAppStage(
|
||||
if (status.surfaceRequested) {
|
||||
AppSurface(connection, frame, template, Modifier.fillMaxSize())
|
||||
}
|
||||
// The docked player floats over one side of the map: the chrome keeps clear of it,
|
||||
// and the app hears that side is covered, so its map centres in the rest.
|
||||
val panel = LocalSidePanelInset.current
|
||||
val panelAtStart = LocalCardsAtEnd.current
|
||||
TemplateChrome(
|
||||
template,
|
||||
Modifier.fillMaxSize().padding(top = if (embedded) 0.dp else CHROME_BAR, bottom = navBar),
|
||||
Modifier.fillMaxSize().padding(
|
||||
top = if (embedded) 0.dp else CHROME_BAR,
|
||||
bottom = navBar,
|
||||
start = if (panelAtStart) panel else 0.dp,
|
||||
end = if (panelAtStart) 0.dp else panel,
|
||||
),
|
||||
)
|
||||
if (panel > 0.dp) {
|
||||
Box(
|
||||
Modifier
|
||||
.align(if (panelAtStart) Alignment.CenterStart else Alignment.CenterEnd)
|
||||
.fillMaxHeight()
|
||||
.width(panel)
|
||||
.covers("side-panel", if (panelAtStart) CoverEdge.START else CoverEdge.END),
|
||||
)
|
||||
}
|
||||
BottomCover(navBar, Modifier.align(Alignment.BottomCenter))
|
||||
}
|
||||
if (!embedded) ChromeBar(status, onDisconnect, Modifier.align(Alignment.TopCenter))
|
||||
@@ -106,12 +128,16 @@ private fun AppSurface(
|
||||
val measured = bounds?.takeIf { width > 0 && height > 0 }?.let { cover?.visibleWithin(it, width, height) }
|
||||
// The card column moves the map's centre but isn't taken out of the stable area: apps size
|
||||
// their own overlays from it, and Vela's speedometer shrank to fit the strip right of the cards.
|
||||
val measuredStable = bounds?.takeIf { width > 0 && height > 0 }?.let { cover?.visibleWithin(it, width, height, skip = CoverEdge.START) }
|
||||
LaunchedEffect(template, size, measured, measuredStable) {
|
||||
val cardsAtEnd = LocalCardsAtEnd.current
|
||||
val cardEdge = cardEdge
|
||||
val measuredStable = bounds?.takeIf { width > 0 && height > 0 }?.let { cover?.visibleWithin(it, width, height, skip = cardEdge) }
|
||||
LaunchedEffect(template, size, measured, measuredStable, cardsAtEnd) {
|
||||
if (width > 0 && height > 0) {
|
||||
val visible = visibleAreaFor(template, width, height, measured)
|
||||
val stable = if (!isContentTemplate(template)) {
|
||||
visibleAreaFor(template, width, height, measuredStable)
|
||||
val visible = visibleAreaFor(template, width, height, measured, cardsAtEnd)
|
||||
// Waze seems to centre its car in the stable area rather than the visible one, so it
|
||||
// gets the cards taken out of that too. Test: see whether its car moves out from under them.
|
||||
val stable = if (!isContentTemplate(template) && connection.appPackage != "com.waze") {
|
||||
visibleAreaFor(template, width, height, measuredStable, cardsAtEnd)
|
||||
} else {
|
||||
visible
|
||||
}
|
||||
@@ -227,14 +253,14 @@ private fun ChromeBar(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
status.app ?: "Car app",
|
||||
status.app ?: stringResource(R.string.stage_car_app),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
GsIconButton(
|
||||
androidx.compose.material.icons.Icons.Filled.Close,
|
||||
"Close ${status.app ?: "the app"}",
|
||||
status.app?.let { stringResource(R.string.stage_close, it) } ?: stringResource(R.string.stage_close_app),
|
||||
onDisconnect,
|
||||
size = 36.dp,
|
||||
iconSize = 20.dp,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.List
|
||||
import androidx.compose.material.icons.filled.Call
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
@@ -13,7 +16,9 @@ import app.seb3thehacker.gearslip.ProjectedContent
|
||||
/** One tile in the launcher and the screen it opens. */
|
||||
class CarApp(
|
||||
val id: String,
|
||||
/** English, for matching what a driver says; [labelRes] is what the screen shows. */
|
||||
val label: String,
|
||||
@param:StringRes val labelRes: Int,
|
||||
val icon: ImageVector,
|
||||
val content: @Composable () -> Unit,
|
||||
)
|
||||
@@ -22,19 +27,28 @@ class CarApp(
|
||||
object CarApps {
|
||||
|
||||
val all: List<CarApp> = listOf(
|
||||
CarApp("web", "Browser", Icons.Filled.Search) {
|
||||
CarApp("web", "Browser", R.string.app_browser, Icons.Filled.Search) {
|
||||
WebApp(content = ProjectedContent.resolve(LocalContext.current))
|
||||
},
|
||||
CarApp("phone", "Screen sharing", Icons.Filled.Share) {
|
||||
CarApp("phone", "Screen sharing", R.string.app_screen_sharing, Icons.Filled.Share) {
|
||||
PhoneAppsScreen()
|
||||
},
|
||||
CarApp("dialer", "Phone", Icons.Filled.Call) {
|
||||
CarApp("dialer", "Phone", R.string.carset_phone, Icons.Filled.Call) {
|
||||
PhoneDialerScreen()
|
||||
},
|
||||
CarApp("settings", "Settings", Icons.Filled.Settings) {
|
||||
CarApp("settings", "Settings", R.string.settings_title, Icons.Filled.Settings) {
|
||||
CarSettingsScreen()
|
||||
},
|
||||
CarApp("allapps", "All apps", R.string.app_all_apps, Icons.Filled.List) {
|
||||
AllAppsScreen()
|
||||
},
|
||||
)
|
||||
|
||||
fun find(id: String): CarApp? = all.firstOrNull { it.id == id }
|
||||
|
||||
/** The screens [BuiltInApps.entries] offers here, so a choice list never names a hidden one. */
|
||||
fun shown(launcher: Boolean, canCall: Boolean): List<CarApp> {
|
||||
val ids = BuiltInApps.entries(launcher, canCall).mapNotNull { (it.builtIn?.screen as? CarScreen.App)?.id }.toSet()
|
||||
return all.filter { it.id in ids }
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
@@ -38,6 +40,11 @@ import java.security.MessageDigest
|
||||
* phone's own Bluetooth address (apps get a placeholder), and confirming the pairing code without
|
||||
* asking. So a car may turn the request down, and the phone always shows its pairing dialog. The
|
||||
* channel stays closed unless the driver taps Pair: every new channel is a risk to cars that work.
|
||||
*
|
||||
* The one exception is a driver who typed the phone's real address into Settings. A VW MIB2 drops
|
||||
* an already paired phone's Bluetooth when it connects over USB, because nothing tells it the two
|
||||
* are the same phone (issue #28). With the address, Gearslip opens the channel on every connection
|
||||
* and sends the same request, which is how Android Auto says so.
|
||||
*/
|
||||
object CarBluetooth {
|
||||
|
||||
@@ -61,13 +68,15 @@ object CarBluetooth {
|
||||
@Volatile private var bondStarted = false
|
||||
/** From the Pair tap until it succeeds or fails, whether or not the card still shows. */
|
||||
@Volatile private var pairing = false
|
||||
/** Already paired: the channel is open only to tell the car which phone this is. */
|
||||
@Volatile private var announcing = false
|
||||
private var receiver: BroadcastReceiver? = null
|
||||
|
||||
/** The car's Bluetooth address, when it named one in its service list. */
|
||||
val carAddress: String? get() = car?.service?.carAddress
|
||||
|
||||
/** The car's Bluetooth channel once the driver has asked to pair, -1 otherwise. */
|
||||
val channelId: Int get() = car?.takeIf { pairing }?.service?.serviceId ?: -1
|
||||
val channelId: Int get() = car?.takeIf { pairing || announcing }?.service?.serviceId ?: -1
|
||||
|
||||
fun init(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
@@ -81,18 +90,39 @@ object CarBluetooth {
|
||||
) {
|
||||
car = Car(service, open, send)
|
||||
bondStarted = false
|
||||
announcing = false
|
||||
log.i("the car offers Bluetooth pairing, methods=${service.pairingMethods}")
|
||||
val device = device() ?: return
|
||||
when {
|
||||
device.bondState == BluetoothDevice.BOND_BONDED && typedAddress() != null -> {
|
||||
log.i("already paired with this car - telling it which phone this is")
|
||||
announcing = true
|
||||
open()
|
||||
}
|
||||
device.bondState == BluetoothDevice.BOND_BONDED -> log.i("already paired with this car")
|
||||
declined(service.carAddress) -> log.i("not paired, and the driver said not now for this car")
|
||||
else -> _prompt.value = Prompt.ASK
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the car's Bluetooth channel though there's no pairing to do, as a real phone does on
|
||||
* every connection. Issue #18: a 2021 Bolt hangs up about 20 seconds in, and this is one of
|
||||
* the two channels it offers that Gearslip never opened. Without an address the driver typed,
|
||||
* it only opens the channel: a made-up address could set the car pairing with the wrong phone.
|
||||
*/
|
||||
fun announce(why: String) {
|
||||
val c = car ?: return
|
||||
if (pairing || announcing) return
|
||||
announcing = true
|
||||
log.i("-> ChannelOpenRequest on channel ${c.service.serviceId} ($why)")
|
||||
c.open()
|
||||
}
|
||||
|
||||
fun onCarDisconnected() {
|
||||
car = null
|
||||
pairing = false
|
||||
announcing = false
|
||||
main.post {
|
||||
stopWatching()
|
||||
_prompt.value = Prompt.NONE
|
||||
@@ -111,7 +141,7 @@ object CarBluetooth {
|
||||
// If the car never answers on its channel, pair from the phone anyway.
|
||||
main.postDelayed({ startBond("the car didn't answer the pairing request") }, CAR_REPLY_WAIT_MS)
|
||||
main.postDelayed({
|
||||
if (pairing) finish("Pairing didn't finish. Pair from the car's Bluetooth menu instead.")
|
||||
if (pairing) finish(R.string.bt_pair_unfinished)
|
||||
}, GIVE_UP_MS)
|
||||
}
|
||||
|
||||
@@ -137,7 +167,12 @@ object CarBluetooth {
|
||||
val status = Protobuf.readInt32Field(body, 1) ?: 0
|
||||
log.i("<- ChannelOpenResponse: status=$status")
|
||||
if (status != 0) {
|
||||
main.post { startBond("the car wouldn't open its Bluetooth channel") }
|
||||
if (announcing) log.i("the car wouldn't open its Bluetooth channel")
|
||||
else main.post { startBond("the car wouldn't open its Bluetooth channel") }
|
||||
return
|
||||
}
|
||||
if (announcing && typedAddress() == null) {
|
||||
log.i("Bluetooth channel open; no address typed in Settings, so no PairingRequest")
|
||||
return
|
||||
}
|
||||
val method = c.service.pairingMethods.firstOrNull { it == METHOD_NUMERIC_COMPARISON }
|
||||
@@ -151,6 +186,7 @@ object CarBluetooth {
|
||||
MSG_PAIRING_RESPONSE -> {
|
||||
val fields = Wire.fields(body)
|
||||
log.i("<- PairingResponse status=${Wire.varint(fields, 1)} already_paired=${Wire.varint(fields, 2) == 1L}")
|
||||
if (!pairing) return
|
||||
// Some cars start pairing themselves once they answer; give them a moment first.
|
||||
main.postDelayed({ startBond("the car answered") }, CAR_BOND_WAIT_MS)
|
||||
}
|
||||
@@ -168,7 +204,7 @@ object CarBluetooth {
|
||||
bondStarted = true
|
||||
val started = runCatching { device.createBond() }.getOrDefault(false)
|
||||
log.i("pairing from the phone ($why): ${if (started) "started" else "refused"}")
|
||||
if (!started) finish("Pairing didn't start. Pair from the car's Bluetooth menu instead.")
|
||||
if (!started) finish(R.string.bt_pair_not_started)
|
||||
}
|
||||
|
||||
private fun watchBonding(device: BluetoothDevice) {
|
||||
@@ -184,8 +220,8 @@ object CarBluetooth {
|
||||
log.i("bond state $previous -> $state")
|
||||
when {
|
||||
state == BluetoothDevice.BOND_BONDING -> bondStarted = true
|
||||
state == BluetoothDevice.BOND_BONDED -> finish("Paired. Calls now play through the car.")
|
||||
previous == BluetoothDevice.BOND_BONDING -> finish("Pairing didn't finish. Pair from the car's Bluetooth menu instead.")
|
||||
state == BluetoothDevice.BOND_BONDED -> finish(R.string.bt_paired)
|
||||
previous == BluetoothDevice.BOND_BONDING -> finish(R.string.bt_pair_unfinished)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,12 +229,12 @@ object CarBluetooth {
|
||||
receiver = r
|
||||
}
|
||||
|
||||
private fun finish(message: String) {
|
||||
private fun finish(@StringRes message: Int) {
|
||||
pairing = false
|
||||
stopWatching()
|
||||
main.removeCallbacksAndMessages(null)
|
||||
_prompt.value = Prompt.NONE
|
||||
CarToasts.show(this, message, CarToast.LENGTH_LONG)
|
||||
CarToasts.show(this, appContext?.getString(message) ?: return, CarToast.LENGTH_LONG)
|
||||
}
|
||||
|
||||
private fun stopWatching() {
|
||||
@@ -227,12 +263,30 @@ object CarBluetooth {
|
||||
return adapter.getRemoteDevice(address)
|
||||
}
|
||||
|
||||
/** Apps get 02:00:00:00:00:00 here, not the real address. Sent anyway, as the field is required. */
|
||||
/**
|
||||
* Apps get 02:00:00:00:00:00 here, not the real address, so the one the driver typed in wins.
|
||||
* Sent either way, as the field is required. Never logged: it identifies the phone.
|
||||
*/
|
||||
@SuppressLint("MissingPermission", "HardwareIds")
|
||||
private fun phoneAddress(): String =
|
||||
runCatching { appContext?.getSystemService(BluetoothManager::class.java)?.adapter?.address }.getOrNull()
|
||||
typedAddress()
|
||||
?: runCatching { appContext?.getSystemService(BluetoothManager::class.java)?.adapter?.address }.getOrNull()
|
||||
?: "02:00:00:00:00:00"
|
||||
|
||||
private fun typedAddress(): String? = appContext?.let { typedAddress(it) }
|
||||
|
||||
/** The phone's own address as the driver typed it in Settings, or null. */
|
||||
fun typedAddress(context: Context): String? =
|
||||
AppSettings.getString(context, KEY_PHONE_ADDRESS, "").takeIf { BluetoothAdapter.checkBluetoothAddress(it) }
|
||||
|
||||
/** Saves [address] after tidying it up; false when it isn't one. Empty clears it. */
|
||||
fun setTypedAddress(context: Context, address: String): Boolean {
|
||||
val tidy = address.trim().uppercase().replace('-', ':')
|
||||
if (tidy.isNotEmpty() && !BluetoothAdapter.checkBluetoothAddress(tidy)) return false
|
||||
AppSettings.putString(context, KEY_PHONE_ADDRESS, tidy)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun declined(address: String): Boolean {
|
||||
val context = appContext ?: return false
|
||||
return hash(address) in AppSettings.getString(context, KEY_DECLINED, "").split(",")
|
||||
@@ -244,6 +298,7 @@ object CarBluetooth {
|
||||
.take(8).joinToString("") { "%02x".format(it) }
|
||||
|
||||
private const val KEY_DECLINED = "bluetooth_pair_declined"
|
||||
private const val KEY_PHONE_ADDRESS = "phone_bluetooth_address"
|
||||
private const val CAR_REPLY_WAIT_MS = 5_000L
|
||||
private const val CAR_BOND_WAIT_MS = 3_000L
|
||||
private const val GIVE_UP_MS = 90_000L
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
@@ -60,7 +62,7 @@ fun CarDashboardScreen() {
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
ScreenHeader("Today")
|
||||
ScreenHeader(stringResource(R.string.dash_today))
|
||||
WeatherCard(context)
|
||||
AgendaCard(context)
|
||||
NotificationsSection()
|
||||
@@ -84,9 +86,9 @@ private fun WeatherCard(context: Context) {
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
when (val s = state) {
|
||||
WeatherState.Loading -> Text("Getting weather…", style = MaterialTheme.typography.bodyLarge)
|
||||
WeatherState.Loading -> Text(stringResource(R.string.dash_getting_weather), style = MaterialTheme.typography.bodyLarge)
|
||||
is WeatherState.Unavailable -> Text(
|
||||
s.reason, style = MaterialTheme.typography.bodyLarge,
|
||||
stringResource(s.reason), style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
is WeatherState.Ready -> {
|
||||
@@ -94,7 +96,7 @@ private fun WeatherCard(context: Context) {
|
||||
WeatherIcon(w.code, w.isDay, size = 44.dp)
|
||||
Column {
|
||||
Text(
|
||||
"${w.temp}${w.tempUnit} · ${Weather.describe(w.code)}",
|
||||
"${w.temp}${w.tempUnit} · ${Weather.condition(w.code)}",
|
||||
style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(w.place, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
@@ -112,12 +114,12 @@ private fun AgendaCard(context: Context) {
|
||||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("Coming up", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Text(stringResource(R.string.dash_coming_up), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
val list = events
|
||||
when {
|
||||
list == null -> Text("Loading calendar…", style = MaterialTheme.typography.bodyMedium)
|
||||
list == null -> Text(stringResource(R.string.dash_loading_calendar), style = MaterialTheme.typography.bodyMedium)
|
||||
list.isEmpty() -> Text(
|
||||
"Nothing in the next week.",
|
||||
stringResource(R.string.dash_nothing_week),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -135,7 +137,7 @@ private fun EventRow(event: AgendaEvent) {
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
eventWhen(event),
|
||||
eventWhen(event, stringResource(R.string.dash_all_day)),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
@@ -149,14 +151,14 @@ private fun EventRow(event: AgendaEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
/** "Today" events show just a time; later days get a weekday so a week's list stays readable. */
|
||||
private fun eventWhen(event: AgendaEvent): String {
|
||||
/** stringResource(R.string.dash_today) events show just a time; later days get a weekday so a week's list stays readable. */
|
||||
private fun eventWhen(event: AgendaEvent, allDay: String): String {
|
||||
val zone = if (event.allDay) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||
val start = Calendar.getInstance(zone).apply { timeInMillis = event.startMillis }
|
||||
val today = Calendar.getInstance()
|
||||
val sameDay = start.get(Calendar.YEAR) == today.get(Calendar.YEAR) &&
|
||||
start.get(Calendar.DAY_OF_YEAR) == today.get(Calendar.DAY_OF_YEAR)
|
||||
val time = if (event.allDay) "All day" else DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(event.startMillis))
|
||||
val time = if (event.allDay) allDay else DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(event.startMillis))
|
||||
if (sameDay || (event.allDay && start.before(today))) return time
|
||||
return SimpleDateFormat("EEE", Locale.getDefault()).apply { timeZone = zone }.format(Date(event.startMillis)) + " " + time
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import app.seb3thehacker.gearslip.Wire
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* The car's sensors, read into real values for the car apps that ask (a map's speed, an EV app's
|
||||
* range). [CarSensors] keeps the raw readings for the Vehicle data screen; this keeps the few with
|
||||
* a known meaning, decoded the way Android Auto's SensorBatch scales them:
|
||||
*
|
||||
* gps_location = 1 { latitude_e7 = 2; longitude_e7 = 3; accuracy_e3 = 4; altitude_e2 = 5;
|
||||
* speed_e3 = 6; bearing_e6 = 7 }
|
||||
* compass = 2 { bearing_e6 = 1 } speed = 3 { speed_e3 = 1 } (metres per second)
|
||||
* odometer = 5 { kms_e1 = 1 } fuel = 6 { level = 1 (percent); range = 2 (km); low = 3 }
|
||||
*
|
||||
* Each value is null until the car first sends it, and the whole lot clears when it disconnects.
|
||||
*/
|
||||
object CarData {
|
||||
|
||||
class Location(
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val accuracyMeters: Float?,
|
||||
val altitudeMeters: Double?,
|
||||
val speedMps: Float?,
|
||||
val bearingDegrees: Float?,
|
||||
)
|
||||
|
||||
data class Snapshot(
|
||||
val speedMps: Float? = null,
|
||||
val odometerKm: Float? = null,
|
||||
val fuelPercent: Int? = null,
|
||||
val rangeKm: Int? = null,
|
||||
val fuelLow: Boolean? = null,
|
||||
val compassDegrees: Float? = null,
|
||||
val location: Location? = null,
|
||||
/** When each kind last changed, by its SensorBatch field number. */
|
||||
val updatedAt: Map<Int, Long> = emptyMap(),
|
||||
)
|
||||
|
||||
/** What the car says it is, from its service discovery: model and year. It names no maker. */
|
||||
class Model(val name: String, val year: Int?)
|
||||
|
||||
private val flow = MutableStateFlow(Snapshot())
|
||||
val state: StateFlow<Snapshot> = flow
|
||||
|
||||
@Volatile var model: Model? = null
|
||||
private set
|
||||
|
||||
fun onModel(name: String, year: String) {
|
||||
model = name.takeIf { it.isNotBlank() }?.let { Model(it, year.toIntOrNull()?.takeIf { y -> y > 0 }) }
|
||||
}
|
||||
|
||||
fun onEvent(body: ByteArray) {
|
||||
val now = System.currentTimeMillis()
|
||||
val batch = Wire.fields(body)
|
||||
var s = flow.value
|
||||
val touched = HashSet<Int>()
|
||||
// Each kind may come several times in one batch: the last is the newest.
|
||||
Wire.allBytes(batch, LOCATION).lastOrNull()?.let { bytes ->
|
||||
val f = Wire.fields(bytes)
|
||||
val lat = Wire.varint(f, 2)?.toInt()
|
||||
val lon = Wire.varint(f, 3)?.toInt()
|
||||
if (lat != null && lon != null) {
|
||||
s = s.copy(
|
||||
location = Location(
|
||||
lat / 1e7, lon / 1e7,
|
||||
Wire.varint(f, 4)?.let { it / 1e3f },
|
||||
Wire.varint(f, 5)?.let { it.toInt() / 1e2 },
|
||||
Wire.varint(f, 6)?.let { it.toInt() / 1e3f },
|
||||
Wire.varint(f, 7)?.let { it.toInt() / 1e6f },
|
||||
),
|
||||
)
|
||||
touched += LOCATION
|
||||
}
|
||||
}
|
||||
Wire.allBytes(batch, COMPASS).lastOrNull()?.let { bytes ->
|
||||
Wire.varint(Wire.fields(bytes), 1)?.let { s = s.copy(compassDegrees = it.toInt() / 1e6f); touched += COMPASS }
|
||||
}
|
||||
Wire.allBytes(batch, SPEED).lastOrNull()?.let { bytes ->
|
||||
Wire.varint(Wire.fields(bytes), 1)?.let { s = s.copy(speedMps = it.toInt() / 1e3f); touched += SPEED }
|
||||
}
|
||||
Wire.allBytes(batch, ODOMETER).lastOrNull()?.let { bytes ->
|
||||
Wire.varint(Wire.fields(bytes), 1)?.let { s = s.copy(odometerKm = it.toInt() / 10f); touched += ODOMETER }
|
||||
}
|
||||
Wire.allBytes(batch, FUEL).lastOrNull()?.let { bytes ->
|
||||
val f = Wire.fields(bytes)
|
||||
s = s.copy(
|
||||
fuelPercent = Wire.varint(f, 1)?.toInt() ?: s.fuelPercent,
|
||||
rangeKm = Wire.varint(f, 2)?.toInt() ?: s.rangeKm,
|
||||
fuelLow = Wire.varint(f, 3)?.let { it != 0L } ?: s.fuelLow,
|
||||
)
|
||||
touched += FUEL
|
||||
}
|
||||
if (touched.isEmpty()) return
|
||||
flow.value = s.copy(updatedAt = s.updatedAt + touched.associateWith { now })
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
flow.value = Snapshot()
|
||||
model = null
|
||||
}
|
||||
|
||||
const val LOCATION = 1
|
||||
const val COMPASS = 2
|
||||
const val SPEED = 3
|
||||
const val ODOMETER = 5
|
||||
const val FUEL = 6
|
||||
}
|
||||
@@ -37,6 +37,9 @@ object CarEnvironment {
|
||||
private val frameFlow = MutableStateFlow(Frame(800, 480, 160))
|
||||
val frame: StateFlow<Frame> = frameFlow
|
||||
|
||||
/** The density the car reported; [frame] carries the one the car UI and its apps draw at. */
|
||||
private var carDensityDpi = 160
|
||||
|
||||
val phoneDark: StateFlow<Boolean> = phoneDarkFlow
|
||||
val darkOutside: StateFlow<Boolean> = darkOutsideFlow
|
||||
val battery: StateFlow<Battery> = batteryFlow
|
||||
@@ -64,7 +67,9 @@ object CarEnvironment {
|
||||
}
|
||||
|
||||
@Volatile private var sensorNight: Boolean? = null
|
||||
private var appContext: Context? = null
|
||||
/** Set once the car UI starts; the cluster screen draws with it too. */
|
||||
var appContext: Context? = null
|
||||
private set
|
||||
private var running = false
|
||||
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
@@ -93,8 +98,18 @@ object CarEnvironment {
|
||||
}
|
||||
|
||||
fun setDisplay(width: Int, height: Int, densityDpi: Int) {
|
||||
displayFlow.value = "${width}x$height @ ${densityDpi}dpi"
|
||||
frameFlow.value = Frame(width, height, densityDpi)
|
||||
carDensityDpi = densityDpi
|
||||
val dpi = CarSettings.effectiveDensity(densityDpi)
|
||||
displayFlow.value = "${width}x$height @ ${dpi}dpi"
|
||||
frameFlow.value = Frame(width, height, dpi)
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes up the density setting. It's read only as a session starts, so the display and the
|
||||
* apps on it can't disagree mid-drive; a session with no car behind it, the preview, asks here.
|
||||
*/
|
||||
fun applyDensity() {
|
||||
frameFlow.value = frameFlow.value.copy(densityDpi = CarSettings.effectiveDensity(carDensityDpi))
|
||||
}
|
||||
|
||||
fun start(context: Context) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -12,6 +13,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import app.seb3thehacker.gearslip.car.theme.gsPanelShadow
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
@@ -64,7 +67,9 @@ import app.seb3thehacker.gearslip.media.NowPlaying
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* The home screen: the map on the left, the docked player on the right when asked for.
|
||||
* The home screen: the map, with the docked player floating over one side of it when asked for.
|
||||
* With the player on the left, the map app's cards move to the map's far edge, so the player, the
|
||||
* map and the cards each keep their own space.
|
||||
*
|
||||
* The map is whatever navigation app was used last, brought back on its own. The player sits in
|
||||
* the nav bar's pill until its dock button moves it here; the column closes itself back to a
|
||||
@@ -94,26 +99,56 @@ fun CarHome() {
|
||||
}
|
||||
val splitScreen = phase == CarMedia.Phase.READY && now.isActive && panel != SidePanel.NONE
|
||||
val playerLeft by CarSettings.playerLeft.collectAsStateWithLifecycle()
|
||||
// What the card showed last, so it still has something to show while it slides away.
|
||||
val shown = remember { androidx.compose.runtime.mutableStateOf(SidePanel.CONTROLS) }
|
||||
if (panel != SidePanel.NONE) shown.value = panel
|
||||
|
||||
// No padding anywhere, split screen or not: the player sits flush against the map.
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
val side = Modifier.weight(1f).fillMaxHeight().padding(bottom = LocalNavBarInset.current)
|
||||
if (splitScreen && playerLeft) {
|
||||
if (panel == SidePanel.LYRICS) SideLyrics(now, side) else DockedPlayer(now, side)
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.weight(if (splitScreen) 2.1f else 1f)
|
||||
.fillMaxHeight(),
|
||||
// The map runs edge to edge underneath; the player floats over one side of it as a card, with
|
||||
// a gap all round. The map's surface keeps its size when the card comes and goes, so the app
|
||||
// only re-centres its map instead of drawing onto a new surface.
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
val column = (maxWidth / PLAYER_SHARE).coerceIn(PLAYER_MIN_WIDTH, PLAYER_MAX_WIDTH)
|
||||
androidx.compose.runtime.CompositionLocalProvider(
|
||||
LocalCardsAtEnd provides (splitScreen && playerLeft),
|
||||
LocalSidePanelInset provides if (splitScreen) column else 0.dp,
|
||||
) {
|
||||
MapPane(navStatus, frame)
|
||||
}
|
||||
if (splitScreen && !playerLeft) {
|
||||
if (panel == SidePanel.LYRICS) SideLyrics(now, side) else DockedPlayer(now, side)
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
splitScreen,
|
||||
Modifier.align(if (playerLeft) Alignment.CenterStart else Alignment.CenterEnd),
|
||||
enter = androidx.compose.animation.slideInHorizontally(tween(220)) { if (playerLeft) -it / 3 else it / 3 } +
|
||||
androidx.compose.animation.fadeIn(tween(180)),
|
||||
exit = androidx.compose.animation.slideOutHorizontally(tween(180)) { if (playerLeft) -it / 3 else it / 3 } +
|
||||
androidx.compose.animation.fadeOut(tween(140)),
|
||||
) {
|
||||
val card = Modifier
|
||||
.width(column)
|
||||
.fillMaxHeight()
|
||||
.padding(
|
||||
start = if (playerLeft) CARD_GAP else 0.dp,
|
||||
end = if (playerLeft) 0.dp else CARD_GAP,
|
||||
top = CARD_GAP,
|
||||
bottom = LocalNavBarInset.current + CARD_GAP,
|
||||
)
|
||||
if (shown.value == SidePanel.LYRICS) SideLyrics(now, card) else DockedPlayer(now, card)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The player takes one part in this many of the screen's width. */
|
||||
private const val PLAYER_SHARE = 3.6f
|
||||
|
||||
/** Narrow enough to leave the map most of a small screen; on a wide one the map gets the extra room. */
|
||||
private val PLAYER_MIN_WIDTH = 240.dp
|
||||
private val PLAYER_MAX_WIDTH = 340.dp
|
||||
|
||||
/** Between the player card and the screen's edges, and the map's own cards beside it. */
|
||||
private val CARD_GAP = 12.dp
|
||||
|
||||
/** The card's corners: rounder than a button's, as the map app's own cards are. */
|
||||
private val CARD_SHAPE = RoundedCornerShape(24.dp)
|
||||
|
||||
@Composable
|
||||
private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame) {
|
||||
val navigator = LocalCarNavigator.current
|
||||
@@ -123,8 +158,11 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
Modifier.fillMaxSize().padding(
|
||||
start = if (LocalCardsAtEnd.current) LocalSidePanelInset.current else 0.dp,
|
||||
end = if (LocalCardsAtEnd.current) 0.dp else LocalSidePanelInset.current,
|
||||
).padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterVertically),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
val hasLast = CarSettings.lastNav.value != null
|
||||
@@ -132,18 +170,19 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame
|
||||
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)
|
||||
Text(status.app?.let { stringResource(R.string.home_starting, it) } ?: stringResource(R.string.home_starting_map), style = MaterialTheme.typography.titleMedium)
|
||||
CarAppConnection.Phase.REJECTED, CarAppConnection.Phase.FAILED -> {
|
||||
Text("${status.app ?: "The map app"} would not start.", style = MaterialTheme.typography.titleMedium)
|
||||
Text(status.app?.let { stringResource(R.string.home_failed, it) } ?: stringResource(R.string.home_failed_map), style = MaterialTheme.typography.titleMedium)
|
||||
status.detail?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
GsButton(onClick = navigator::apps, tone = GsTone.Tonal) { Text("Choose another app") }
|
||||
Spacer(Modifier.height(8.dp))
|
||||
StatusButton(stringResource(R.string.home_choose_app), GsTone.Primary, navigator::apps)
|
||||
}
|
||||
else -> if (!hasNavApps) {
|
||||
// Nothing to auto-select and nothing to pick from Car apps either - this is the
|
||||
// only case worth a distinct screen, since every other IDLE case resolves itself.
|
||||
Text("No mapping apps found.", style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.home_no_maps), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"Install a navigation app that supports Android Auto and it will start here on its own.",
|
||||
stringResource(R.string.home_no_maps_body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -151,9 +190,18 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame
|
||||
// IDLE with a last app on record means the driver stopped it themselves - it will
|
||||
// not come back on its own, so the screen must offer a way to bring it back rather
|
||||
// than sit on a "starting" message that never resolves.
|
||||
Text("Map stopped.", style = MaterialTheme.typography.titleMedium)
|
||||
GsButton(onClick = { scope.launch { CarServices.reconnectNav(frame) } }, tone = GsTone.Tonal) { Text("Reopen") }
|
||||
GsButton(onClick = { navigator.apps() }, tone = GsTone.Tonal) { Text("Car apps") }
|
||||
Text(stringResource(R.string.home_map_stopped), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
stringResource(R.string.home_map_stopped_body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
// Side by side, the same size, the likely one lit: one clear choice, not a stack.
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
StatusButton(stringResource(R.string.home_reopen_map), GsTone.Primary) { scope.launch { CarServices.reconnectNav(frame) } }
|
||||
StatusButton(stringResource(R.string.home_car_apps), GsTone.Tonal) { navigator.apps() }
|
||||
}
|
||||
} else {
|
||||
// Autostart is still picking one - a navigation app is installed, so this resolves
|
||||
// to a "Starting..." state itself, in the next frame or two.
|
||||
@@ -162,9 +210,17 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame
|
||||
}
|
||||
}
|
||||
|
||||
/** A big, even button for the map pane's few choices. */
|
||||
@Composable
|
||||
private fun StatusButton(label: String, tone: GsTone, onClick: () -> Unit) {
|
||||
GsButton(onClick = onClick, tone = tone, modifier = Modifier.width(220.dp).height(64.dp)) {
|
||||
Text(label, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The player, docked beside the map: art, title, progress, transport, then like, shuffle, repeat
|
||||
* and lyrics. While it's here the nav bar's pill steps aside on Home, so the controls never show
|
||||
* The player, docked beside the map: the art as large as it fits, then title, progress, transport,
|
||||
* and like, shuffle, repeat and lyrics along the bottom. While it's here the nav bar's pill steps aside on Home, so the controls never show
|
||||
* twice. The X sends it back to the nav bar.
|
||||
*/
|
||||
@Composable
|
||||
@@ -175,28 +231,31 @@ private fun DockedPlayer(now: NowPlaying, modifier: Modifier) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
|
||||
|
||||
Surface(modifier = modifier, color = rememberArtColor(art, MaterialTheme.colorScheme.surfaceVariant)) {
|
||||
BoxWithConstraints(Modifier.fillMaxSize().padding(12.dp)) {
|
||||
// Whatever height the controls below leave over, so the art grows on a taller
|
||||
// screen and shrinks rather than pushing the buttons off a short one.
|
||||
val artSize = minOf(maxWidth - 44.dp, maxHeight - 250.dp).coerceIn(56.dp, 200.dp)
|
||||
// The theme's surface, with a faint wash of the cover's colours behind the top of it. A solid
|
||||
// slab of album colour beside the map fought it for attention; this only hints at the song.
|
||||
Surface(modifier = modifier.gsPanelShadow(CARD_SHAPE), shape = CARD_SHAPE, color = scheme.surfaceContainer) {
|
||||
AlbumGlow(art, Modifier.fillMaxSize())
|
||||
Box(Modifier.fillMaxSize().padding(12.dp)) {
|
||||
Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
// The cover gets everything the controls leave, as big a square as fits, sitting
|
||||
// right on top of the title; the controls stay pinned to the bottom.
|
||||
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.BottomCenter) {
|
||||
val artSize = minOf(maxWidth, maxHeight).coerceAtLeast(0.dp)
|
||||
// Art opens the full media screen, same as on the pill.
|
||||
val coverShape = RoundedCornerShape(16.dp)
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.size(artSize)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.gsPanelShadow(coverShape)
|
||||
.clip(coverShape)
|
||||
.background(scheme.surface)
|
||||
.clickable { navigator.media() },
|
||||
) {
|
||||
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
||||
// A new song's cover fades in over the old one instead of cutting to it.
|
||||
androidx.compose.animation.Crossfade(art, animationSpec = tween(350), label = "cover") { shown ->
|
||||
shown?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
||||
}
|
||||
}
|
||||
GsIconButton(
|
||||
Icons.Filled.Close, "Back to the nav bar", navigator::closeSidePanel,
|
||||
Modifier.align(Alignment.TopEnd), size = 36.dp, iconSize = 20.dp,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
@@ -212,46 +271,53 @@ private fun DockedPlayer(now: NowPlaying, modifier: Modifier) {
|
||||
} else {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
}
|
||||
TransportRow(now, media, skip = 48.dp, modifier = Modifier.fillMaxWidth())
|
||||
TransportRow(now, media, skip = 56.dp, modifier = Modifier.fillMaxWidth())
|
||||
Spacer(Modifier.height(10.dp))
|
||||
PlayModeRow(now, media, size = 42.dp, modifier = Modifier.fillMaxWidth()) {
|
||||
PlayModeRow(now, media, size = 50.dp, modifier = Modifier.fillMaxWidth()) {
|
||||
GsIconButton(
|
||||
ImageVector.vectorResource(R.drawable.lyrics_24), "Lyrics", navigator::showSideLyrics,
|
||||
size = 42.dp, iconSize = 22.dp,
|
||||
ImageVector.vectorResource(R.drawable.lyrics_24), stringResource(R.string.storage_lyrics), navigator::showSideLyrics,
|
||||
size = 50.dp, iconSize = 26.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
GsIconButton(
|
||||
Icons.Filled.Close, stringResource(R.string.home_back_nav), navigator::closeSidePanel,
|
||||
Modifier.align(Alignment.TopEnd), size = 36.dp, iconSize = 20.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lyrics alone beside the map, big enough to read at a glance. No art: that space goes to a
|
||||
* larger title and artist instead. The player goes back to the nav bar meanwhile, whose dock
|
||||
* button swaps these back out for the controls.
|
||||
* Lyrics alone beside the map, big enough to read at a glance. The header is slim, since the
|
||||
* player goes back to the nav bar meanwhile and names the song there. Its note button swaps the
|
||||
* lyrics back for the player; the X closes the panel.
|
||||
*/
|
||||
@Composable
|
||||
private fun SideLyrics(now: NowPlaying, modifier: Modifier) {
|
||||
val navigator = LocalCarNavigator.current
|
||||
val context = LocalContext.current
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
|
||||
Surface(modifier = modifier, color = rememberArtColor(art, MaterialTheme.colorScheme.surfaceVariant)) {
|
||||
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
now.title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
now.artist, style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Surface(modifier = modifier.gsPanelShadow(CARD_SHAPE), shape = CARD_SHAPE, color = scheme.surfaceContainer) {
|
||||
Column(Modifier.fillMaxSize().padding(start = 18.dp, end = 12.dp, top = 12.dp, bottom = 8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.size(36.dp).clip(RoundedCornerShape(8.dp)).background(scheme.surface)) {
|
||||
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
||||
}
|
||||
GsIconButton(Icons.Filled.Close, "Hide lyrics", navigator::closeSidePanel, size = 36.dp, iconSize = 20.dp)
|
||||
Spacer(Modifier.size(10.dp))
|
||||
Text(
|
||||
listOf(now.title, now.artist).filter { it.isNotEmpty() }.joinToString(" · "),
|
||||
Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = scheme.onSurfaceVariant,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
// The way back to the player, as the player's own Lyrics button is the way here.
|
||||
GsIconButton(MediaIcons.MusicNote, stringResource(R.string.home_show_player), navigator::showSidePlayer, size = 36.dp, iconSize = 20.dp)
|
||||
Spacer(Modifier.size(6.dp))
|
||||
GsIconButton(Icons.Filled.Close, stringResource(R.string.home_hide_lyrics), navigator::closeSidePanel, size = 36.dp, iconSize = 20.dp)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
LyricsPanel(now, Modifier.weight(1f).fillMaxWidth(), lineStyle = MaterialTheme.typography.headlineSmall)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -153,7 +155,7 @@ fun CarKeyboard(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Key(if (symbols) "abc" else "?123", Modifier.weight(1.4f)) { symbols = !symbols }
|
||||
Key("space", Modifier.weight(if (onDismiss != null) 3.2f else 4f)) { onTextChange("$text ") }
|
||||
Key(stringResource(R.string.keyboard_space), Modifier.weight(if (onDismiss != null) 3.2f else 4f)) { onTextChange("$text ") }
|
||||
if (onDismiss != null) {
|
||||
DismissKey(onDismiss, Modifier.weight(1.4f))
|
||||
}
|
||||
@@ -200,7 +202,7 @@ private fun DismissKey(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(Icons.Filled.Close, contentDescription = "Hide keyboard")
|
||||
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.keyboard_hide))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -220,7 +222,7 @@ private fun SubmitKey(modifier: Modifier, search: Boolean, image: ImageVector?,
|
||||
) {
|
||||
Icon(
|
||||
image ?: if (search) Icons.Filled.Search else Icons.Filled.ArrowBack,
|
||||
contentDescription = "Submit",
|
||||
contentDescription = stringResource(R.string.keyboard_submit),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
@@ -127,7 +129,7 @@ internal object LauncherCache {
|
||||
// notifications - rather than a filter per kind that has to know about the others.
|
||||
val tiles = (carApps.keys + mediaApps.keys + messagingApps.keys).flatMap { pkg ->
|
||||
val icon by lazy { iconOf(pkg) }
|
||||
tilesFor(carApps[pkg], mediaApps[pkg], messagingApps[pkg]).map { tile ->
|
||||
tilesFor(carApps[pkg], mediaApps[pkg], messagingApps[pkg], context.getString(R.string.media_browse)).map { tile ->
|
||||
when (tile) {
|
||||
is AppTile.Screen -> Entry(tile.label, icon, template = tile.app)
|
||||
is AppTile.Player -> Entry(tile.app.label, icon, media = tile.app)
|
||||
@@ -163,6 +165,7 @@ private fun tilesFor(
|
||||
car: TemplateApp?,
|
||||
media: app.seb3thehacker.gearslip.media.MediaApp?,
|
||||
messaging: app.seb3thehacker.gearslip.notify.MessagingApp?,
|
||||
browse: String,
|
||||
): List<AppTile> {
|
||||
if (car != null && !car.isMediaScreen && car.kind != "messaging") return listOf(AppTile.Screen(car, car.label))
|
||||
val pkg = car?.component?.packageName ?: media?.component?.packageName ?: messaging?.packageName ?: return emptyList()
|
||||
@@ -170,7 +173,7 @@ private fun tilesFor(
|
||||
val screenRefused = KnownApps.screenBroken(pkg) && KnownApps.playerWorks(pkg)
|
||||
val messengerScreen = messaging != null && (car?.kind == "messaging" || car?.kind == "app")
|
||||
if (car != null && !messengerScreen && !(media != null && screenRefused)) {
|
||||
add(AppTile.Screen(car, if (media != null) "${car.label} · Browse" else car.label))
|
||||
add(AppTile.Screen(car, if (media != null) "${car.label} · $browse" else car.label))
|
||||
}
|
||||
if (media != null) add(AppTile.Player(media))
|
||||
else if (messaging != null) add(AppTile.Messages(messaging))
|
||||
@@ -182,10 +185,13 @@ private fun tilesFor(
|
||||
internal fun rememberAllEntries(): List<Entry> {
|
||||
val context = LocalContext.current
|
||||
val installed by LauncherCache.state.collectAsState()
|
||||
val launcher by LauncherMode.active.collectAsState()
|
||||
LaunchedEffect(context) {
|
||||
if (LauncherCache.entries == null) withContext(Dispatchers.IO) { LauncherCache.load(context) }
|
||||
}
|
||||
return remember(installed) { BuiltInApps.entries + installed.orEmpty() }
|
||||
return remember(installed, launcher) {
|
||||
BuiltInApps.entries(launcher, LauncherMode.canCall(context)) + installed.orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
/** The launcher's groups, in the order they're laid out. */
|
||||
@@ -221,9 +227,10 @@ private fun groupOf(entry: Entry, mediaPackages: Set<String>): AppGroup {
|
||||
* The launcher's tiles in the order they're drawn: grouped by [AppGroup], alphabetical inside a
|
||||
* group with Settings last of all, then with the driver's own moves from [custom] laid over it.
|
||||
* Moved apps only trade places among themselves, so an app installed later still lands in its group.
|
||||
* [hidden] leaves tiles out of the drawer only: a pinned app stays on the nav bar.
|
||||
*/
|
||||
internal fun launcherOrder(all: List<Entry>, showVehicleData: Boolean, custom: List<String>): List<Entry> {
|
||||
val shown = all.filter { it.builtIn !== BuiltInApps.VehicleData || showVehicleData }
|
||||
internal fun launcherOrder(all: List<Entry>, showVehicleData: Boolean, custom: List<String>, hidden: Set<String>): List<Entry> {
|
||||
val shown = all.filter { (it.builtIn !== BuiltInApps.VehicleData || showVehicleData) && it.componentId !in hidden }
|
||||
val mediaPackages = shown.mapNotNull { it.media?.component?.packageName }.toSet()
|
||||
val grouped = shown.sortedWith(
|
||||
compareBy<Entry>({ groupOf(it, mediaPackages) }, { it.builtIn === BuiltInApps.Settings }, { it.label.lowercase() }),
|
||||
@@ -237,7 +244,7 @@ internal fun launcherOrder(all: List<Entry>, showVehicleData: Boolean, custom: L
|
||||
|
||||
/** An app by the id a pin or the running list knows it by, built-in or installed. */
|
||||
internal fun findEntry(id: String): Entry? =
|
||||
BuiltInApps.entries.find { it.componentId == id } ?: LauncherCache.entries?.find { it.componentId == id }
|
||||
BuiltInApps.everyEntry.find { it.componentId == id } ?: LauncherCache.entries?.find { it.componentId == id }
|
||||
|
||||
/** Rescans the car and media apps now, replacing the cached list; the launcher keeps showing the old one meanwhile. */
|
||||
fun warmLauncherApps(context: Context) { LauncherCache.load(context, force = true) }
|
||||
@@ -280,14 +287,15 @@ fun CarLauncher() {
|
||||
val pkg = entry.packageName?.takeIf { entry.messaging == null }
|
||||
val id = entry.componentId
|
||||
return Tile(
|
||||
entry.label, entry.icon, entry.builtIn?.glyph,
|
||||
entry.builtIn?.let { context.getString(it.labelRes) } ?: entry.label, entry.icon, entry.builtIn?.glyph,
|
||||
broken = pkg != null && (KnownApps.isBroken(context, pkg) || (entry.template != null && KnownApps.screenBroken(pkg))),
|
||||
onLongClick = id?.let { { navigator.showAppMenu(it) } },
|
||||
) { openApp(entry, id?.let { running[it] }, navigator, frame) }
|
||||
}
|
||||
|
||||
val appOrder by CarSettings.appOrder.collectAsState()
|
||||
val tiles = launcherOrder(all, showVehicleData, appOrder).map(::tileOf)
|
||||
val hidden by CarSettings.hiddenApps.collectAsState()
|
||||
val tiles = launcherOrder(all, showVehicleData, appOrder, hidden).map(::tileOf)
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 108.dp),
|
||||
@@ -307,7 +315,7 @@ private fun BrokenBadge(size: androidx.compose.ui.unit.Dp, modifier: Modifier =
|
||||
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))
|
||||
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.launcher_broken), tint = Color.White, modifier = Modifier.size(size * 0.66f))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.material.icons.filled.Call
|
||||
import app.seb3thehacker.gearslip.call.CarCalls
|
||||
import app.seb3thehacker.gearslip.call.CallUiState
|
||||
@@ -74,6 +75,7 @@ import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.notify.PhoneNavigation
|
||||
import app.seb3thehacker.gearslip.car.theme.GsColors
|
||||
import app.seb3thehacker.gearslip.car.theme.withTintedSurfaces
|
||||
import app.seb3thehacker.gearslip.car.theme.GsIconBox
|
||||
@@ -115,12 +117,12 @@ private fun BreadcrumbBar(navigator: CarNavigator) {
|
||||
Modifier.fillMaxWidth().height(40.dp).padding(start = 2.dp, end = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", navigator::back, size = 32.dp, iconSize = 20.dp)
|
||||
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back), navigator::back, size = 32.dp, iconSize = 20.dp)
|
||||
Spacer(Modifier.width(2.dp))
|
||||
trail.forEachIndexed { index, crumb ->
|
||||
val isLast = index == trail.lastIndex
|
||||
Text(
|
||||
crumb.label,
|
||||
crumb.shown(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = if (isLast) FontWeight.SemiBold else FontWeight.Normal,
|
||||
color = if (isLast) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
@@ -164,7 +166,11 @@ internal fun CarNavBar(navigator: CarNavigator) {
|
||||
val frame by CarEnvironment.frame.collectAsState()
|
||||
val running = rememberRunningApps()
|
||||
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceContainer) {
|
||||
// A full-width strip along the bottom in the screens' own background, so it reads as part of
|
||||
// the screen above rather than a panel on it. Over the player it's see-through, so the
|
||||
// cover's glow carries on behind it.
|
||||
val overPlayer = navigator.current == CarScreen.Media
|
||||
Surface(color = if (overPlayer) androidx.compose.ui.graphics.Color.Transparent else MaterialTheme.colorScheme.background) {
|
||||
BoxWithConstraints(Modifier.fillMaxWidth().height(NAV_BAR_HEIGHT).padding(horizontal = 10.dp)) {
|
||||
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
|
||||
// Home and Apps are two different places now, not a toggle on one button - the
|
||||
@@ -192,12 +198,13 @@ internal fun CarNavBar(navigator: CarNavigator) {
|
||||
// Next turn first, off the map only; the player shares the space when it fits,
|
||||
// and gives way to the turn when it doesn't.
|
||||
// A call takes the player's place until it ends.
|
||||
val turn = rememberNextTurn().takeIf { navigator.current != CarScreen.Home }
|
||||
// A turn from the phone has no map in the car to show it, so it shows on Home too.
|
||||
val turn = rememberNextTurn()?.takeIf { it.fromPhone || navigator.current != CarScreen.Home }
|
||||
val call = CarCalls.state.collectAsState().value as? CallUiState.Active
|
||||
BoxWithConstraints(Modifier.weight(1f).padding(horizontal = 16.dp), contentAlignment = Alignment.Center) {
|
||||
val roomForBoth = maxWidth >= TURN_AND_PLAYER_MIN_WIDTH
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
if (call == null || roomForBoth) turn?.let { NavTurnPill(it) { navigator.home() } }
|
||||
if (call == null || roomForBoth) turn?.let { NavTurnPill(it) { if (!it.fromPhone) navigator.home() } }
|
||||
if (call != null) {
|
||||
NavCallPill(call, Modifier.weight(1f, fill = false))
|
||||
} else if (turn == null || roomForBoth) {
|
||||
@@ -280,7 +287,7 @@ private fun NavHomeButton(navigator: CarNavigator, entries: List<Entry>, running
|
||||
}
|
||||
} else {
|
||||
Icon(
|
||||
ImageVector.vectorResource(R.drawable.navigation_24), contentDescription = "Home",
|
||||
ImageVector.vectorResource(R.drawable.navigation_24), contentDescription = stringResource(R.string.crumb_home),
|
||||
modifier = Modifier.padding(NAV_ITEM_PADDING).size(28.dp),
|
||||
)
|
||||
}
|
||||
@@ -341,7 +348,7 @@ private fun NavAppIcon(
|
||||
onLongClick = onLongClick,
|
||||
) {
|
||||
entry.icon?.let { Image(it, entry.label, Modifier.size(30.dp).clip(RoundedCornerShape(8.dp))) }
|
||||
entry.builtIn?.let { Icon(it.glyph, entry.label, Modifier.size(26.dp)) }
|
||||
entry.builtIn?.let { Icon(it.glyph, entry.shownLabel(), Modifier.size(26.dp)) }
|
||||
// Only running apps get the line along the bottom edge, the usual taskbar mark for it.
|
||||
if (open) {
|
||||
Box(
|
||||
@@ -411,8 +418,8 @@ private const val TITLE_SHOWN_MS = 3_000L
|
||||
/** Below this, the nav bar's middle only has room for the turn, not the player beside it. */
|
||||
private val TURN_AND_PLAYER_MIN_WIDTH = 520.dp
|
||||
|
||||
/** The next manoeuvre, as the running nav app last described it. */
|
||||
private class NextTurn(val icon: CarIcon?, val distance: String, val road: String)
|
||||
/** The next manoeuvre, as the running nav app last described it, or [fromPhone] a map guiding on the phone. */
|
||||
private class NextTurn(val icon: CarIcon?, val distance: String, val road: String, val fromPhone: Boolean = false)
|
||||
|
||||
/**
|
||||
* Read straight off the nav app's own template: a navigation app keeps sending its routing card
|
||||
@@ -424,10 +431,10 @@ private fun rememberNextTurn(): NextTurn? {
|
||||
val template by CarServices.nav.template.collectAsState()
|
||||
// Read so a change of units redraws the distance straight away, not at the next update.
|
||||
CarSettings.units.collectAsState().value
|
||||
if (status.phase != CarAppConnection.Phase.RUNNING) return null
|
||||
val routing = (template as? NavigationTemplate)?.navigationInfo as? RoutingInfo ?: return null
|
||||
if (routing.isLoading) return null
|
||||
val step = routing.currentStep ?: return null
|
||||
val phone by PhoneNavigation.turn.collectAsState()
|
||||
val routing = ((template as? NavigationTemplate)?.navigationInfo as? RoutingInfo)
|
||||
?.takeIf { status.phase == CarAppConnection.Phase.RUNNING }
|
||||
val step = routing?.takeIf { !it.isLoading }?.currentStep ?: return phone?.let { phoneTurn(it) }
|
||||
return NextTurn(
|
||||
icon = step.maneuver?.icon,
|
||||
distance = routing.currentDistance.display(),
|
||||
@@ -435,6 +442,15 @@ private fun rememberNextTurn(): NextTurn? {
|
||||
)
|
||||
}
|
||||
|
||||
/** Google Maps' or Waze's own arrow, and the distance as the app wrote it. */
|
||||
@Composable
|
||||
private fun phoneTurn(turn: PhoneNavigation.Turn): NextTurn {
|
||||
val icon = remember(turn.arrow) {
|
||||
turn.arrow?.let { CarIcon.Builder(androidx.core.graphics.drawable.IconCompat.createWithBitmap(it)).build() }
|
||||
}
|
||||
return NextTurn(icon, turn.distance?.display() ?: turn.distanceText, turn.road, fromPhone = true)
|
||||
}
|
||||
|
||||
/** The next turn in the nav bar: arrow, how far, onto what. Tapping goes back to the map. */
|
||||
@Composable
|
||||
private fun NavTurnPill(turn: NextTurn, onClick: () -> Unit) {
|
||||
@@ -492,7 +508,7 @@ private fun NavCallPill(call: CallUiState.Active, modifier: Modifier = Modifier)
|
||||
) {
|
||||
Icon(Icons.Filled.Call, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
Text(
|
||||
call.caller?.label ?: "On a call",
|
||||
call.caller?.label ?: stringResource(R.string.navbar_on_call),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
@@ -507,12 +523,12 @@ private fun NavCallPill(call: CallUiState.Active, modifier: Modifier = Modifier)
|
||||
Spacer(Modifier.width(2.dp))
|
||||
GsIconButton(
|
||||
if (muted) MediaIcons.MicOff else MediaIcons.Mic,
|
||||
if (muted) "Unmute" else "Mute",
|
||||
stringResource(if (muted) R.string.navbar_unmute else R.string.navbar_mute),
|
||||
CarCalls::toggleMute,
|
||||
tone = if (muted) GsTone.Primary else GsTone.Neutral, size = 36.dp, iconSize = 22.dp,
|
||||
)
|
||||
GsIconButton(
|
||||
MediaIcons.CallEnd, "End call", { CarCalls.end(context) },
|
||||
MediaIcons.CallEnd, stringResource(R.string.navbar_end_call), { CarCalls.end(context) },
|
||||
colors = GsColors(scheme.error, scheme.onError), size = 36.dp, iconSize = 22.dp,
|
||||
)
|
||||
}
|
||||
@@ -520,15 +536,16 @@ private fun NavCallPill(call: CallUiState.Active, modifier: Modifier = Modifier)
|
||||
}
|
||||
|
||||
/** "Title - Artist" on one line. The text itself cuts it off, and only where the pill runs out of room. */
|
||||
@Composable
|
||||
private fun nowPlayingLabel(title: String, artist: String): String =
|
||||
listOf(title.trim(), artist.trim()).filter { it.isNotEmpty() }.joinToString(" - ")
|
||||
.ifEmpty { "Nothing playing" }
|
||||
.ifEmpty { stringResource(R.string.media_nothing_playing) }
|
||||
|
||||
/**
|
||||
* The player in the nav bar, on every screen. Art and title open the full media screen;
|
||||
* play/pause and skip work right here. The dock button moves the player into the column beside
|
||||
* the map on Home (see [CarHome]), and the pill steps aside there while it's docked - unless the
|
||||
* column is showing lyrics instead, when the pill stays and its dock button swaps them back.
|
||||
* column is showing lyrics instead, when the pill stays and its dock button closes them.
|
||||
*/
|
||||
@Composable
|
||||
private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier: Modifier = Modifier) {
|
||||
@@ -604,24 +621,34 @@ private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier:
|
||||
}
|
||||
if (!showTitle) {
|
||||
GsIconButton(
|
||||
MediaIcons.Previous, "Previous", media::previous,
|
||||
MediaIcons.Previous, stringResource(R.string.player_previous), media::previous,
|
||||
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), size = 36.dp, iconSize = 22.dp,
|
||||
)
|
||||
}
|
||||
GsIconButton(
|
||||
if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow,
|
||||
if (now.playing) "Pause" else "Play",
|
||||
stringResource(if (now.playing) R.string.player_pause else R.string.media_play),
|
||||
media::togglePlay,
|
||||
tone = GsTone.Primary, size = 36.dp, iconSize = 22.dp,
|
||||
)
|
||||
if (!showTitle) {
|
||||
GsIconButton(
|
||||
MediaIcons.Next, "Next", media::next,
|
||||
MediaIcons.Next, stringResource(R.string.player_next), media::next,
|
||||
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_NEXT), size = 36.dp, iconSize = 22.dp,
|
||||
)
|
||||
}
|
||||
// Drawn on the side the panel opens on, and filled in while it's open.
|
||||
val playerLeft by CarSettings.playerLeft.collectAsState()
|
||||
val docked = navigator.current == CarScreen.Home && navigator.sidePanel != SidePanel.NONE
|
||||
GsIconButton(
|
||||
MediaIcons.DockRight, "Move the player beside the map", navigator::togglePlayerDock,
|
||||
when {
|
||||
playerLeft && docked -> MediaIcons.DockLeftOpen
|
||||
playerLeft -> MediaIcons.DockLeft
|
||||
docked -> MediaIcons.DockRightOpen
|
||||
else -> MediaIcons.DockRight
|
||||
},
|
||||
stringResource(if (docked) R.string.navbar_close_panel else R.string.navbar_dock_player),
|
||||
navigator::togglePlayerDock,
|
||||
size = 36.dp, iconSize = 22.dp,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -41,7 +45,11 @@ sealed interface CarScreen {
|
||||
enum class SidePanel { NONE, CONTROLS, LYRICS }
|
||||
|
||||
/** One step on the breadcrumb trail: the screen it shows and the word for it in the trail. */
|
||||
data class Crumb(val screen: CarScreen, val label: String)
|
||||
data class Crumb(val screen: CarScreen, val label: String, @param:StringRes val labelRes: Int = 0) {
|
||||
/** [labelRes] when the step is one of Gearslip's own, translated; [label] for an app's own name. */
|
||||
@Composable
|
||||
fun shown(): String = if (labelRes != 0) stringResource(labelRes) else label
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the car UI is, and how it got there. A car touchscreen has no hardware back button, so
|
||||
@@ -57,6 +65,7 @@ class CarNavigator(startId: String) {
|
||||
Crumb(
|
||||
if (CarApps.find(startId) != null) CarScreen.App(startId) else CarScreen.Home,
|
||||
"Home",
|
||||
R.string.crumb_home,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -69,15 +78,15 @@ class CarNavigator(startId: String) {
|
||||
|
||||
/** 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) {
|
||||
private fun push(screen: CarScreen, label: String, @StringRes labelRes: Int = 0) {
|
||||
if (stack.last().screen == screen) return
|
||||
stack.add(Crumb(screen, label))
|
||||
stack.add(Crumb(screen, label, labelRes))
|
||||
}
|
||||
|
||||
/** 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"))
|
||||
stack.add(Crumb(CarScreen.Home, "Home", R.string.crumb_home))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,8 +99,9 @@ class CarNavigator(startId: String) {
|
||||
private set
|
||||
|
||||
/**
|
||||
* The pill's dock button: moves the player beside the map, going Home first if need be. With
|
||||
* lyrics already there it swaps them for the controls; with the controls there it closes.
|
||||
* The pill's dock button, an open/close switch for the panel: moves the player beside the map,
|
||||
* going Home first if need be, or closes the panel when something is already there. Switching
|
||||
* between the player and lyrics is done inside the panel itself.
|
||||
*/
|
||||
fun togglePlayerDock() {
|
||||
if (current != CarScreen.Home) {
|
||||
@@ -99,9 +109,11 @@ class CarNavigator(startId: String) {
|
||||
home()
|
||||
return
|
||||
}
|
||||
sidePanel = if (sidePanel == SidePanel.CONTROLS) SidePanel.NONE else SidePanel.CONTROLS
|
||||
sidePanel = if (sidePanel == SidePanel.NONE) SidePanel.CONTROLS else SidePanel.NONE
|
||||
}
|
||||
|
||||
fun showSidePlayer() { sidePanel = SidePanel.CONTROLS }
|
||||
|
||||
fun showSideLyrics() { sidePanel = SidePanel.LYRICS }
|
||||
|
||||
fun closeSidePanel() { sidePanel = SidePanel.NONE }
|
||||
@@ -114,17 +126,17 @@ class CarNavigator(startId: String) {
|
||||
|
||||
fun dismissAppMenu() { appMenu = null }
|
||||
|
||||
fun media() { push(CarScreen.Media, "Media") }
|
||||
fun media() { push(CarScreen.Media, "Media", R.string.crumb_media) }
|
||||
|
||||
fun browse(label: String) { push(CarScreen.Browse, label) }
|
||||
|
||||
fun apps() { push(CarScreen.Apps, "Apps") }
|
||||
fun apps() { push(CarScreen.Apps, "Apps", R.string.crumb_apps) }
|
||||
|
||||
fun settings() { push(CarScreen.Settings, "Settings") }
|
||||
fun settings() { push(CarScreen.Settings, "Settings", R.string.settings_title) }
|
||||
|
||||
fun dashboard() { push(CarScreen.Dashboard, "Dashboard") }
|
||||
fun dashboard() { push(CarScreen.Dashboard, "Dashboard", R.string.crumb_dashboard) }
|
||||
|
||||
fun weather() { push(CarScreen.Weather, "Weather") }
|
||||
fun weather() { push(CarScreen.Weather, "Weather", R.string.crumb_weather) }
|
||||
|
||||
fun vehicleData() { push(CarScreen.VehicleData, "Vehicle data") }
|
||||
|
||||
@@ -132,10 +144,10 @@ class CarNavigator(startId: String) {
|
||||
|
||||
/** The list lives on the dashboard; only a reply gets a screen of its own. */
|
||||
fun notifications(replyTo: String? = null) {
|
||||
if (replyTo == null) dashboard() else push(CarScreen.Notifications(replyTo), "Reply")
|
||||
if (replyTo == null) dashboard() else push(CarScreen.Notifications(replyTo), "Reply", R.string.crumb_reply)
|
||||
}
|
||||
|
||||
fun open(id: String) { push(CarScreen.App(id), CarApps.find(id)?.label ?: id) }
|
||||
fun open(id: String) { push(CarScreen.App(id), CarApps.find(id)?.label ?: id, CarApps.find(id)?.labelRes ?: 0) }
|
||||
|
||||
/** 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. */
|
||||
|
||||
@@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip.car
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.media.session.MediaController
|
||||
import android.media.session.MediaSessionManager
|
||||
import android.media.session.PlaybackState
|
||||
import app.seb3thehacker.gearslip.audio.CarAudio
|
||||
@@ -30,6 +31,11 @@ object CarServices {
|
||||
|
||||
private var context: Context? = null
|
||||
|
||||
/** Buffering counts as playing: an app between songs is about to make sound again. */
|
||||
private val PLAYING_STATES = setOf(PlaybackState.STATE_PLAYING, PlaybackState.STATE_BUFFERING)
|
||||
/** How long after pausing for the car leaving to look again for an app that started by itself. */
|
||||
private const val REPAUSE_MS = 2_000L
|
||||
|
||||
val nav: CarAppConnection by lazy { CarAppConnection(requireNotNull(context)) }
|
||||
val media: CarMedia by lazy { CarMedia(requireNotNull(context)) }
|
||||
|
||||
@@ -252,25 +258,76 @@ object CarServices {
|
||||
* the phone's speaker. Unplugging a USB accessory isn't "audio becoming noisy" the way
|
||||
* headphones are, so the apps won't pause themselves. Every playing session is paused, not
|
||||
* just the one Gearslip opened: anything the car was playing was going through the car.
|
||||
* An app can start again on its own once the speaker is given back a moment later, which a
|
||||
* Deezer driver heard after turning the car off (issue #39), so the ones it paused are
|
||||
* looked at once more and paused again if they're playing.
|
||||
*/
|
||||
fun pausePhoneMedia(why: String = "the car left") {
|
||||
val ctx = context ?: return
|
||||
pausedForNow = null
|
||||
pausedOwnForNow = false
|
||||
val paused = pause(why)
|
||||
if (paused.isEmpty()) return
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
|
||||
val again = paused.filter { it.playbackState?.state in PLAYING_STATES }
|
||||
again.forEach { runCatching { it.transportControls.pause() } }
|
||||
if (again.isNotEmpty()) {
|
||||
app.seb3thehacker.gearslip.GearslipLog.i("media: paused ${again.joinToString { it.packageName }} again: it started by itself after $why")
|
||||
}
|
||||
}, REPAUSE_MS)
|
||||
}
|
||||
|
||||
/** What [pausePhoneMediaForNow] paused, for [resumePhoneMedia] to play again. */
|
||||
@Volatile private var pausedForNow: List<MediaController>? = null
|
||||
/** Without notification access, whether it paused the app Gearslip drives itself. */
|
||||
@Volatile private var pausedOwnForNow = false
|
||||
|
||||
/** The car wants the speakers for a moment: pause, and remember what to play after. */
|
||||
fun pausePhoneMediaForNow() {
|
||||
pausedOwnForNow = false
|
||||
pausedForNow = pause("the car needs the speakers for a moment")
|
||||
}
|
||||
|
||||
/** The car handed the speakers back: play what it paused, unless the driver has since. */
|
||||
fun resumePhoneMedia() {
|
||||
val paused = pausedForNow ?: return
|
||||
pausedForNow = null
|
||||
if (pausedOwnForNow) {
|
||||
pausedOwnForNow = false
|
||||
mediaOrNull()?.let { if (!it.now.value.playing) it.togglePlay() }
|
||||
return
|
||||
}
|
||||
val waiting = paused.filter { it.playbackState?.state == PlaybackState.STATE_PAUSED }
|
||||
waiting.forEach { runCatching { it.transportControls.play() } }
|
||||
if (waiting.isNotEmpty()) {
|
||||
app.seb3thehacker.gearslip.GearslipLog.i("media: playing ${waiting.joinToString { it.packageName }} again: the car gave the speakers back")
|
||||
}
|
||||
}
|
||||
|
||||
/** What it paused. Without notification access it can only reach the app Gearslip drives itself. */
|
||||
private fun pause(why: String): List<MediaController> {
|
||||
val ctx = context ?: return emptyList()
|
||||
val manager = ctx.getSystemService(MediaSessionManager::class.java)
|
||||
val listener = ComponentName(ctx, GearslipNotificationListener::class.java)
|
||||
val playing = try {
|
||||
manager.getActiveSessions(listener).filter { it.playbackState?.state == PlaybackState.STATE_PLAYING }
|
||||
manager.getActiveSessions(listener).filter { it.playbackState?.state in PLAYING_STATES }
|
||||
} catch (e: SecurityException) {
|
||||
null
|
||||
}
|
||||
if (playing == null) {
|
||||
// No notification access: the app Gearslip itself is driving is all it can reach.
|
||||
mediaOrNull()?.let { if (it.now.value.playing) it.togglePlay() }
|
||||
return
|
||||
mediaOrNull()?.let {
|
||||
if (it.now.value.playing) {
|
||||
it.togglePlay()
|
||||
pausedOwnForNow = true
|
||||
}
|
||||
}
|
||||
return emptyList()
|
||||
}
|
||||
playing.forEach { runCatching { it.transportControls.pause() } }
|
||||
if (playing.isNotEmpty()) {
|
||||
app.seb3thehacker.gearslip.GearslipLog.i("media: paused ${playing.joinToString { it.packageName }}: $why")
|
||||
}
|
||||
return playing
|
||||
}
|
||||
|
||||
/** The car went away: nothing should keep running for a screen that no longer exists. */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import app.seb3thehacker.gearslip.car.theme.DarkStyle
|
||||
import android.content.Context
|
||||
import app.seb3thehacker.gearslip.AppSettings
|
||||
import app.seb3thehacker.gearslip.audio.CarAudio
|
||||
@@ -52,10 +53,12 @@ enum class NavBarMode { SHOW, HIDE_NAVIGATING, HIDE }
|
||||
object CarSettings {
|
||||
|
||||
private const val KEY_SCALE = "car_scale"
|
||||
private const val KEY_DENSITY = "car_density"
|
||||
private const val KEY_OPEN = "car_open_on_connect"
|
||||
private const val KEY_NIGHT = "car_night_mode"
|
||||
private const val KEY_PIPE_AUDIO = "car_pipe_audio"
|
||||
private const val KEY_SPEAK_DIRECTIONS = "car_speak_directions"
|
||||
private const val KEY_DIRECTIONS_OVER_MUSIC = "car_directions_over_music"
|
||||
private const val KEY_BLUETOOTH_WARNING = "car_bluetooth_warning"
|
||||
private const val KEY_LOCATION_WARNING = "car_location_warning"
|
||||
private const val KEY_AUDIO_CHOICE_ASKED = "car_audio_choice_asked"
|
||||
@@ -64,14 +67,18 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
||||
private const val KEY_AUTOPLAY = "car_autoplay"
|
||||
private const val KEY_PLAYER_TITLE = "car_player_title"
|
||||
private const val KEY_PLAYER_LEFT = "car_player_left"
|
||||
private const val KEY_MORE_LYRICS = "car_more_lyrics"
|
||||
private const val KEY_THEME = "car_app_theme"
|
||||
private const val KEY_UI_THEME = "car_ui_theme"
|
||||
private const val KEY_DARK_STYLE = "car_dark_style"
|
||||
private const val KEY_PINNED = "car_pinned_apps"
|
||||
private const val KEY_APP_ORDER = "car_app_order"
|
||||
private const val KEY_HIDDEN_APPS = "car_hidden_apps"
|
||||
private const val KEY_EXPERIMENTAL_FEATURES = "car_experimental_features"
|
||||
private const val KEY_SYSTEM_SPEECH = "car_system_speech"
|
||||
private const val KEY_CAR_SENSORS = "car_sensors_enabled"
|
||||
private const val KEY_ADAPTIVE_VIDEO = "adaptive_video_enabled"
|
||||
private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
|
||||
private const val KEY_DEBUG_BADGE = "debug_badge"
|
||||
private const val KEY_SEEN_SAFETY_WARNING = "car_seen_safety_warning"
|
||||
private const val KEY_UNITS = "car_units"
|
||||
@@ -93,10 +100,12 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
||||
private lateinit var app: Context
|
||||
|
||||
private val scaleFlow = MutableStateFlow(1f)
|
||||
private val carDensityFlow = MutableStateFlow(DENSITY_FROM_CAR)
|
||||
private val openFlow = MutableStateFlow(HOME)
|
||||
private val nightFlow = MutableStateFlow(NightMode.AUTO)
|
||||
private val pipeAudioFlow = MutableStateFlow(true)
|
||||
private val speakDirectionsFlow = MutableStateFlow(true)
|
||||
private val directionsOverMusicFlow = MutableStateFlow(false)
|
||||
private val bluetoothWarningFlow = MutableStateFlow(true)
|
||||
private val locationWarningFlow = MutableStateFlow(true)
|
||||
private val lastNavFlow = MutableStateFlow<String?>(null)
|
||||
@@ -104,18 +113,25 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
||||
private val autoplayFlow = MutableStateFlow(false)
|
||||
private val playerTitleFlow = MutableStateFlow(false)
|
||||
private val playerLeftFlow = MutableStateFlow(false)
|
||||
private val moreLyricsFlow = MutableStateFlow(true)
|
||||
private val themeFlow = MutableStateFlow(AppTheme.PHONE)
|
||||
val appTheme: StateFlow<AppTheme> = themeFlow
|
||||
private val darkStyleFlow = MutableStateFlow(DarkStyle.BLACK)
|
||||
|
||||
/** How dark the car's dark mode goes; black until the driver picks another. */
|
||||
val darkStyle: StateFlow<DarkStyle> = darkStyleFlow
|
||||
private val uiThemeFlow = MutableStateFlow(GsThemes.default.id)
|
||||
|
||||
/** Id of the [GsTheme] controls are drawn in; an unknown id falls back to the default. */
|
||||
val uiTheme: StateFlow<String> = uiThemeFlow
|
||||
private val pinnedFlow = MutableStateFlow<Set<String>>(emptySet())
|
||||
private val appOrderFlow = MutableStateFlow<List<String>>(emptyList())
|
||||
private val hiddenAppsFlow = MutableStateFlow<Set<String>>(emptySet())
|
||||
private val experimentalFeaturesFlow = MutableStateFlow(false)
|
||||
private val systemSpeechFlow = MutableStateFlow(false)
|
||||
private val carSensorsFlow = MutableStateFlow(false)
|
||||
private val adaptiveVideoFlow = MutableStateFlow(false)
|
||||
private val clusterVideoFlow = MutableStateFlow(false)
|
||||
private val debugBadgeFlow = MutableStateFlow(true)
|
||||
private val seenSafetyWarningFlow = MutableStateFlow(false)
|
||||
private val unitsFlow = MutableStateFlow(Units.AUTO)
|
||||
@@ -208,6 +224,9 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
||||
/** The launcher's order once the driver has moved a tile; empty keeps the grouped default. */
|
||||
val appOrder: StateFlow<List<String>> = appOrderFlow
|
||||
|
||||
/** The tiles left out of the car's app drawer, by [Entry.componentId]. */
|
||||
val hiddenApps: StateFlow<Set<String>> = hiddenAppsFlow
|
||||
|
||||
/** Flattened component names of the map and media apps used last, brought back on connect. */
|
||||
val lastNav: StateFlow<String?> = lastNavFlow
|
||||
val lastMedia: StateFlow<String?> = lastMediaFlow
|
||||
@@ -224,7 +243,20 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
||||
/** The home screen's player sits left of the map instead of right of it. */
|
||||
val playerLeft: StateFlow<Boolean> = playerLeftFlow
|
||||
|
||||
/** Lyrics from BetterLyrics and KuGou as well as LRCLIB; off sends song details to LRCLIB only. */
|
||||
val moreLyrics: StateFlow<Boolean> = moreLyricsFlow
|
||||
|
||||
val scale: StateFlow<Float> = scaleFlow
|
||||
|
||||
/** [carDensity] when it leaves the choice to the car. */
|
||||
const val DENSITY_FROM_CAR = 0
|
||||
val DENSITIES = listOf(160, 180, 200, 220, 240)
|
||||
|
||||
/** An absolute DPI for the car UI and its apps; the video's resolution doesn't change with it. */
|
||||
val carDensity: StateFlow<Int> = carDensityFlow
|
||||
|
||||
/** The density to draw at, for a car or preview that would otherwise use [carDpi]. */
|
||||
fun effectiveDensity(carDpi: Int) = carDensityFlow.value.takeIf { it != DENSITY_FROM_CAR } ?: carDpi
|
||||
val openOnConnect: StateFlow<String> = openFlow
|
||||
val nightMode: StateFlow<NightMode> = nightFlow
|
||||
|
||||
@@ -240,6 +272,9 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
||||
/** Gearslip reads the map app's turns aloud on the car's directions channel; see DirectionsVoice. */
|
||||
val speakDirections: StateFlow<Boolean> = speakDirectionsFlow
|
||||
|
||||
/** The turns go out on the music channel instead, for cars the directions channel upsets. */
|
||||
val directionsOverMusic: StateFlow<Boolean> = directionsOverMusicFlow
|
||||
|
||||
/** With Bluetooth audio, warn when the phone isn't connected to the car; see BluetoothAudioCheck. */
|
||||
val bluetoothWarning: StateFlow<Boolean> = bluetoothWarningFlow
|
||||
|
||||
@@ -282,6 +317,13 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
||||
*/
|
||||
val adaptiveVideoEnabled: StateFlow<Boolean> = adaptiveVideoFlow
|
||||
|
||||
/**
|
||||
* Gearslip's turn card on a car's instrument-cluster screen, for a car that offers one as a
|
||||
* second video sink. Applies only while [experimentalFeaturesEnabled] is on too, and from the
|
||||
* next connection; untested in a car so far.
|
||||
*/
|
||||
val clusterVideoEnabled: StateFlow<Boolean> = clusterVideoFlow
|
||||
|
||||
/** Debug builds only: the car screen's DEBUG BUILD badge, which screenshots for the README leave out. */
|
||||
val debugBadge: StateFlow<Boolean> = debugBadgeFlow
|
||||
|
||||
@@ -306,20 +348,26 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
||||
fun init(context: Context) {
|
||||
app = context.applicationContext
|
||||
scaleFlow.value = AppSettings.getString(app, KEY_SCALE, "1.0").toFloatOrNull() ?: 1f
|
||||
carDensityFlow.value = AppSettings.getString(app, KEY_DENSITY, "").toIntOrNull()?.takeIf { it in DENSITIES } ?: DENSITY_FROM_CAR
|
||||
openFlow.value = AppSettings.getString(app, KEY_OPEN, HOME)
|
||||
lastNavFlow.value = AppSettings.getString(app, KEY_LAST_NAV, "").ifEmpty { null }
|
||||
lastMediaFlow.value = AppSettings.getString(app, KEY_LAST_MEDIA, "").ifEmpty { null }
|
||||
themeFlow.value = runCatching {
|
||||
AppTheme.valueOf(AppSettings.getString(app, KEY_THEME, AppTheme.PHONE.name))
|
||||
}.getOrDefault(AppTheme.PHONE)
|
||||
darkStyleFlow.value = runCatching {
|
||||
DarkStyle.valueOf(AppSettings.getString(app, KEY_DARK_STYLE, DarkStyle.BLACK.name))
|
||||
}.getOrDefault(DarkStyle.BLACK)
|
||||
uiThemeFlow.value = AppSettings.getString(app, KEY_UI_THEME, GsThemes.default.id)
|
||||
autoplayFlow.value = AppSettings.getString(app, KEY_AUTOPLAY, "false") == "true"
|
||||
playerTitleFlow.value = AppSettings.getString(app, KEY_PLAYER_TITLE, "false") == "true"
|
||||
playerLeftFlow.value = AppSettings.getString(app, KEY_PLAYER_LEFT, "false") == "true"
|
||||
moreLyricsFlow.value = AppSettings.getString(app, KEY_MORE_LYRICS, "true") == "true"
|
||||
pipeAudioFlow.value = AppSettings.getString(app, KEY_PIPE_AUDIO, "true") == "true"
|
||||
// Until the driver sets it, on for USB audio and off for Bluetooth, where the map app's
|
||||
// own voice already reaches the car.
|
||||
speakDirectionsFlow.value = AppSettings.getString(app, KEY_SPEAK_DIRECTIONS, pipeAudioFlow.value.toString()) == "true"
|
||||
directionsOverMusicFlow.value = AppSettings.getString(app, KEY_DIRECTIONS_OVER_MUSIC, "false") == "true"
|
||||
bluetoothWarningFlow.value = AppSettings.getString(app, KEY_BLUETOOTH_WARNING, "true") == "true"
|
||||
locationWarningFlow.value = AppSettings.getString(app, KEY_LOCATION_WARNING, "true") == "true"
|
||||
nightFlow.value = runCatching {
|
||||
@@ -329,10 +377,13 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
||||
.split(",").filter { it.isNotEmpty() }.toSet()
|
||||
appOrderFlow.value = AppSettings.getString(app, KEY_APP_ORDER, "")
|
||||
.split(",").filter { it.isNotEmpty() }
|
||||
hiddenAppsFlow.value = AppSettings.getString(app, KEY_HIDDEN_APPS, "")
|
||||
.split(",").filter { it.isNotEmpty() }.toSet()
|
||||
experimentalFeaturesFlow.value = AppSettings.getString(app, KEY_EXPERIMENTAL_FEATURES, "false") == "true"
|
||||
systemSpeechFlow.value = AppSettings.getString(app, KEY_SYSTEM_SPEECH, "false") == "true"
|
||||
carSensorsFlow.value = AppSettings.getString(app, KEY_CAR_SENSORS, "false") == "true"
|
||||
adaptiveVideoFlow.value = AppSettings.getString(app, KEY_ADAPTIVE_VIDEO, "false") == "true"
|
||||
clusterVideoFlow.value = AppSettings.getString(app, KEY_CLUSTER_VIDEO, "false") == "true"
|
||||
debugBadgeFlow.value = AppSettings.getString(app, KEY_DEBUG_BADGE, "true") == "true"
|
||||
seenSafetyWarningFlow.value = AppSettings.getString(app, KEY_SEEN_SAFETY_WARNING, "false") == "true"
|
||||
unitsFlow.value = runCatching {
|
||||
@@ -358,6 +409,11 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
||||
AppSettings.putString(app, KEY_SCALE, value.toString())
|
||||
}
|
||||
|
||||
fun setCarDensity(dpi: Int) {
|
||||
carDensityFlow.value = dpi
|
||||
AppSettings.putString(app, KEY_DENSITY, dpi.toString())
|
||||
}
|
||||
|
||||
fun setOpenOnConnect(id: String) {
|
||||
openFlow.value = id
|
||||
AppSettings.putString(app, KEY_OPEN, id)
|
||||
@@ -378,6 +434,11 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
||||
AppSettings.putString(app, KEY_THEME, theme.name)
|
||||
}
|
||||
|
||||
fun setDarkStyle(style: DarkStyle) {
|
||||
darkStyleFlow.value = style
|
||||
AppSettings.putString(app, KEY_DARK_STYLE, style.name)
|
||||
}
|
||||
|
||||
fun setUiTheme(id: String) {
|
||||
uiThemeFlow.value = id
|
||||
AppSettings.putString(app, KEY_UI_THEME, id)
|
||||
@@ -393,6 +454,11 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
||||
AppSettings.putString(app, KEY_PLAYER_LEFT, on.toString())
|
||||
}
|
||||
|
||||
fun setMoreLyrics(on: Boolean) {
|
||||
moreLyricsFlow.value = on
|
||||
AppSettings.putString(app, KEY_MORE_LYRICS, on.toString())
|
||||
}
|
||||
|
||||
fun setAutoplay(on: Boolean) {
|
||||
autoplayFlow.value = on
|
||||
AppSettings.putString(app, KEY_AUTOPLAY, on.toString())
|
||||
@@ -416,6 +482,11 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
||||
AppSettings.putString(app, KEY_SPEAK_DIRECTIONS, on.toString())
|
||||
}
|
||||
|
||||
fun setDirectionsOverMusic(on: Boolean) {
|
||||
directionsOverMusicFlow.value = on
|
||||
AppSettings.putString(app, KEY_DIRECTIONS_OVER_MUSIC, on.toString())
|
||||
}
|
||||
|
||||
fun setBluetoothWarning(on: Boolean) {
|
||||
bluetoothWarningFlow.value = on
|
||||
AppSettings.putString(app, KEY_BLUETOOTH_WARNING, on.toString())
|
||||
@@ -441,6 +512,11 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
||||
AppSettings.putString(app, KEY_ADAPTIVE_VIDEO, on.toString())
|
||||
}
|
||||
|
||||
fun setClusterVideoEnabled(on: Boolean) {
|
||||
clusterVideoFlow.value = on
|
||||
AppSettings.putString(app, KEY_CLUSTER_VIDEO, on.toString())
|
||||
}
|
||||
|
||||
fun setCarSensorsEnabled(on: Boolean) {
|
||||
carSensorsFlow.value = on
|
||||
AppSettings.putString(app, KEY_CAR_SENSORS, on.toString())
|
||||
@@ -465,6 +541,12 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
||||
AppSettings.putString(app, KEY_PINNED, next.joinToString(","))
|
||||
}
|
||||
|
||||
fun setAppHidden(id: String, hide: Boolean) {
|
||||
val next = if (hide) hiddenAppsFlow.value + id else hiddenAppsFlow.value - id
|
||||
hiddenAppsFlow.value = next
|
||||
AppSettings.putString(app, KEY_HIDDEN_APPS, next.joinToString(","))
|
||||
}
|
||||
|
||||
fun setAppOrder(ids: List<String>) {
|
||||
appOrderFlow.value = ids
|
||||
AppSettings.putString(app, KEY_APP_ORDER, ids.joinToString(","))
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
|
||||
import app.seb3thehacker.gearslip.car.theme.DarkStyle
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -8,6 +11,9 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -52,6 +58,7 @@ import kotlinx.coroutines.withContext
|
||||
fun CarSettingsScreen() {
|
||||
val scale by CarSettings.scale.collectAsState()
|
||||
val open by CarSettings.openOnConnect.collectAsState()
|
||||
val launcher by LauncherMode.active.collectAsState()
|
||||
val night by CarSettings.nightMode.collectAsState()
|
||||
val units by CarSettings.units.collectAsState()
|
||||
val navBar by CarSettings.navBarMode.collectAsState()
|
||||
@@ -60,16 +67,20 @@ fun CarSettingsScreen() {
|
||||
val whatsNew by CarSettings.whatsNewAfterUpdates.collectAsState()
|
||||
val pipeAudio by CarSettings.pipeAudio.collectAsState()
|
||||
val speakDirections by CarSettings.speakDirections.collectAsState()
|
||||
val directionsOverMusic by CarSettings.directionsOverMusic.collectAsState()
|
||||
val bluetoothWarning by CarSettings.bluetoothWarning.collectAsState()
|
||||
val locationWarning by CarSettings.locationWarning.collectAsState()
|
||||
val autoplay by CarSettings.autoplay.collectAsState()
|
||||
val playerTitle by CarSettings.playerTitle.collectAsState()
|
||||
val playerLeft by CarSettings.playerLeft.collectAsState()
|
||||
val moreLyrics by CarSettings.moreLyrics.collectAsState()
|
||||
val theme by CarSettings.appTheme.collectAsState()
|
||||
val uiTheme by CarSettings.uiTheme.collectAsState()
|
||||
val darkStyle by CarSettings.darkStyle.collectAsState()
|
||||
val experimentalFeatures by CarSettings.experimentalFeaturesEnabled.collectAsState()
|
||||
val carSensors by CarSettings.carSensorsEnabled.collectAsState()
|
||||
val adaptiveVideo by CarSettings.adaptiveVideoEnabled.collectAsState()
|
||||
val clusterVideo by CarSettings.clusterVideoEnabled.collectAsState()
|
||||
val systemSpeech by CarSettings.systemSpeech.collectAsState()
|
||||
val display by CarEnvironment.display.collectAsState()
|
||||
val vehicle by CarEnvironment.vehicle.collectAsState()
|
||||
@@ -78,232 +89,275 @@ fun CarSettingsScreen() {
|
||||
value = withContext(Dispatchers.Default) { CertSummary.read(context) }
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize(),
|
||||
// Built as it scrolls into view rather than all at once: every row here is a set of
|
||||
// buttons, and building the whole screen made opening Settings visibly slow.
|
||||
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
item {
|
||||
ScreenHeader("Settings")
|
||||
}
|
||||
// A rail of the groups down the left, and the chosen group's settings beside it: five short
|
||||
// lists instead of one long scroll.
|
||||
var chosen by androidx.compose.runtime.saveable.rememberSaveable { androidx.compose.runtime.mutableIntStateOf(0) }
|
||||
val listState = androidx.compose.foundation.lazy.rememberLazyListState()
|
||||
androidx.compose.runtime.LaunchedEffect(chosen) { listState.scrollToItem(0) }
|
||||
Row(Modifier.fillMaxSize().padding(start = 16.dp, top = 12.dp)) {
|
||||
SettingsRail(listOf(stringResource(R.string.car_group_screen), stringResource(R.string.car_group_nav_bar), stringResource(R.string.car_group_sound), stringResource(R.string.car_group_warnings), stringResource(R.string.car_group_more)), chosen) { chosen = it }
|
||||
LazyColumn(
|
||||
Modifier.weight(1f).fillMaxHeight(),
|
||||
state = listState,
|
||||
// Built as it scrolls into view rather than all at once: every row here is a set of
|
||||
// buttons, and building the whole screen made opening Settings visibly slow.
|
||||
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
var section: Int
|
||||
section = 0
|
||||
if (section == chosen) item { Group(stringResource(R.string.car_group_screen)) }
|
||||
|
||||
item { Group("Screen") }
|
||||
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Open on connect",
|
||||
listOf(CarSettings.HOME to "Home") + CarApps.all.filter { it.id != "settings" }.map { it.id to it.label },
|
||||
open,
|
||||
) { CarSettings.setOpenOnConnect(it) }
|
||||
}
|
||||
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Size",
|
||||
listOf(1f to "Normal", 1.25f to "Large", 1.5f to "Extra large"),
|
||||
scale,
|
||||
) { CarSettings.setScale(it) }
|
||||
}
|
||||
|
||||
item {
|
||||
ChoiceRow(
|
||||
"App theme",
|
||||
listOf(AppTheme.PHONE to "Follow phone", AppTheme.LIGHT to "Light", AppTheme.DARK to "Dark"),
|
||||
theme,
|
||||
) { CarSettings.setAppTheme(it) }
|
||||
}
|
||||
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Theme",
|
||||
GsThemes.builtIn.map { it.id to it.name },
|
||||
uiTheme,
|
||||
) { CarSettings.setUiTheme(it) }
|
||||
}
|
||||
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Map day/night",
|
||||
listOf(NightMode.AUTO to "Auto", NightMode.DAY to "Day", NightMode.NIGHT to "Night"),
|
||||
night,
|
||||
) { CarSettings.setNightMode(it) }
|
||||
}
|
||||
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Units",
|
||||
listOf(Units.AUTO to "Auto", Units.IMPERIAL to "Imperial (mi, ft, °F)", Units.METRIC to "Metric (km, m, °C)"),
|
||||
units,
|
||||
) { CarSettings.setUnits(it) }
|
||||
}
|
||||
|
||||
// For a head unit that reports the wrong size and stretches the picture (issue #12).
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Screen size (next connection)",
|
||||
listOf<ScreenSize?>(null).plus(SCREEN_PRESETS).plus(screenSize).distinct()
|
||||
.map { it to (it?.toString() ?: "From the car") },
|
||||
screenSize,
|
||||
) { CarSettings.setScreenSize(it) }
|
||||
}
|
||||
|
||||
if (screenSize != null) item {
|
||||
ChoiceRow(
|
||||
"Fit to the screen",
|
||||
listOf(ScreenFit.CROP to "Crop the edges", ScreenFit.SQUASH to "Squash to fit"),
|
||||
screenFit,
|
||||
) { CarSettings.setScreenFit(it) }
|
||||
}
|
||||
|
||||
item { Group("Nav bar and player") }
|
||||
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Nav bar",
|
||||
listOf(
|
||||
NavBarMode.SHOW to "Always show",
|
||||
NavBarMode.HIDE_NAVIGATING to "Hide while navigating",
|
||||
NavBarMode.HIDE to "Hide until tapped",
|
||||
),
|
||||
navBar,
|
||||
) { CarSettings.setNavBarMode(it) }
|
||||
}
|
||||
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Player in the nav bar",
|
||||
listOf(false to "Skip buttons", true to "Song title"),
|
||||
playerTitle,
|
||||
) { CarSettings.setPlayerTitle(it) }
|
||||
}
|
||||
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Player beside the map",
|
||||
listOf(false to "Right", true to "Left"),
|
||||
playerLeft,
|
||||
) { CarSettings.setPlayerLeft(it) }
|
||||
}
|
||||
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Autoplay on connect",
|
||||
listOf(false to "Off", true to "On"),
|
||||
autoplay,
|
||||
) { CarSettings.setAutoplay(it) }
|
||||
}
|
||||
|
||||
item { Group("Sound and voice") }
|
||||
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Car audio",
|
||||
listOf(false to "Bluetooth", true to "USB"),
|
||||
pipeAudio,
|
||||
) { CarSettings.setPipeAudio(it) }
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
"USB sends the phone's sound through Gearslip. Android asks to share your screen each " +
|
||||
"drive, and a few apps block it. Bluetooth plays as the phone normally would, and " +
|
||||
"Android never asks.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
item { Group("Warnings") }
|
||||
|
||||
if (!pipeAudio) item {
|
||||
ChoiceRow(
|
||||
"Warn when Bluetooth isn't connected",
|
||||
listOf(false to "Off", true to "On"),
|
||||
bluetoothWarning,
|
||||
) { CarSettings.setBluetoothWarning(it) }
|
||||
}
|
||||
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Warn when location is off",
|
||||
listOf(false to "Off", true to "On"),
|
||||
locationWarning,
|
||||
) { CarSettings.setLocationWarning(it) }
|
||||
}
|
||||
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Spoken directions",
|
||||
listOf(false to "Off", true to "On"),
|
||||
speakDirections,
|
||||
) { CarSettings.setSpeakDirections(it) }
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
"Gearslip reads each turn aloud through the car's speakers, and the music dips while it " +
|
||||
"speaks. Turn it off if your map app's own voice already plays in the car.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
ExperimentalFeatureRow(
|
||||
title = "Use the phone's speech recognition",
|
||||
description = "Listens through the phone's own speech service instead of Gearslip's. On " +
|
||||
"most phones that's Google's, which may send your voice to Google. GrapheneOS has " +
|
||||
"none, so Gearslip keeps using its own.",
|
||||
checked = systemSpeech,
|
||||
) { CarSettings.setSystemSpeech(it) }
|
||||
}
|
||||
|
||||
item { Group("More") }
|
||||
|
||||
item {
|
||||
WebPageSetting()
|
||||
}
|
||||
|
||||
item {
|
||||
ScreenFit(vehicle)
|
||||
}
|
||||
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Experimental features",
|
||||
listOf(false to "Hide", true to "Show"),
|
||||
experimentalFeatures,
|
||||
) { CarSettings.setExperimentalFeaturesEnabled(it) }
|
||||
}
|
||||
|
||||
if (experimentalFeatures) item {
|
||||
ExperimentalFeaturesList(carSensors, adaptiveVideo)
|
||||
}
|
||||
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Connection", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Info("Phone", "${Build.MANUFACTURER} ${Build.MODEL}")
|
||||
Info("Vehicle", vehicle.ifEmpty { "-" })
|
||||
Info("Video", display.ifEmpty { "-" })
|
||||
Info("Certificate", cert?.headline ?: "Checking…")
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_open_on_connect),
|
||||
listOf(CarSettings.HOME to stringResource(R.string.choice_open_home)) + CarApps.shown(launcher, LauncherMode.canCall(context)).map { it.id to stringResource(it.labelRes) },
|
||||
open,
|
||||
) { CarSettings.setOpenOnConnect(it) }
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("About", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Info("Version", app.seb3thehacker.gearslip.BuildConfig.VERSION_NAME)
|
||||
Info("Build", app.seb3thehacker.gearslip.BuildConfig.VERSION_CODE.toString())
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_size),
|
||||
listOf(1f to stringResource(R.string.choice_size_normal), 1.25f to stringResource(R.string.choice_size_large), 1.5f to stringResource(R.string.choice_size_xlarge)),
|
||||
scale,
|
||||
) { CarSettings.setScale(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_app_theme),
|
||||
listOf(AppTheme.PHONE to stringResource(R.string.choice_theme_phone), AppTheme.LIGHT to stringResource(R.string.choice_theme_light), AppTheme.DARK to stringResource(R.string.choice_theme_dark)),
|
||||
theme,
|
||||
) { CarSettings.setAppTheme(it) }
|
||||
}
|
||||
|
||||
if (section == chosen && theme != AppTheme.LIGHT) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_dark_mode),
|
||||
listOf(
|
||||
DarkStyle.BLACK to stringResource(R.string.choice_dark_black),
|
||||
DarkStyle.BLACK_COLOUR to stringResource(R.string.choice_dark_black_colour),
|
||||
DarkStyle.DEEP_COLOUR to stringResource(R.string.choice_dark_deep_colour),
|
||||
DarkStyle.GREY to stringResource(R.string.choice_dark_grey),
|
||||
),
|
||||
darkStyle,
|
||||
) { CarSettings.setDarkStyle(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.carset_theme),
|
||||
GsThemes.builtIn.map { it.id to it.name },
|
||||
uiTheme,
|
||||
) { CarSettings.setUiTheme(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_map_day_night),
|
||||
listOf(NightMode.AUTO to stringResource(R.string.choice_auto), NightMode.DAY to stringResource(R.string.choice_night_day), NightMode.NIGHT to stringResource(R.string.choice_night_night)),
|
||||
night,
|
||||
) { CarSettings.setNightMode(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_units),
|
||||
listOf(Units.AUTO to stringResource(R.string.choice_auto), Units.IMPERIAL to stringResource(R.string.carset_imperial), Units.METRIC to stringResource(R.string.carset_metric)),
|
||||
units,
|
||||
) { CarSettings.setUnits(it) }
|
||||
}
|
||||
|
||||
// For a head unit that reports the wrong size and stretches the picture (issue #12).
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.carset_screen_size),
|
||||
listOf<ScreenSize?>(null).plus(SCREEN_PRESETS).plus(screenSize).distinct()
|
||||
.map { it to (it?.toString() ?: stringResource(R.string.screen_from_car)) },
|
||||
screenSize,
|
||||
) { CarSettings.setScreenSize(it) }
|
||||
}
|
||||
|
||||
if (section == chosen && screenSize != null) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.carset_fit),
|
||||
listOf(ScreenFit.CROP to stringResource(R.string.screen_crop), ScreenFit.SQUASH to stringResource(R.string.screen_squash)),
|
||||
screenFit,
|
||||
) { CarSettings.setScreenFit(it) }
|
||||
}
|
||||
|
||||
section = 1
|
||||
if (section == chosen) item { Group(stringResource(R.string.car_group_nav_bar)) }
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_nav_bar),
|
||||
listOf(
|
||||
NavBarMode.SHOW to stringResource(R.string.choice_nav_show),
|
||||
NavBarMode.HIDE_NAVIGATING to stringResource(R.string.choice_nav_hide_navigating),
|
||||
NavBarMode.HIDE to stringResource(R.string.choice_nav_hide),
|
||||
),
|
||||
navBar,
|
||||
) { CarSettings.setNavBarMode(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_player_nav_bar),
|
||||
listOf(false to stringResource(R.string.car_skip_buttons), true to stringResource(R.string.car_song_title)),
|
||||
playerTitle,
|
||||
) { CarSettings.setPlayerTitle(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_player_beside_map),
|
||||
listOf(false to stringResource(R.string.car_right), true to stringResource(R.string.car_left)),
|
||||
playerLeft,
|
||||
) { CarSettings.setPlayerLeft(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_autoplay),
|
||||
listOf(false to stringResource(R.string.off), true to stringResource(R.string.on)),
|
||||
autoplay,
|
||||
) { CarSettings.setAutoplay(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.carset_lyrics_from),
|
||||
listOf(false to "LRCLIB", true to stringResource(R.string.carset_more_sources)),
|
||||
moreLyrics,
|
||||
) { CarSettings.setMoreLyrics(it) }
|
||||
}
|
||||
|
||||
section = 2
|
||||
if (section == chosen) item { Group(stringResource(R.string.car_group_sound)) }
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_audio),
|
||||
listOf(false to stringResource(R.string.guide_audio_bluetooth), true to stringResource(R.string.guide_audio_usb)),
|
||||
pipeAudio,
|
||||
) { CarSettings.setPipeAudio(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
Text(
|
||||
stringResource(R.string.carset_audio_note),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
section = 3
|
||||
if (section == chosen) item { Group(stringResource(R.string.car_group_warnings)) }
|
||||
|
||||
if (section == chosen && !pipeAudio) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_warn_bluetooth),
|
||||
listOf(false to stringResource(R.string.off), true to stringResource(R.string.on)),
|
||||
bluetoothWarning,
|
||||
) { CarSettings.setBluetoothWarning(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_warn_location),
|
||||
listOf(false to stringResource(R.string.off), true to stringResource(R.string.on)),
|
||||
locationWarning,
|
||||
) { CarSettings.setLocationWarning(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_spoken_directions),
|
||||
listOf(false to stringResource(R.string.off), true to stringResource(R.string.on)),
|
||||
speakDirections,
|
||||
) { CarSettings.setSpeakDirections(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
Text(
|
||||
stringResource(R.string.carset_directions_note),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
if (section == chosen && speakDirections) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_directions_over_music),
|
||||
listOf(false to stringResource(R.string.off), true to stringResource(R.string.on)),
|
||||
directionsOverMusic,
|
||||
) { CarSettings.setDirectionsOverMusic(it) }
|
||||
}
|
||||
|
||||
if (section == chosen && speakDirections) item {
|
||||
Text(
|
||||
stringResource(R.string.car_directions_over_music_note),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ExperimentalFeatureRow(
|
||||
"What's new after updates",
|
||||
"Show the list of changes once, the first time the car connects after an update.",
|
||||
whatsNew,
|
||||
) { CarSettings.setWhatsNewAfterUpdates(it) }
|
||||
GsButton(onClick = CarSettings::openWhatsNew, tone = GsTone.Tonal, modifier = Modifier.height(56.dp)) {
|
||||
Text("What's new", style = MaterialTheme.typography.titleMedium)
|
||||
title = stringResource(R.string.car_phone_speech),
|
||||
description = stringResource(R.string.carset_phone_speech_note),
|
||||
checked = systemSpeech,
|
||||
) { CarSettings.setSystemSpeech(it) }
|
||||
}
|
||||
|
||||
section = 4
|
||||
if (section == chosen) item { Group(stringResource(R.string.car_group_more)) }
|
||||
|
||||
if (section == chosen) item {
|
||||
WebPageSetting()
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ScreenFit(vehicle)
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_experimental),
|
||||
listOf(false to stringResource(R.string.carset_hide), true to stringResource(R.string.carset_show)),
|
||||
experimentalFeatures,
|
||||
) { CarSettings.setExperimentalFeaturesEnabled(it) }
|
||||
}
|
||||
|
||||
if (section == chosen && experimentalFeatures) item {
|
||||
ExperimentalFeaturesList(carSensors, adaptiveVideo, clusterVideo)
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(stringResource(R.string.settings_connection), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Info(stringResource(R.string.carset_phone), "${Build.MANUFACTURER} ${Build.MODEL}")
|
||||
Info(stringResource(R.string.carset_vehicle), vehicle.ifEmpty { "-" })
|
||||
Info(stringResource(R.string.carset_video), display.ifEmpty { "-" })
|
||||
Info(stringResource(R.string.settings_certificate), cert?.headline ?: stringResource(R.string.cert_checking))
|
||||
}
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(stringResource(R.string.settings_about), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Info(stringResource(R.string.carset_version), app.seb3thehacker.gearslip.BuildConfig.VERSION_NAME)
|
||||
Info(stringResource(R.string.carset_build), app.seb3thehacker.gearslip.BuildConfig.VERSION_CODE.toString())
|
||||
ExperimentalFeatureRow(
|
||||
stringResource(R.string.carset_whats_new_after),
|
||||
stringResource(R.string.carset_whats_new_after_sub),
|
||||
whatsNew,
|
||||
) { CarSettings.setWhatsNewAfterUpdates(it) }
|
||||
GsButton(onClick = CarSettings::openWhatsNew, tone = GsTone.Tonal, modifier = Modifier.height(56.dp)) {
|
||||
Text(stringResource(R.string.settings_whats_new), style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -328,6 +382,47 @@ private fun <T> ChoiceRow(title: String, options: List<Pair<T, String>>, selecte
|
||||
}
|
||||
}
|
||||
|
||||
/** The groups, with the chosen one lit: a card down the left of Settings. */
|
||||
@Composable
|
||||
private fun SettingsRail(groups: List<String>, chosen: Int, onChoose: (Int) -> Unit) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val shape = RoundedCornerShape(24.dp)
|
||||
Surface(
|
||||
Modifier.width(250.dp).padding(bottom = 12.dp).fillMaxHeight().gsPanelShadow(shape),
|
||||
shape = shape,
|
||||
color = scheme.surfaceContainer,
|
||||
) {
|
||||
Column(
|
||||
Modifier.padding(10.dp).verticalScroll(androidx.compose.foundation.rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.settings_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(start = 14.dp, top = 6.dp, bottom = 10.dp),
|
||||
)
|
||||
groups.forEachIndexed { i, name ->
|
||||
val on = i == chosen
|
||||
GsIconBox(
|
||||
onClick = { onChoose(i) },
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
colors = if (on) GsColors(scheme.primaryContainer, scheme.onPrimaryContainer)
|
||||
else GsColors(Color.Transparent, scheme.onSurface),
|
||||
latched = on,
|
||||
) {
|
||||
Text(
|
||||
name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = if (on) FontWeight.Bold else FontWeight.Normal,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A group of settings: a heading, so a long list reads as a few short ones. */
|
||||
@Composable
|
||||
private fun Group(title: String) {
|
||||
@@ -369,25 +464,26 @@ private fun <T> ChoiceButtons(options: List<Pair<T, String>>, selected: T, onSel
|
||||
* so the list reads fine whether there's one entry or several.
|
||||
*/
|
||||
@Composable
|
||||
private fun ExperimentalFeaturesList(carSensors: Boolean, adaptiveVideo: Boolean) {
|
||||
private fun ExperimentalFeaturesList(carSensors: Boolean, adaptiveVideo: Boolean, clusterVideo: Boolean) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
ExperimentalFeatureRow(
|
||||
title = "Vehicle data",
|
||||
description = "Adds a Vehicle data tile: a live, raw dump of the head unit's sensor " +
|
||||
"channel. Field labels (speed, RPM, fuel...) are carried over from other " +
|
||||
"reverse-engineering projects and unverified against this car - treat them as a " +
|
||||
"guess, not a fact, until you've watched one change for yourself.",
|
||||
title = stringResource(R.string.car_vehicle_data),
|
||||
description = stringResource(R.string.carset_vehicle_data_note),
|
||||
checked = carSensors,
|
||||
) { CarSettings.setCarSensorsEnabled(it) }
|
||||
ExperimentalFeatureRow(
|
||||
title = "Adaptive video quality",
|
||||
description = "Lowers the picture's quality when the car, or its Wi-Fi, can't keep " +
|
||||
"up, and raises it again once it can. Takes effect on the next connection.",
|
||||
title = stringResource(R.string.car_adaptive_video),
|
||||
description = stringResource(R.string.carset_adaptive_note),
|
||||
checked = adaptiveVideo,
|
||||
) { CarSettings.setAdaptiveVideoEnabled(it) }
|
||||
ExperimentalFeatureRow(
|
||||
title = stringResource(R.string.car_cluster_video),
|
||||
description = stringResource(R.string.car_cluster_video_note),
|
||||
checked = clusterVideo,
|
||||
) { CarSettings.setClusterVideoEnabled(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,7 +529,7 @@ private fun WebPageSetting() {
|
||||
var url by remember { mutableStateOf(AppSettings.startupUrl(context)) }
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Web page", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Text(stringResource(R.string.web_page), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Surface(
|
||||
onClick = { editing = !editing },
|
||||
shape = MaterialTheme.shapes.large,
|
||||
@@ -441,7 +537,7 @@ private fun WebPageSetting() {
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
url.ifEmpty { "DuckDuckGo (default) - tap to set a different page" },
|
||||
url.ifEmpty { stringResource(R.string.carset_web_default) },
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (url.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
@@ -450,7 +546,7 @@ private fun WebPageSetting() {
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"Shown by the Browser app on the launcher. Applies on the next connection.",
|
||||
stringResource(R.string.carset_web_note),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -493,22 +589,22 @@ private fun ScreenFit(vehicle: String) {
|
||||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Screen fit", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Stepper("Top", insets.top) { change(insets.copy(top = (insets.top + it).coerceAtLeast(0))) }
|
||||
Stepper("Bottom", insets.bottom) { change(insets.copy(bottom = (insets.bottom + it).coerceAtLeast(0))) }
|
||||
Text(stringResource(R.string.carset_screen_fit), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Stepper(stringResource(R.string.carset_top), insets.top) { change(insets.copy(top = (insets.top + it).coerceAtLeast(0))) }
|
||||
Stepper(stringResource(R.string.carset_bottom), insets.bottom) { change(insets.copy(bottom = (insets.bottom + it).coerceAtLeast(0))) }
|
||||
Text(
|
||||
"Usable height ${480 - insets.top - insets.bottom}px of 480",
|
||||
stringResource(R.string.carset_usable_height, 480 - insets.top - insets.bottom),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
GsButton(
|
||||
onClick = {
|
||||
val ok = app.seb3thehacker.gearslip.VehicleProfiles.saveInsets(context, vehicle, insets)
|
||||
saved = if (ok) "Saved to $vehicle" else "Could not save"
|
||||
saved = if (ok) context.getString(R.string.carset_saved_to, vehicle) else context.getString(R.string.carset_save_failed)
|
||||
},
|
||||
enabled = known,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) { Text(if (known) "Save for this vehicle" else "No vehicle profile matched") }
|
||||
) { Text(if (known) stringResource(R.string.carset_save_vehicle) else stringResource(R.string.carset_no_profile)) }
|
||||
saved?.let { Text(it, style = MaterialTheme.typography.bodyMedium) }
|
||||
}
|
||||
}
|
||||
@@ -520,7 +616,7 @@ private fun Stepper(label: String, value: Int, onStep: (Int) -> Unit) {
|
||||
StepButton("−5", Modifier.weight(1f)) { onStep(-5) }
|
||||
StepButton("−1", Modifier.weight(1f)) { onStep(-1) }
|
||||
Text(
|
||||
"${value}px",
|
||||
stringResource(R.string.carset_px, value),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.weight(1.3f),
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.material.icons.filled.Call
|
||||
import app.seb3thehacker.gearslip.call.CarCalls
|
||||
import app.seb3thehacker.gearslip.call.CallUiState
|
||||
@@ -62,6 +63,8 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import android.media.session.PlaybackState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -110,6 +113,7 @@ fun CarUi() {
|
||||
val phoneDark by CarEnvironment.phoneDark.collectAsState()
|
||||
val appTheme by CarSettings.appTheme.collectAsState()
|
||||
val uiThemeId by CarSettings.uiTheme.collectAsState()
|
||||
val darkStyle by CarSettings.darkStyle.collectAsState()
|
||||
val mediaApp by CarServices.mediaApp.collectAsState()
|
||||
val darkOutside by CarEnvironment.darkOutside.collectAsState()
|
||||
val appContext = LocalContext.current.applicationContext
|
||||
@@ -118,10 +122,15 @@ fun CarUi() {
|
||||
AppTheme.LIGHT -> false
|
||||
AppTheme.DARK -> true
|
||||
}
|
||||
val colors = (if (dark) dynamicDarkColorScheme(appContext) else dynamicLightColorScheme(appContext)).withTintedSurfaces(dark)
|
||||
val colors = (if (dark) dynamicDarkColorScheme(appContext) else dynamicLightColorScheme(appContext)).withTintedSurfaces(dark, darkStyle)
|
||||
val base = LocalDensity.current
|
||||
val insets by CarEnvironment.insets.collectAsState()
|
||||
val navigator = remember { CarNavigator(CarSettings.openOnConnect.value) }
|
||||
val navigator = remember {
|
||||
// A start screen picked for the car may be one launcher mode hides, like Screen sharing.
|
||||
val start = CarSettings.openOnConnect.value
|
||||
val shown = CarApps.shown(LauncherMode.active.value, LauncherMode.canCall(appContext)).any { it.id == start }
|
||||
CarNavigator(if (shown) start else CarSettings.HOME)
|
||||
}
|
||||
val controlsWake = remember { MapControlsWake() }
|
||||
LaunchedEffect(controlsWake) {
|
||||
while (true) {
|
||||
@@ -222,8 +231,10 @@ fun CarUi() {
|
||||
},
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
// Insets are in video-frame pixels, so convert with the un-scaled density.
|
||||
val pad = with(base) {
|
||||
// Insets are video-frame pixels. Resolve them with the live (size-scaled) density,
|
||||
// which padding() also uses, so the safe-area margin stays one fixed physical size
|
||||
// at every size setting instead of growing with it and eating the screen's sides.
|
||||
val pad = with(LocalDensity.current) {
|
||||
PaddingValues(
|
||||
start = insets.left.toDp(), top = insets.top.toDp(),
|
||||
end = insets.right.toDp(), bottom = insets.bottom.toDp(),
|
||||
@@ -232,12 +243,11 @@ fun CarUi() {
|
||||
val screen = navigator.current
|
||||
val safetyWarningSeen by CarSettings.hasSeenSafetyWarning.collectAsState()
|
||||
val keyboardVisible by CarKeyboardVisibility.state
|
||||
// Off "Always show", the bar floats over the map rather than taking a row of its
|
||||
// own: hiding it then leaves the map's surface the size it was. Resizing it would
|
||||
// hand the map app a new surface each time, and some go black on that.
|
||||
// The bar always lies over the map and every other screen rather than taking a row
|
||||
// of its own: hiding it then leaves the map's surface the size it was.
|
||||
// Resizing it would hand the map app a new surface each time, and some go black on that.
|
||||
val navBarMode by CarSettings.navBarMode.collectAsState()
|
||||
val appTemplate by CarServices.nav.template.collectAsState()
|
||||
val floatingBar = navBarMode != NavBarMode.SHOW
|
||||
val barMayHide = screen == CarScreen.Home && when (navBarMode) {
|
||||
NavBarMode.SHOW -> false
|
||||
NavBarMode.HIDE_NAVIGATING -> appTemplate is androidx.car.app.navigation.model.NavigationTemplate
|
||||
@@ -248,7 +258,8 @@ fun CarUi() {
|
||||
// A call keeps the bar up whatever the setting: End and Mute live on it.
|
||||
val onCall = CarCalls.state.collectAsState().value is CallUiState.Active
|
||||
val barShown = !keyboardVisible && (!barMayHide || controlsWake.navBarAwake || onCall)
|
||||
val homeInset = if (floatingBar && barShown) NAV_BAR_HEIGHT else 0.dp
|
||||
// Up to the bar's top edge, so the map's cards and the player end above it.
|
||||
val homeInset = if (barShown) NAV_BAR_HEIGHT else 0.dp
|
||||
// Only the screen that asked: going Home or anywhere else ends it.
|
||||
val fullscreen = CarFullscreen.active && screen != CarScreen.Home
|
||||
Box(Modifier.fillMaxSize().padding(pad)) {
|
||||
@@ -268,43 +279,58 @@ fun CarUi() {
|
||||
// The keyboard takes the nav bar's row instead of squeezing in above it -
|
||||
// nothing on the nav bar is useful mid-type, and the screen is too small to
|
||||
// show both.
|
||||
if (!floatingBar && !keyboardVisible) CarNavBar(navigator)
|
||||
}
|
||||
// Every other screen, and everything that pops up, in a layer over Home rather
|
||||
// than inside its area: full screen grows this layer over the nav bar's row while
|
||||
// Home, and the map's surface in it, keeps its size.
|
||||
// Each new screen fades in while rising a little, once, over a fifth of a second.
|
||||
// Coming from Home the backdrop fades in with it, over the map; between two other
|
||||
// screens it stays put, so the map never flashes through.
|
||||
val lastScreen = remember { arrayOf<CarScreen>(CarScreen.Home) }
|
||||
val fromHome = remember(screen) { lastScreen[0] == CarScreen.Home }
|
||||
SideEffect { lastScreen[0] = screen }
|
||||
val enter = remember(screen) { androidx.compose.animation.core.Animatable(0f) }
|
||||
LaunchedEffect(screen) { enter.animateTo(1f, androidx.compose.animation.core.tween(180)) }
|
||||
if (safetyWarningSeen) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(bottom = if (!floatingBar && !keyboardVisible && !fullscreen) NAV_BAR_HEIGHT else 0.dp),
|
||||
) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
if (screen != CarScreen.Home) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer { if (fromHome) alpha = enter.value }
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
// The floating bar never hides here, so keep the screen clear of it.
|
||||
.padding(bottom = if (floatingBar && !keyboardVisible && !fullscreen) NAV_BAR_HEIGHT else 0.dp)
|
||||
// The bar never hides here, so keep the screen clear of it. The
|
||||
// player runs on under the see-through bar and keeps clear itself.
|
||||
.padding(bottom = if (!keyboardVisible && !fullscreen && screen != CarScreen.Media) NAV_BAR_HEIGHT else 0.dp)
|
||||
// Keeps touches that miss this screen's controls off the map.
|
||||
.pointerInput(Unit) { detectTapGestures { } },
|
||||
) {
|
||||
when (screen) {
|
||||
CarScreen.Home -> Unit
|
||||
CarScreen.Apps -> CarLauncher()
|
||||
CarScreen.Media -> mediaApp?.let { MediaScreen(it) { navigator.back() } }
|
||||
CarScreen.Browse -> BrowseAppScreen(navigator)
|
||||
CarScreen.Settings -> CarSettingsScreen()
|
||||
CarScreen.Dashboard -> CarDashboardScreen()
|
||||
CarScreen.Weather -> WeatherScreen()
|
||||
CarScreen.VehicleData -> VehicleDataScreen()
|
||||
is CarScreen.Notifications -> NotificationsScreen(screen.replyTo)
|
||||
is CarScreen.Messages -> MessagesScreen(screen.packageName)
|
||||
is CarScreen.App -> CarApps.find(screen.id)?.content?.invoke()
|
||||
Box(
|
||||
Modifier.fillMaxSize().graphicsLayer {
|
||||
if (!fromHome) alpha = enter.value
|
||||
translationY = (1f - enter.value) * 16.dp.toPx()
|
||||
},
|
||||
) {
|
||||
when (screen) {
|
||||
CarScreen.Home -> Unit
|
||||
CarScreen.Apps -> CarLauncher()
|
||||
CarScreen.Media -> CompositionLocalProvider(
|
||||
LocalNavBarInset provides if (!keyboardVisible && !fullscreen) NAV_BAR_HEIGHT else 0.dp,
|
||||
) { mediaApp?.let { MediaScreen(it) { navigator.back() } } }
|
||||
CarScreen.Browse -> BrowseAppScreen(navigator)
|
||||
CarScreen.Settings -> CarSettingsScreen()
|
||||
CarScreen.Dashboard -> CarDashboardScreen()
|
||||
CarScreen.Weather -> WeatherScreen()
|
||||
CarScreen.VehicleData -> VehicleDataScreen()
|
||||
is CarScreen.Notifications -> NotificationsScreen(screen.replyTo)
|
||||
is CarScreen.Messages -> MessagesScreen(screen.packageName)
|
||||
is CarScreen.App -> CarApps.find(screen.id)?.content?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
CarToastOverlay(Modifier.align(Alignment.BottomCenter).padding(bottom = homeInset))
|
||||
CarAlertOverlay(Modifier.align(Alignment.TopCenter))
|
||||
CallPromptOverlay()
|
||||
BluetoothPairOverlay()
|
||||
PopupTimer()
|
||||
@@ -325,7 +351,7 @@ fun CarUi() {
|
||||
// whatever the screen drew there.
|
||||
DebugBuildBadge(Modifier.align(Alignment.BottomEnd).padding(bottom = homeInset))
|
||||
}
|
||||
if (floatingBar || fullscreen) {
|
||||
run {
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
if (fullscreen) CarFullscreen.revealed || onCall else barShown,
|
||||
Modifier.align(Alignment.BottomCenter),
|
||||
@@ -343,7 +369,7 @@ fun CarUi() {
|
||||
GsButton(onClick = CarFullscreen::exit, modifier = Modifier.height(48.dp)) {
|
||||
Icon(MediaIcons.FullscreenExit, contentDescription = null, modifier = Modifier.size(22.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Exit full screen", style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.carui_exit_fullscreen), style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -388,22 +414,20 @@ private fun CarSafetyWizard(onDone: () -> Unit) {
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("Drive safely", style = ChromeType.headline, fontWeight = FontWeight.Bold)
|
||||
Text(stringResource(R.string.carui_drive_safely), style = ChromeType.headline, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"Gearslip is unofficial software: it is not made or reviewed by any " +
|
||||
"carmaker, and it can fail without warning.",
|
||||
stringResource(R.string.carui_unofficial),
|
||||
style = ChromeType.body,
|
||||
)
|
||||
Text(
|
||||
"Never interact with this screen while driving. Set the route or the " +
|
||||
"song before you go, then keep your attention on the road.",
|
||||
stringResource(R.string.carui_never_interact),
|
||||
style = ChromeType.body,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
GsButton(onClick = onDone, modifier = Modifier.fillMaxWidth().height(56.dp)) {
|
||||
Text("I understand", style = ChromeType.body)
|
||||
Text(stringResource(R.string.carui_understand), style = ChromeType.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -434,7 +458,7 @@ private fun BrowseAppScreen(navigator: CarNavigator) {
|
||||
}
|
||||
val failed = status.phase == CarAppConnection.Phase.REJECTED || status.phase == CarAppConnection.Phase.FAILED
|
||||
if (failed || timedOut) {
|
||||
AppWontStart(status.app ?: "This app", leave)
|
||||
AppWontStart(status.app ?: stringResource(R.string.carui_this_app), leave)
|
||||
return
|
||||
}
|
||||
CarAppStage(CarServices.browse, status, frame, onDisconnect = leave)
|
||||
@@ -450,13 +474,13 @@ private fun AppWontStart(app: String, onBack: () -> Unit) {
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text("$app doesn't work with Gearslip yet.", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
|
||||
Text(stringResource(R.string.carui_app_unsupported, app), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
"It didn't start. Some apps only run on Google's Android Auto.",
|
||||
stringResource(R.string.carui_app_unsupported_body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
GsButton(onClick = onBack, tone = GsTone.Tonal) { Text("Back") }
|
||||
GsButton(onClick = onBack, tone = GsTone.Tonal) { Text(stringResource(R.string.back)) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
@@ -76,7 +78,7 @@ fun CarWhatsNew() {
|
||||
) {
|
||||
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Text(
|
||||
"What's new in ${release.versionName}",
|
||||
stringResource(R.string.whatsnew_title, release.versionName),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
@@ -99,12 +101,12 @@ fun CarWhatsNew() {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
GsSwitch(afterUpdates, CarSettings::setWhatsNewAfterUpdates)
|
||||
Text(
|
||||
"Show after updates",
|
||||
stringResource(R.string.whatsnew_show_after),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
GsButton(onClick = close, modifier = Modifier.height(56.dp)) {
|
||||
Text("Got it", style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.whatsnew_got_it), style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.car.app.navigation.model.RoutingInfo
|
||||
import androidx.car.app.navigation.model.Step
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.Protobuf
|
||||
import app.seb3thehacker.gearslip.notify.PhoneNavigation
|
||||
|
||||
/**
|
||||
* The next turn on the car's instrument cluster, over its NavigationStatusService (Service
|
||||
@@ -200,6 +201,14 @@ internal class NavTurn(val step: Step, val distance: Distance?, val seconds: Lon
|
||||
/** The map app's next turn: from its trip (what clusters get), or its routing card. */
|
||||
fun current(): NavTurn? {
|
||||
val nav = CarServices.navOrNull() ?: return null
|
||||
hosted(nav)?.let { return it }
|
||||
// Google Maps or Waze guiding on the phone, when no map in the car is.
|
||||
val phone = PhoneNavigation.turn.value ?: return null
|
||||
val step = Step.Builder(phone.road).setManeuver(Maneuver.Builder(phone.maneuver).build()).build()
|
||||
return NavTurn(step, phone.distance, null, false)
|
||||
}
|
||||
|
||||
private fun hosted(nav: app.seb3thehacker.gearslip.host.CarAppConnection): NavTurn? {
|
||||
nav.trip.value?.let { trip ->
|
||||
val step = trip.steps.firstOrNull() ?: return@let
|
||||
val estimate = trip.stepTravelEstimates.firstOrNull()
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.host.clockTime
|
||||
import app.seb3thehacker.gearslip.host.display
|
||||
import app.seb3thehacker.gearslip.host.remainingTime
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* The instrument-cluster screen: the next turn, big enough to read in a glance past the wheel.
|
||||
* The arrow and the distance to it, what to do there, and the time, distance and arrival left
|
||||
* along the bottom. With no route it shows what's playing, quietly, or nothing.
|
||||
*
|
||||
* Always dark, like the dials around it. [marginX] and [marginY] are the pixels the car crops off
|
||||
* the frame, kept clear.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ClusterScreen(marginX: Int, marginY: Int) {
|
||||
// The map app's turn changes a few times a minute: read it once a second.
|
||||
val turn by produceState<ClusterTurn?>(null) {
|
||||
while (true) {
|
||||
value = ClusterTurn.now()
|
||||
delay(1_000)
|
||||
}
|
||||
}
|
||||
val density = LocalDensity.current
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
CompositionLocalProvider(LocalContentColor provides Color.White) {
|
||||
BoxWithConstraints(
|
||||
Modifier.fillMaxSize().background(Color.Black).padding(
|
||||
horizontal = with(density) { (marginX / 2).toDp() } + 24.dp,
|
||||
vertical = with(density) { (marginY / 2).toDp() } + 16.dp,
|
||||
),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
// Sized off the height: clusters run from short strips to near squares.
|
||||
val unit = maxHeight / 12
|
||||
val t = turn
|
||||
if (t == null) {
|
||||
NowPlayingLine(Modifier.align(Alignment.Center))
|
||||
return@BoxWithConstraints
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(unit * 0.4f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CarGlyph(t.icon, Modifier.size(unit * 3.4f))
|
||||
Spacer(Modifier.width(unit))
|
||||
Text(
|
||||
t.distance,
|
||||
fontSize = with(density) { (unit * 2.2f).toSp() },
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
if (t.what.isNotEmpty()) {
|
||||
Text(
|
||||
t.what,
|
||||
fontSize = with(density) { (unit * 1.1f).toSp() },
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (t.trip.isNotEmpty()) {
|
||||
Text(
|
||||
t.trip,
|
||||
fontSize = with(density) { (unit * 0.85f).toSp() },
|
||||
color = Color.White.copy(alpha = 0.7f),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NowPlayingLine(modifier: Modifier) {
|
||||
val now = CarServices.mediaOrNull()?.now?.value
|
||||
if (now == null || now.title.isEmpty()) return
|
||||
Text(
|
||||
listOf(now.title, now.artist).filter { it.isNotEmpty() }.joinToString(" · "),
|
||||
modifier,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = Color.White.copy(alpha = 0.7f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
/** What the cluster shows of a route, read from [NavTurn] and the trip's estimate to the destination. */
|
||||
private class ClusterTurn(
|
||||
val icon: androidx.car.app.model.CarIcon?,
|
||||
val distance: String,
|
||||
val what: String,
|
||||
val trip: String,
|
||||
) {
|
||||
companion object {
|
||||
fun now(): ClusterTurn? {
|
||||
val turn = NavTurn.current() ?: return null
|
||||
if (turn.rerouting) return null
|
||||
val step = turn.step
|
||||
val cue = step.cue?.toString().orEmpty()
|
||||
val what = cue.ifEmpty { step.road?.toString().orEmpty() }
|
||||
val estimate = CarServices.navOrNull()?.trip?.value?.destinationTravelEstimates?.firstOrNull()
|
||||
val trip = estimate?.let { e ->
|
||||
listOf(
|
||||
e.remainingTimeSeconds.takeIf { it >= 0 }?.let { remainingTime(it) }.orEmpty(),
|
||||
e.remainingDistance.display(),
|
||||
e.arrivalTimeAtDestination.clockTime(),
|
||||
).filter { it.isNotEmpty() }.joinToString(" · ")
|
||||
}.orEmpty()
|
||||
return ClusterTurn(step.maneuver?.icon, turn.distance.display(), what, trip)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import android.speech.tts.TextToSpeech
|
||||
import android.speech.tts.UtteranceProgressListener
|
||||
import androidx.car.app.model.CarText
|
||||
@@ -10,6 +14,7 @@ import androidx.car.app.navigation.model.Maneuver
|
||||
import androidx.car.app.navigation.model.Step
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.audio.AudioLink
|
||||
import app.seb3thehacker.gearslip.audio.CarAudio
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
@@ -39,11 +44,16 @@ object DirectionsVoice {
|
||||
private var appContext: Context? = null
|
||||
private var tts: TextToSpeech? = null
|
||||
@Volatile private var ttsReady = false
|
||||
/** When the engine was last started; one not ready by [RESTART_MS] later gets another try. */
|
||||
@Volatile private var ttsStartedAt = 0L
|
||||
private val pending = ConcurrentHashMap<String, CountDownLatch>()
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
|
||||
@Volatile private var link: AudioLink? = null
|
||||
@Volatile private var running = false
|
||||
@Volatile private var musicIsOurs: () -> Boolean = { false }
|
||||
/** The car's music sink once it's set up, for [CarSettings.directionsOverMusic]. */
|
||||
@Volatile private var musicSink: () -> AudioLink? = { null }
|
||||
private val speaker = Executors.newSingleThreadScheduledExecutor { Thread(it, "gearslip-directions").apply { isDaemon = true } }
|
||||
/** Counts announcements, so a hand-back scheduled after one is dropped if another has begun. */
|
||||
private var spoken = 0
|
||||
@@ -55,7 +65,19 @@ object DirectionsVoice {
|
||||
fun init(context: Context) {
|
||||
if (tts != null) return
|
||||
appContext = context.applicationContext
|
||||
tts = TextToSpeech(appContext) { status -> ttsReady = status == TextToSpeech.SUCCESS }.apply {
|
||||
start()
|
||||
}
|
||||
|
||||
private fun start() {
|
||||
ttsReady = false
|
||||
ttsStartedAt = SystemClock.elapsedRealtime()
|
||||
tts = TextToSpeech(appContext) { status ->
|
||||
ttsReady = status == TextToSpeech.SUCCESS
|
||||
if (!ttsReady) GearslipLog.w("guidance: text-to-speech didn't start (status $status)")
|
||||
// The turns are worded in Gearslip's language; the engine's default voice may be
|
||||
// another one, and read German words with an English voice (issue #70).
|
||||
else speakIn(appContext?.resources?.configuration?.locales?.get(0))
|
||||
}.apply {
|
||||
setOnUtteranceProgressListener(object : UtteranceProgressListener() {
|
||||
override fun onStart(utteranceId: String?) {}
|
||||
override fun onDone(utteranceId: String?) { pending.remove(utteranceId)?.countDown() }
|
||||
@@ -65,10 +87,21 @@ object DirectionsVoice {
|
||||
}
|
||||
}
|
||||
|
||||
/** Keeps the engine's own voice when it has none for [locale]. */
|
||||
private fun speakIn(locale: java.util.Locale?) {
|
||||
val engine = tts ?: return
|
||||
locale ?: return
|
||||
val result = engine.setLanguage(locale)
|
||||
if (result == TextToSpeech.LANG_MISSING_DATA || result == TextToSpeech.LANG_NOT_SUPPORTED) {
|
||||
GearslipLog.w("guidance: the speech engine has no ${locale.displayLanguage} voice - using its default")
|
||||
}
|
||||
}
|
||||
|
||||
/** The car opened its directions sink: start following the map app's turns. */
|
||||
fun attach(link: AudioLink, musicIsOurs: () -> Boolean) {
|
||||
fun attach(link: AudioLink, musicIsOurs: () -> Boolean, musicSink: () -> AudioLink? = { null }) {
|
||||
this.link = link
|
||||
this.musicIsOurs = musicIsOurs
|
||||
this.musicSink = musicSink
|
||||
GearslipLog.i("guidance: the car offered a directions sink at ${link.sampleRate}Hz x${link.channels}")
|
||||
if (running) return
|
||||
running = true
|
||||
@@ -121,7 +154,7 @@ object DirectionsVoice {
|
||||
say(instruction)
|
||||
stage = Stage.DONE
|
||||
} else {
|
||||
say("In ${spoken(turn.distance)}, $instruction")
|
||||
say(ahead(turn.distance, instruction))
|
||||
stage = if (meters != null && meters > PREPARE_FROM_METERS) Stage.FAR else Stage.PREPARED
|
||||
}
|
||||
return
|
||||
@@ -132,7 +165,7 @@ object DirectionsVoice {
|
||||
stage = Stage.DONE
|
||||
}
|
||||
stage == Stage.FAR && meters != null && meters <= PREPARE_AT_METERS -> {
|
||||
say("In ${spoken(turn.distance)}, $instruction")
|
||||
say(ahead(turn.distance, instruction))
|
||||
stage = Stage.PREPARED
|
||||
}
|
||||
}
|
||||
@@ -140,67 +173,79 @@ object DirectionsVoice {
|
||||
|
||||
/** What to do, in words: built from the manoeuvre when the app names one, else the app's own text. */
|
||||
private fun instruction(step: Step): String? {
|
||||
val res = appContext?.resources ?: return null
|
||||
val road = step.road.text()
|
||||
val onto = if (road.isBlank()) "" else " onto $road"
|
||||
val toward = if (road.isBlank()) "" else " toward $road"
|
||||
// Each manoeuvre is a whole sentence, with and without the road, so a translation can
|
||||
// put the road wherever its language wants it.
|
||||
fun say(plain: Int, withRoad: Int) =
|
||||
if (road.isBlank() || withRoad == 0) res.getString(plain) else res.getString(withRoad, road)
|
||||
val maneuver = step.maneuver
|
||||
val built = when (maneuver?.type) {
|
||||
Maneuver.TYPE_TURN_SLIGHT_LEFT -> "Turn slightly left$onto"
|
||||
Maneuver.TYPE_TURN_SLIGHT_RIGHT -> "Turn slightly right$onto"
|
||||
Maneuver.TYPE_TURN_NORMAL_LEFT -> "Turn left$onto"
|
||||
Maneuver.TYPE_TURN_NORMAL_RIGHT -> "Turn right$onto"
|
||||
Maneuver.TYPE_TURN_SHARP_LEFT -> "Turn sharply left$onto"
|
||||
Maneuver.TYPE_TURN_SHARP_RIGHT -> "Turn sharply right$onto"
|
||||
Maneuver.TYPE_U_TURN_LEFT, Maneuver.TYPE_U_TURN_RIGHT -> "Make a U-turn"
|
||||
Maneuver.TYPE_KEEP_LEFT -> "Keep left$toward"
|
||||
Maneuver.TYPE_KEEP_RIGHT -> "Keep right$toward"
|
||||
Maneuver.TYPE_FORK_LEFT -> "Keep left at the fork$toward"
|
||||
Maneuver.TYPE_FORK_RIGHT -> "Keep right at the fork$toward"
|
||||
Maneuver.TYPE_TURN_SLIGHT_LEFT -> say(R.string.dir_slight_left, R.string.dir_slight_left_onto)
|
||||
Maneuver.TYPE_TURN_SLIGHT_RIGHT -> say(R.string.dir_slight_right, R.string.dir_slight_right_onto)
|
||||
Maneuver.TYPE_TURN_NORMAL_LEFT -> say(R.string.dir_left, R.string.dir_left_onto)
|
||||
Maneuver.TYPE_TURN_NORMAL_RIGHT -> say(R.string.dir_right, R.string.dir_right_onto)
|
||||
Maneuver.TYPE_TURN_SHARP_LEFT -> say(R.string.dir_sharp_left, R.string.dir_sharp_left_onto)
|
||||
Maneuver.TYPE_TURN_SHARP_RIGHT -> say(R.string.dir_sharp_right, R.string.dir_sharp_right_onto)
|
||||
Maneuver.TYPE_U_TURN_LEFT, Maneuver.TYPE_U_TURN_RIGHT -> say(R.string.dir_u_turn, 0)
|
||||
Maneuver.TYPE_KEEP_LEFT -> say(R.string.dir_keep_left, R.string.dir_keep_left_toward)
|
||||
Maneuver.TYPE_KEEP_RIGHT -> say(R.string.dir_keep_right, R.string.dir_keep_right_toward)
|
||||
Maneuver.TYPE_FORK_LEFT -> say(R.string.dir_fork_left, R.string.dir_fork_left_toward)
|
||||
Maneuver.TYPE_FORK_RIGHT -> say(R.string.dir_fork_right, R.string.dir_fork_right_toward)
|
||||
Maneuver.TYPE_ON_RAMP_SLIGHT_LEFT, Maneuver.TYPE_ON_RAMP_NORMAL_LEFT, Maneuver.TYPE_ON_RAMP_SHARP_LEFT,
|
||||
Maneuver.TYPE_ON_RAMP_U_TURN_LEFT -> "Take the ramp on the left$onto"
|
||||
Maneuver.TYPE_ON_RAMP_U_TURN_LEFT -> say(R.string.dir_ramp_left, R.string.dir_ramp_left_onto)
|
||||
Maneuver.TYPE_ON_RAMP_SLIGHT_RIGHT, Maneuver.TYPE_ON_RAMP_NORMAL_RIGHT, Maneuver.TYPE_ON_RAMP_SHARP_RIGHT,
|
||||
Maneuver.TYPE_ON_RAMP_U_TURN_RIGHT -> "Take the ramp on the right$onto"
|
||||
Maneuver.TYPE_OFF_RAMP_SLIGHT_LEFT, Maneuver.TYPE_OFF_RAMP_NORMAL_LEFT -> "Take the exit on the left$toward"
|
||||
Maneuver.TYPE_OFF_RAMP_SLIGHT_RIGHT, Maneuver.TYPE_OFF_RAMP_NORMAL_RIGHT -> "Take the exit on the right$toward"
|
||||
Maneuver.TYPE_MERGE_LEFT -> "Merge left$onto"
|
||||
Maneuver.TYPE_MERGE_RIGHT -> "Merge right$onto"
|
||||
Maneuver.TYPE_MERGE_SIDE_UNSPECIFIED -> "Merge$onto"
|
||||
Maneuver.TYPE_ON_RAMP_U_TURN_RIGHT -> say(R.string.dir_ramp_right, R.string.dir_ramp_right_onto)
|
||||
Maneuver.TYPE_OFF_RAMP_SLIGHT_LEFT, Maneuver.TYPE_OFF_RAMP_NORMAL_LEFT -> say(R.string.dir_exit_left, R.string.dir_exit_left_toward)
|
||||
Maneuver.TYPE_OFF_RAMP_SLIGHT_RIGHT, Maneuver.TYPE_OFF_RAMP_NORMAL_RIGHT -> say(R.string.dir_exit_right, R.string.dir_exit_right_toward)
|
||||
Maneuver.TYPE_MERGE_LEFT -> say(R.string.dir_merge_left, R.string.dir_merge_left_onto)
|
||||
Maneuver.TYPE_MERGE_RIGHT -> say(R.string.dir_merge_right, R.string.dir_merge_right_onto)
|
||||
Maneuver.TYPE_MERGE_SIDE_UNSPECIFIED -> say(R.string.dir_merge, R.string.dir_merge_onto)
|
||||
Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CW, Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CW_WITH_ANGLE,
|
||||
Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW, Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW_WITH_ANGLE ->
|
||||
maneuver.roundaboutExitNumber.takeIf { it in 1..ORDINALS.size }
|
||||
?.let { "At the roundabout, take the ${ORDINALS[it - 1]} exit$onto" }
|
||||
?: "Go through the roundabout$onto"
|
||||
Maneuver.TYPE_ROUNDABOUT_ENTER_CW, Maneuver.TYPE_ROUNDABOUT_ENTER_CCW -> "Enter the roundabout"
|
||||
Maneuver.TYPE_ROUNDABOUT_EXIT_CW, Maneuver.TYPE_ROUNDABOUT_EXIT_CCW -> "Exit the roundabout$onto"
|
||||
Maneuver.TYPE_STRAIGHT -> "Continue straight$onto"
|
||||
Maneuver.TYPE_NAME_CHANGE -> "Continue$onto"
|
||||
Maneuver.TYPE_FERRY_BOAT, Maneuver.TYPE_FERRY_BOAT_LEFT, Maneuver.TYPE_FERRY_BOAT_RIGHT -> "Take the ferry"
|
||||
Maneuver.TYPE_FERRY_TRAIN, Maneuver.TYPE_FERRY_TRAIN_LEFT, Maneuver.TYPE_FERRY_TRAIN_RIGHT -> "Take the train"
|
||||
Maneuver.TYPE_DESTINATION, Maneuver.TYPE_DESTINATION_STRAIGHT -> "Your destination is ahead"
|
||||
Maneuver.TYPE_DESTINATION_LEFT -> "Your destination is on the left"
|
||||
Maneuver.TYPE_DESTINATION_RIGHT -> "Your destination is on the right"
|
||||
?.let { res.getString(ORDINALS[it - 1]) }
|
||||
?.let { exit ->
|
||||
if (road.isBlank()) res.getString(R.string.dir_roundabout_take, exit)
|
||||
else res.getString(R.string.dir_roundabout_take_onto, exit, road)
|
||||
}
|
||||
?: say(R.string.dir_roundabout_through, R.string.dir_roundabout_through_onto)
|
||||
Maneuver.TYPE_ROUNDABOUT_ENTER_CW, Maneuver.TYPE_ROUNDABOUT_ENTER_CCW -> say(R.string.dir_roundabout_enter, 0)
|
||||
Maneuver.TYPE_ROUNDABOUT_EXIT_CW, Maneuver.TYPE_ROUNDABOUT_EXIT_CCW -> say(R.string.dir_roundabout_exit, R.string.dir_roundabout_exit_onto)
|
||||
Maneuver.TYPE_STRAIGHT -> say(R.string.dir_straight, R.string.dir_straight_onto)
|
||||
Maneuver.TYPE_NAME_CHANGE -> say(R.string.dir_continue, R.string.dir_continue_onto)
|
||||
Maneuver.TYPE_FERRY_BOAT, Maneuver.TYPE_FERRY_BOAT_LEFT, Maneuver.TYPE_FERRY_BOAT_RIGHT -> say(R.string.dir_ferry, 0)
|
||||
Maneuver.TYPE_FERRY_TRAIN, Maneuver.TYPE_FERRY_TRAIN_LEFT, Maneuver.TYPE_FERRY_TRAIN_RIGHT -> say(R.string.dir_train, 0)
|
||||
Maneuver.TYPE_DESTINATION, Maneuver.TYPE_DESTINATION_STRAIGHT -> say(R.string.dir_destination, 0)
|
||||
Maneuver.TYPE_DESTINATION_LEFT -> say(R.string.dir_destination_left, 0)
|
||||
Maneuver.TYPE_DESTINATION_RIGHT -> say(R.string.dir_destination_right, 0)
|
||||
else -> null
|
||||
}
|
||||
return built ?: step.cue.text().ifBlank { null }
|
||||
}
|
||||
|
||||
/** "In 300 meters, turn left": the distance ahead of a turn that isn't close yet. */
|
||||
private fun ahead(d: Distance, instruction: String): String =
|
||||
appContext?.getString(R.string.dir_in_distance, spoken(d), instruction) ?: instruction
|
||||
|
||||
/** The app's own rounded distance, read the way a person would say it. */
|
||||
private fun spoken(d: Distance): String {
|
||||
val res = appContext?.resources ?: return ""
|
||||
val n = d.displayDistance
|
||||
val number = if (n % 1.0 == 0.0) n.toInt().toString() else "%.1f".format(n)
|
||||
val one = n == 1.0
|
||||
val whole = n % 1.0 == 0.0
|
||||
val number = if (whole) n.toInt().toString() else "%.1f".format(n)
|
||||
return when (d.displayUnit) {
|
||||
Distance.UNIT_KILOMETERS, Distance.UNIT_KILOMETERS_P1 -> if (one) "1 kilometer" else "$number kilometers"
|
||||
Distance.UNIT_KILOMETERS, Distance.UNIT_KILOMETERS_P1 ->
|
||||
if (whole) res.getQuantityString(R.plurals.dir_km, n.toInt(), number) else res.getString(R.string.dir_km_decimal, number)
|
||||
Distance.UNIT_MILES, Distance.UNIT_MILES_P1 -> when (n) {
|
||||
0.25 -> "a quarter mile"
|
||||
0.5 -> "half a mile"
|
||||
0.75 -> "three quarters of a mile"
|
||||
1.0 -> "1 mile"
|
||||
else -> "$number miles"
|
||||
0.25 -> res.getString(R.string.dir_quarter_mile)
|
||||
0.5 -> res.getString(R.string.dir_half_mile)
|
||||
0.75 -> res.getString(R.string.dir_three_quarter_mile)
|
||||
else -> if (whole) res.getQuantityString(R.plurals.dir_miles, n.toInt(), number) else res.getString(R.string.dir_miles_decimal, number)
|
||||
}
|
||||
Distance.UNIT_FEET -> "$number feet"
|
||||
Distance.UNIT_YARDS -> "$number yards"
|
||||
else -> "${n.roundToInt()} meters"
|
||||
Distance.UNIT_FEET -> res.getQuantityString(R.plurals.dir_feet, n.toInt(), number)
|
||||
Distance.UNIT_YARDS -> res.getQuantityString(R.plurals.dir_yards, n.toInt(), number)
|
||||
else -> res.getQuantityString(R.plurals.dir_meters, n.roundToInt(), n.roundToInt())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,6 +264,16 @@ object DirectionsVoice {
|
||||
val sink = link ?: return
|
||||
if (!ttsReady) {
|
||||
GearslipLog.w("guidance: text-to-speech isn't ready, a turn went unspoken")
|
||||
// A failed or unanswered start was final, so one bad start silenced a whole drive
|
||||
// (issue #35). Try again, once the last try has had its time.
|
||||
if (SystemClock.elapsedRealtime() - ttsStartedAt > RESTART_MS) {
|
||||
ttsStartedAt = SystemClock.elapsedRealtime()
|
||||
GearslipLog.i("guidance: starting text-to-speech again")
|
||||
main.post {
|
||||
runCatching { engine.shutdown() }
|
||||
start()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
val file = File(context.cacheDir, "directions.wav")
|
||||
@@ -234,10 +289,16 @@ object DirectionsVoice {
|
||||
GearslipLog.w("guidance: text-to-speech took too long, a turn went unspoken")
|
||||
return
|
||||
}
|
||||
val pcm = Wav.read(file)?.let { Wav.convert(it, sink.sampleRate, sink.channels) } ?: run {
|
||||
val wav = Wav.read(file) ?: run {
|
||||
GearslipLog.w("guidance: text-to-speech gave audio Gearslip can't read")
|
||||
return
|
||||
}
|
||||
val music = musicSink()
|
||||
if (CarSettings.directionsOverMusic.value && music != null) {
|
||||
speakOverMusic(music, Wav.convert(wav, music.sampleRate, music.channels))
|
||||
return
|
||||
}
|
||||
val pcm = Wav.convert(wav, sink.sampleRate, sink.channels)
|
||||
// Logged before speaking too, so a car that dies mid-announcement shows what it was doing.
|
||||
GearslipLog.i("guidance: speaking a turn")
|
||||
// Held through back-to-back prompts rather than asked for and given back each time.
|
||||
@@ -251,18 +312,44 @@ object DirectionsVoice {
|
||||
GearslipLog.i("guidance: spoke a turn (${pcm.size / (2 * sink.channels) * 1000 / sink.sampleRate} ms)")
|
||||
}
|
||||
|
||||
/**
|
||||
* A 2025 Nissan's Bosch unit drops the link about a second after any turn on its directions
|
||||
* channel while its music channel is open, and plays music through nothing else (issue #35).
|
||||
* So the directions channel is left alone: the turn rides in the music, or, with no music
|
||||
* playing, goes out on the music channel by itself.
|
||||
*/
|
||||
private fun speakOverMusic(music: AudioLink, pcm: ByteArray) {
|
||||
GearslipLog.i("guidance: speaking a turn over the music channel")
|
||||
if (!CarAudio.speakOverMusic(pcm)) {
|
||||
music.startForTurn()
|
||||
try {
|
||||
stream(music, pcm, alive = { musicSink() === music }, startAndStop = false)
|
||||
} finally {
|
||||
music.stopForTurn()
|
||||
// Music that started during the turn found the stream running and didn't start its own.
|
||||
if (CarAudio.capture.value == CarAudio.Capture.ON) music.start()
|
||||
}
|
||||
}
|
||||
GearslipLog.i("guidance: spoke a turn over the music channel (${pcm.size / (2 * music.channels) * 1000 / music.sampleRate} ms)")
|
||||
}
|
||||
|
||||
/** Start, the words in 20 ms chunks at about real time, a little silence, Stop. */
|
||||
private fun stream(sink: AudioLink, pcm: ByteArray) {
|
||||
private fun stream(
|
||||
sink: AudioLink,
|
||||
pcm: ByteArray,
|
||||
alive: () -> Boolean = { link === sink },
|
||||
startAndStop: Boolean = true,
|
||||
) {
|
||||
val frame = 2 * sink.channels
|
||||
val chunk = sink.sampleRate / 50 * frame
|
||||
val chunkMs = 20L
|
||||
val padded = pcm + ByteArray(sink.sampleRate / 5 * frame) // 200 ms tail so the last word isn't clipped
|
||||
sink.start()
|
||||
if (startAndStop) sink.start()
|
||||
try {
|
||||
val began = System.currentTimeMillis()
|
||||
var offset = 0
|
||||
var sent = 0
|
||||
while (offset < padded.size && link === sink) {
|
||||
while (offset < padded.size && alive()) {
|
||||
val size = minOf(chunk, padded.size - offset)
|
||||
sink.write(padded.copyOfRange(offset, offset + size), size)
|
||||
offset += size
|
||||
@@ -273,7 +360,7 @@ object DirectionsVoice {
|
||||
// Let the car play what it has before closing the stream on it.
|
||||
Thread.sleep(LEAD_MS)
|
||||
} finally {
|
||||
sink.stop()
|
||||
if (startAndStop) sink.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,5 +434,9 @@ object DirectionsVoice {
|
||||
private const val LEAD_MS = 100L
|
||||
private const val HOLD_MS = 2_000L
|
||||
private const val SYNTH_TIMEOUT_S = 10L
|
||||
private val ORDINALS = listOf("first", "second", "third", "fourth", "fifth", "sixth", "seventh", "eighth")
|
||||
private const val RESTART_MS = 30_000L
|
||||
private val ORDINALS = listOf(
|
||||
R.string.dir_ordinal_1, R.string.dir_ordinal_2, R.string.dir_ordinal_3, R.string.dir_ordinal_4,
|
||||
R.string.dir_ordinal_5, R.string.dir_ordinal_6, R.string.dir_ordinal_7, R.string.dir_ordinal_8,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import android.app.ActivityOptions
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.view.Display
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.LauncherActivity
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Gearslip as the home screen of the device it runs on: a head unit that runs Android itself, or
|
||||
* a spare phone or tablet on the dash. The car UI fills the device's own screen, with no car in
|
||||
* between. Off until it's turned on in Settings, so a phone never lists Gearslip as a home app.
|
||||
*/
|
||||
object LauncherMode {
|
||||
|
||||
private val _active = MutableStateFlow(false)
|
||||
|
||||
/** The car UI is up on this device's own screen, as its home screen. */
|
||||
val active: StateFlow<Boolean> = _active
|
||||
|
||||
internal fun setActive(on: Boolean) {
|
||||
_active.value = on
|
||||
}
|
||||
|
||||
private fun component(context: Context) = ComponentName(context, LauncherActivity::class.java)
|
||||
|
||||
/** Whether Android may offer Gearslip as the home app. */
|
||||
fun enabled(context: Context): Boolean =
|
||||
context.packageManager.getComponentEnabledSetting(component(context)) ==
|
||||
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||
|
||||
fun setEnabled(context: Context, on: Boolean) {
|
||||
context.packageManager.setComponentEnabledSetting(
|
||||
component(context),
|
||||
if (on) PackageManager.COMPONENT_ENABLED_STATE_ENABLED else PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
|
||||
PackageManager.DONT_KILL_APP,
|
||||
)
|
||||
GearslipLog.i("launcher mode ${if (on) "on" else "off"}")
|
||||
}
|
||||
|
||||
/** Android's own "Default home app" choice, where Gearslip is picked once it's offered. */
|
||||
fun openHomeChoice(context: Context) {
|
||||
runCatching {
|
||||
context.startActivity(Intent(Settings.ACTION_HOME_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}.onFailure { GearslipLog.w("launcher mode: no home app settings on this device (${it.message})") }
|
||||
}
|
||||
|
||||
/** Head units and most tablets have no phone of their own to call with. */
|
||||
fun canCall(context: Context): Boolean {
|
||||
val feature = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
PackageManager.FEATURE_TELEPHONY_CALLING
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
PackageManager.FEATURE_TELEPHONY
|
||||
}
|
||||
return context.packageManager.hasSystemFeature(feature)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens an ordinary app full screen, Gearslip's own phone screens included. The car UI lives
|
||||
* on a display of its own, and a context from it would start the app there, which Android
|
||||
* refuses; the application context and the device's own display put it on the real screen.
|
||||
*/
|
||||
fun launch(context: Context, packageName: String) {
|
||||
val app = context.applicationContext
|
||||
val intent = app.packageManager.getLaunchIntentForPackage(packageName) ?: return
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
val options = ActivityOptions.makeBasic().setLaunchDisplayId(Display.DEFAULT_DISPLAY)
|
||||
runCatching { app.startActivity(intent, options.toBundle()) }
|
||||
.onFailure { GearslipLog.w("launcher mode: couldn't open $packageName (${it.message})") }
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
@@ -11,6 +13,7 @@ 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.aspectRatio
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -83,34 +86,138 @@ internal fun BrowsePanel(browse: app.seb3thehacker.gearslip.media.BrowseState, m
|
||||
// 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) {
|
||||
Row(Modifier.padding(bottom = 12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (media.canGoUp) {
|
||||
GsIconButton(Icons.Filled.ArrowBack, "Up", media::up)
|
||||
GsIconButton(Icons.Filled.ArrowBack, stringResource(R.string.media_up), media::up, size = 44.dp, shape = CircleShape)
|
||||
Spacer(Modifier.width(14.dp))
|
||||
}
|
||||
browse.trail.lastOrNull()?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
|
||||
style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
when {
|
||||
browse.loading -> Notice("Loading…", null)
|
||||
browse.loading -> Notice(stringResource(R.string.media_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.",
|
||||
stringResource(R.string.media_keeps_library, appLabel),
|
||||
stringResource(R.string.media_browse_start),
|
||||
)
|
||||
browse.failed -> Notice("Could not load this list.", null)
|
||||
browse.entries.isEmpty() -> Notice("Nothing here.", null)
|
||||
else -> LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(browse.entries, key = { it.id }) { entry -> EntryRow(entry, media) }
|
||||
browse.failed -> Notice(stringResource(R.string.media_load_failed), null)
|
||||
browse.entries.isEmpty() -> Notice(stringResource(R.string.media_nothing_here), null)
|
||||
else -> BrowseList(browse.entries, media)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A list laid out the way the app asked: under its section headings, covers where it asked for a
|
||||
* grid and rows everywhere else. A grid section among others is a shelf that scrolls sideways; a
|
||||
* list that is one grid and nothing else wraps into rows of covers instead.
|
||||
*/
|
||||
@Composable
|
||||
private fun BrowseList(entries: List<MediaEntry>, media: CarMedia) {
|
||||
val sections = remember(entries) { browseSections(entries) }
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
val onlyGrid = sections.size == 1 && sections[0].grid && sections[0].heading == null
|
||||
val columns = ((maxWidth + SHELF_GAP) / (SHELF_COVER + SHELF_GAP)).toInt().coerceAtLeast(2)
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
sections.forEachIndexed { i, section ->
|
||||
val heading = section.heading
|
||||
if (heading != null && sections.getOrNull(i - 1)?.heading != heading) {
|
||||
item(key = "heading $i") {
|
||||
Text(
|
||||
heading,
|
||||
style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = if (i == 0) 0.dp else 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
when {
|
||||
!section.grid -> items(section.entries, key = { it.id }) { entry -> EntryRow(entry, media) }
|
||||
onlyGrid -> items(section.entries.chunked(columns)) { row ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(SHELF_GAP)) {
|
||||
row.forEach { CoverTile(it, media, Modifier.weight(1f)) }
|
||||
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
|
||||
}
|
||||
}
|
||||
else -> item(key = "shelf $i") {
|
||||
androidx.compose.foundation.lazy.LazyRow(horizontalArrangement = Arrangement.spacedBy(SHELF_GAP)) {
|
||||
items(section.entries, key = { it.id }) { CoverTile(it, media, Modifier.width(SHELF_COVER)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val SHELF_COVER = 132.dp
|
||||
private val SHELF_GAP = 14.dp
|
||||
|
||||
/** A run of entries under one heading that are all covers or all rows. */
|
||||
private class BrowseSection(val heading: String?, val grid: Boolean, val entries: List<MediaEntry>)
|
||||
|
||||
private fun browseSections(entries: List<MediaEntry>): List<BrowseSection> {
|
||||
val out = mutableListOf<BrowseSection>()
|
||||
var run = mutableListOf<MediaEntry>()
|
||||
for (entry in entries) {
|
||||
val last = run.lastOrNull()
|
||||
if (last != null && (last.group != entry.group || last.grid != entry.grid)) {
|
||||
out += BrowseSection(last.group, last.grid, run)
|
||||
run = mutableListOf()
|
||||
}
|
||||
run += entry
|
||||
}
|
||||
run.lastOrNull()?.let { out += BrowseSection(it.group, it.grid, run) }
|
||||
return out
|
||||
}
|
||||
|
||||
/** One entry as a cover with its name under it: what the app's grids are made of. */
|
||||
@Composable
|
||||
private fun CoverTile(entry: MediaEntry, media: CarMedia, modifier: Modifier) {
|
||||
val context = LocalContext.current
|
||||
val icon by produceState(entry.iconBitmap, entry.iconUri) {
|
||||
value = entry.iconBitmap ?: MediaArt.load(context, entry.iconUri)
|
||||
}
|
||||
val shape = RoundedCornerShape(16.dp)
|
||||
Column(modifier.clip(shape).clickable { media.select(entry) }) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.gsPanelShadow(shape)
|
||||
.clip(shape)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHigh),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
icon?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
||||
?: Icon(
|
||||
if (entry.browsable) MediaIcons.Folder else MediaIcons.MusicNote, null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxSize(0.35f),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
entry.title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 8.dp, start = 2.dp),
|
||||
)
|
||||
if (entry.subtitle.isNotEmpty()) {
|
||||
Text(
|
||||
entry.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(start = 2.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- up next -----------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -127,12 +234,15 @@ internal fun QueuePanel(queue: List<QueueTrack>, now: NowPlaying, media: CarMedi
|
||||
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.",
|
||||
stringResource(R.string.media_nothing_queued),
|
||||
stringResource(if (now.title.isEmpty()) R.string.media_queue_empty_idle else R.string.media_queue_empty_playing),
|
||||
)
|
||||
} else {
|
||||
LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(upNext, key = { it.queueId }) { track -> QueueRow(track, media) }
|
||||
}
|
||||
}
|
||||
@@ -148,18 +258,18 @@ private fun QueueRow(track: QueueTrack, media: CarMedia) {
|
||||
Surface(
|
||||
onClick = { media.playQueueItem(track) },
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
RowArt(icon, MediaIcons.MusicNote)
|
||||
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||
Text(track.title, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Column(Modifier.weight(1f).padding(horizontal = 14.dp)) {
|
||||
Text(track.title, style = MaterialTheme.typography.titleMedium, 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)
|
||||
Text(track.subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
Icon(Icons.Filled.PlayArrow, "Play", tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Icon(Icons.Filled.PlayArrow, stringResource(R.string.media_play), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,19 +283,19 @@ internal fun EntryRow(entry: MediaEntry, media: CarMedia, onClick: () -> Unit =
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
RowArt(icon, if (entry.browsable) MediaIcons.Folder else MediaIcons.MusicNote)
|
||||
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||
Text(entry.title, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Column(Modifier.weight(1f).padding(horizontal = 14.dp)) {
|
||||
Text(entry.title, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (entry.subtitle.isNotEmpty()) {
|
||||
Text(entry.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(entry.subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
if (entry.browsable && entry.playable) {
|
||||
GsIconButton(Icons.Filled.PlayArrow, "Play", { media.playAll(entry) })
|
||||
GsIconButton(Icons.Filled.PlayArrow, stringResource(R.string.media_play), { media.playAll(entry) })
|
||||
}
|
||||
if (entry.browsable) Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null)
|
||||
}
|
||||
@@ -196,7 +306,7 @@ internal fun EntryRow(entry: MediaEntry, media: CarMedia, onClick: () -> Unit =
|
||||
@Composable
|
||||
private fun RowArt(icon: android.graphics.Bitmap?, fallback: ImageVector) {
|
||||
Box(
|
||||
Modifier.size(62.dp).clip(MaterialTheme.shapes.medium).background(MaterialTheme.colorScheme.surfaceContainerHigh),
|
||||
Modifier.size(56.dp).clip(RoundedCornerShape(12.dp)).background(MaterialTheme.colorScheme.surfaceContainerHigh),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (icon != null) Image(icon.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
||||
@@ -217,8 +327,14 @@ internal object MediaIcons {
|
||||
val Shuffle = icon("Shuffle", "M10.59,9.17L5.41,4 4,5.41l5.17,5.17 1.42,-1.41zM14.5,4l2.04,2.04L4,18.59 5.41,20 17.96,7.46 20,9.5L20,4h-5.5zM14.83,13.41l-1.41,1.41 3.13,3.13L14.5,20L20,20v-5.5l-2.04,2.04 -3.13,-3.13z")
|
||||
val Repeat = icon("Repeat", "M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4z")
|
||||
val RepeatOne = icon("RepeatOne", "M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4zM13,15L13,9h-1l-2,1v1h1.5v4L13,15z")
|
||||
/** A screen with a filled right-hand panel: "put the player beside the map". */
|
||||
val DockRight = icon("DockRight", "M2,4h20v16H2zM4,6h10v12H4z", PathFillType.EvenOdd)
|
||||
/**
|
||||
* A screen split into the map and a side panel: outlined while the panel is closed ("put the
|
||||
* player beside the map"), filled in while it's open ("close it"), on the side it opens on.
|
||||
*/
|
||||
val DockRight = icon("DockRight", "M2,4h20v16H2zM4,6h10v12H4zM16,6h4v12h-4z", PathFillType.EvenOdd)
|
||||
val DockRightOpen = icon("DockRightOpen", "M2,4h20v16H2zM4,6h10v12H4z", PathFillType.EvenOdd)
|
||||
val DockLeft = icon("DockLeft", "M2,4h20v16H2zM4,6h4v12H4zM10,6h10v12H10z", PathFillType.EvenOdd)
|
||||
val DockLeftOpen = icon("DockLeftOpen", "M2,4h20v16H2zM10,6h10v12H10z", PathFillType.EvenOdd)
|
||||
val Fullscreen = icon("Fullscreen", "M7,14L5,14v5h5v-2L7,17v-3zM5,10h2L7,7h3L10,5L5,5v5zM17,17h-3v2h5v-5h-2v3zM14,5v2h3v3h2L19,5h-5z")
|
||||
val FullscreenExit = icon("FullscreenExit", "M5,16h3v3h2v-5L5,14v2zM8,8L5,8v2h5L10,5L8,5v3zM14,19h2v-3h3v-2h-5v5zM16,8L16,5h-2v5h5L19,8h-3z")
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.foundation.text.TextAutoSize
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
@@ -19,6 +24,7 @@ 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.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
@@ -49,6 +55,9 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.graphics.PathFillType
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
@@ -97,18 +106,18 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
|
||||
|
||||
val art = rememberArt(now)
|
||||
|
||||
// The art's colour behind everything, header included; the buttons keep their own.
|
||||
Surface(Modifier.fillMaxSize(), color = rememberArtColor(art, MaterialTheme.colorScheme.background, darkLightness = 0.15f, lightLightness = 0.9f)) {
|
||||
// The theme's background, with the cover's colours glowing faintly behind the cover on the left.
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
if (phase == CarMedia.Phase.READY && !searching) AlbumGlow(art, Modifier.fillMaxSize(), wide = true)
|
||||
if (phase != CarMedia.Phase.READY) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxSize().padding(bottom = LocalNavBarInset.current)) {
|
||||
AppIdentity(app, onExit, Modifier.padding(horizontal = 16.dp, vertical = 10.dp))
|
||||
when (phase) {
|
||||
CarMedia.Phase.REJECTED -> when (rejection) {
|
||||
CarMedia.Rejection.NEEDS_NOTIFICATION_ACCESS -> Notice(
|
||||
"${app.label} keeps its library to itself.",
|
||||
"Gearslip can still control it once it has Notification access on the phone. " +
|
||||
"Allow it under Settings > Notifications > Notification access.",
|
||||
action = "Open on phone" to {
|
||||
stringResource(R.string.media_keeps_library, app.label),
|
||||
stringResource(R.string.media_needs_access),
|
||||
action = stringResource(R.string.media_open_on_phone) to {
|
||||
val app = context.applicationContext
|
||||
runCatching {
|
||||
app.startActivity(
|
||||
@@ -118,24 +127,23 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
|
||||
},
|
||||
)
|
||||
CarMedia.Rejection.NEEDS_APP_RUNNING -> Notice(
|
||||
"${app.label} isn't running.",
|
||||
"${app.label} plays through Gearslip's own player, but only while it's " +
|
||||
"open on the phone. Open it once and it keeps playing here in the car.",
|
||||
action = "Open ${app.label}" to { media.launchAndConnect(app) },
|
||||
stringResource(R.string.media_not_running, app.label),
|
||||
stringResource(R.string.media_not_running_body, app.label),
|
||||
action = stringResource(R.string.media_open_app, app.label) to { media.launchAndConnect(app) },
|
||||
)
|
||||
CarMedia.Rejection.REFUSED -> Notice(
|
||||
"${app.label} would not let Gearslip browse it.",
|
||||
"Some media apps only accept Google's own host.",
|
||||
stringResource(R.string.media_refused, app.label),
|
||||
stringResource(R.string.media_refused_body),
|
||||
)
|
||||
}
|
||||
else -> Notice("Connecting to ${app.label}…", null)
|
||||
else -> Notice(stringResource(R.string.media_connecting, app.label), null)
|
||||
}
|
||||
}
|
||||
return@Surface
|
||||
}
|
||||
|
||||
if (searching) {
|
||||
SearchPanel(
|
||||
Box(Modifier.padding(bottom = LocalNavBarInset.current)) { SearchPanel(
|
||||
appLabel = app.label,
|
||||
media = media,
|
||||
canPickForYou = now.canSearch,
|
||||
@@ -153,52 +161,91 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
|
||||
tab = Tab.NOW_PLAYING
|
||||
},
|
||||
onClose = { searching = false },
|
||||
)
|
||||
) }
|
||||
return@Surface
|
||||
}
|
||||
|
||||
// Two columns, split the same way on every tab so nothing jumps when switching: the app
|
||||
// and the art (or, beside a list, the compact player) on the left; the tabs across the
|
||||
// top of the right column, over whatever the tab shows.
|
||||
Row(
|
||||
Modifier.fillMaxSize().padding(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
Column(Modifier.weight(0.8f).fillMaxHeight()) {
|
||||
AppIdentity(app, onExit)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
if (tab == Tab.NOW_PLAYING) {
|
||||
CoverArt(art, Modifier.weight(1f).fillMaxWidth())
|
||||
} else {
|
||||
CompactPlayer(now, media, Modifier.weight(1f).fillMaxWidth())
|
||||
// The page runs down under the see-through nav bar, so the cover's glow carries on behind
|
||||
// it; the content stops at the bar's top edge. Lists run right to it; the rest keeps a gap.
|
||||
val barTop = LocalNavBarInset.current
|
||||
val barInset = barTop + 8.dp
|
||||
val tabs: @Composable (Modifier) -> Unit = { tabModifier ->
|
||||
MediaTab(stringResource(R.string.media_now_playing), tab == Tab.NOW_PLAYING, tabModifier) { tab = Tab.NOW_PLAYING }
|
||||
MediaTab(stringResource(R.string.media_up_next), tab == Tab.QUEUE, tabModifier) { tab = Tab.QUEUE }
|
||||
MediaTab(stringResource(R.string.media_browse), tab == Tab.BROWSE, tabModifier) { tab = Tab.BROWSE }
|
||||
MediaTab(stringResource(R.string.storage_lyrics), tab == Tab.LYRICS, tabModifier) { tab = Tab.LYRICS }
|
||||
}
|
||||
val searchButton: @Composable () -> Unit = {
|
||||
if (now.canSearch || media.canSearchLibrary) {
|
||||
GsIconButton(
|
||||
Icons.Filled.Search,
|
||||
stringResource(R.string.media_search, app.label),
|
||||
{ searching = true },
|
||||
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface),
|
||||
size = 44.dp,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
)
|
||||
}
|
||||
}
|
||||
val tabContent: @Composable (Modifier) -> Unit = { contentModifier ->
|
||||
Box(contentModifier) {
|
||||
when (tab) {
|
||||
Tab.NOW_PLAYING -> StageControls(now, media, Modifier.fillMaxSize().padding(bottom = barInset))
|
||||
Tab.BROWSE -> BrowsePanel(browse, media, app.label, Modifier.fillMaxSize().padding(bottom = barTop))
|
||||
Tab.QUEUE -> QueuePanel(queue, now, media, Modifier.fillMaxSize().padding(bottom = barTop))
|
||||
Tab.LYRICS -> LyricsPanel(now, Modifier.fillMaxSize().padding(bottom = barInset))
|
||||
}
|
||||
}
|
||||
Column(Modifier.weight(1.2f).fillMaxHeight()) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
MediaTab("Now playing", tab == Tab.NOW_PLAYING, Modifier.weight(1f)) { tab = Tab.NOW_PLAYING }
|
||||
MediaTab("Browse", tab == Tab.BROWSE, Modifier.weight(1f)) { tab = Tab.BROWSE }
|
||||
MediaTab("Up next", tab == Tab.QUEUE, Modifier.weight(1f)) { tab = Tab.QUEUE }
|
||||
MediaTab("Lyrics", tab == Tab.LYRICS, Modifier.weight(1f)) { tab = Tab.LYRICS }
|
||||
if (now.canSearch || media.canSearchLibrary) {
|
||||
GsIconButton(
|
||||
Icons.Filled.Search,
|
||||
"Search ${app.label}",
|
||||
{ searching = true },
|
||||
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface),
|
||||
size = 44.dp,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
)
|
||||
}
|
||||
|
||||
BoxWithConstraints(Modifier.fillMaxSize().padding(start = 16.dp, end = 16.dp, top = 10.dp)) {
|
||||
// A small screen, or a big one at Extra large size, has no room for two columns: the
|
||||
// app and the tabs share one bar, and a list takes the whole width. The nav bar keeps
|
||||
// the controls, so nothing is lost.
|
||||
if (maxWidth < NARROW_WIDTH || maxHeight - barInset < NARROW_HEIGHT) {
|
||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
AppIdentity(app, onExit, Modifier.widthIn(max = 180.dp))
|
||||
Row(
|
||||
Modifier.weight(1f).horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
||||
) { tabs(Modifier) }
|
||||
searchButton()
|
||||
}
|
||||
search?.let { SearchStatus(it, app.label, media::dismissSearch) }
|
||||
if (tab == Tab.NOW_PLAYING) NowPlayingPage(now, media, Modifier.weight(1f).fillMaxWidth().padding(bottom = barInset), padded = false)
|
||||
else tabContent(Modifier.weight(1f).fillMaxWidth())
|
||||
}
|
||||
return@BoxWithConstraints
|
||||
}
|
||||
|
||||
// Two columns, split the same way on every tab so nothing jumps when switching: the app
|
||||
// and the art (or, beside a list, the compact player) on the left; the tabs across the
|
||||
// top of the right column, over whatever the tab shows.
|
||||
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(24.dp)) {
|
||||
Column(Modifier.weight(0.8f).fillMaxHeight().padding(bottom = barInset)) {
|
||||
AppIdentity(app, onExit)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
if (tab == Tab.NOW_PLAYING) {
|
||||
CoverArt(art, Modifier.weight(1f).fillMaxWidth())
|
||||
} else {
|
||||
CompactPlayer(now, media, art, Modifier.weight(1f).fillMaxWidth())
|
||||
}
|
||||
}
|
||||
search?.let { SearchStatus(it, app.label, media::dismissSearch) }
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||
when (tab) {
|
||||
Tab.NOW_PLAYING -> NowPlayingControls(now, media, Modifier.fillMaxSize())
|
||||
Tab.BROWSE -> BrowsePanel(browse, media, app.label, Modifier.fillMaxSize())
|
||||
Tab.QUEUE -> QueuePanel(queue, now, media, Modifier.fillMaxSize())
|
||||
Tab.LYRICS -> LyricsPanel(now, Modifier.fillMaxSize())
|
||||
Column(Modifier.weight(1.2f).fillMaxHeight()) {
|
||||
// The tabs share the row evenly; a long word (a translation, a big text size)
|
||||
// shrinks to fit its tab instead.
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
tabs(Modifier.weight(1f))
|
||||
searchButton()
|
||||
}
|
||||
search?.let { SearchStatus(it, app.label, media::dismissSearch) }
|
||||
Spacer(Modifier.height(12.dp))
|
||||
tabContent(Modifier.weight(1f).fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -213,7 +260,7 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
|
||||
private fun AppIdentity(app: MediaApp, onExit: () -> Unit, modifier: Modifier = Modifier) {
|
||||
val icon = remember(app) { findEntry(app.component.flattenToString())?.icon }
|
||||
Row(modifier, verticalAlignment = Alignment.CenterVertically) {
|
||||
GsIconButton(Icons.Filled.ArrowBack, "Back", onExit, size = 44.dp)
|
||||
GsIconButton(Icons.Filled.ArrowBack, stringResource(R.string.back), onExit, size = 44.dp)
|
||||
Spacer(Modifier.width(14.dp))
|
||||
icon?.let {
|
||||
Image(it, null, Modifier.size(26.dp).clip(RoundedCornerShape(7.dp)))
|
||||
@@ -273,10 +320,10 @@ private fun SearchPanel(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
GsIconButton(Icons.Filled.ArrowBack, "Back", onClose, size = ICON_BUTTON_SIZE)
|
||||
GsIconButton(Icons.Filled.ArrowBack, stringResource(R.string.back), onClose, size = ICON_BUTTON_SIZE)
|
||||
SearchField(
|
||||
query,
|
||||
"Search $appLabel",
|
||||
stringResource(R.string.media_search, appLabel),
|
||||
Modifier.weight(1f).clickable { typing = true },
|
||||
onClear = {
|
||||
query = ""
|
||||
@@ -287,12 +334,12 @@ private fun SearchPanel(
|
||||
Box(Modifier.weight(1f).fillMaxWidth().padding(horizontal = 16.dp)) {
|
||||
val entries = current?.entries
|
||||
when {
|
||||
trimmed.isEmpty() -> SearchNote("Type a song, artist, album, or playlist.")
|
||||
trimmed.isEmpty() -> SearchNote(stringResource(R.string.media_search_hint))
|
||||
unsupported && canPickForYou ->
|
||||
SearchNote("$appLabel can't list results. Press search, and $appLabel picks what to play.")
|
||||
unsupported -> SearchNote("$appLabel can't search from the car.")
|
||||
entries == null -> SearchNote("Searching $appLabel…")
|
||||
entries.isEmpty() -> SearchNote("$appLabel found nothing for \"$trimmed\".")
|
||||
SearchNote(stringResource(R.string.media_search_no_list, appLabel))
|
||||
unsupported -> SearchNote(stringResource(R.string.media_search_unsupported, appLabel))
|
||||
entries == null -> SearchNote(stringResource(R.string.media_searching, appLabel))
|
||||
entries.isEmpty() -> SearchNote(stringResource(R.string.media_search_nothing, appLabel, trimmed))
|
||||
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues(bottom = 8.dp)) {
|
||||
items(entries) { entry ->
|
||||
EntryRow(entry, media) {
|
||||
@@ -350,8 +397,8 @@ private fun SearchStatus(search: CarMedia.Search, appLabel: String, onDismiss: (
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
if (search.ignored) "$appLabel didn't play anything for \"${search.query}\"."
|
||||
else "Searching $appLabel for \"${search.query}\"…",
|
||||
if (search.ignored) stringResource(R.string.media_search_ignored, appLabel, search.query)
|
||||
else stringResource(R.string.media_searching_for, appLabel, search.query),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
@@ -376,14 +423,17 @@ private fun MediaTab(label: String, selected: Boolean, modifier: Modifier, onCli
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
autoSize = TextAutoSize.StepBased(minFontSize = 11.sp, maxFontSize = MaterialTheme.typography.titleSmall.fontSize),
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The song's lyrics, fetched when this tab opens. Synced lyrics follow playback: the line being
|
||||
* sung sits a little above the middle in bold, lines already sung dim, and the list fades out at
|
||||
* both edges instead of cutting a line in half. Word-timed lyrics also light up each word as it's
|
||||
* sung. Unsynced ones are plain scrolling text.
|
||||
*/
|
||||
@Composable
|
||||
internal fun LyricsPanel(
|
||||
@@ -394,43 +444,89 @@ internal fun LyricsPanel(
|
||||
val context = LocalContext.current
|
||||
val lyrics by produceState<LyricsState>(LyricsState.Loading, now.title, now.artist, now.album, now.durationMs) {
|
||||
value = LyricsState.Loading
|
||||
value = Lyrics.find(context, now.title, now.artist, now.album, now.durationMs)
|
||||
?.let { LyricsState.Found(it) } ?: LyricsState.Missing
|
||||
}
|
||||
val list = rememberLazyListState()
|
||||
|
||||
// Ticks with playback so the highlighted line moves; only synced lyrics need it.
|
||||
var position by remember { mutableLongStateOf(0L) }
|
||||
LaunchedEffect(now) {
|
||||
while (true) {
|
||||
position = now.currentPosition()
|
||||
kotlinx.coroutines.delay(300)
|
||||
val found = Lyrics.find(context, now.title, now.artist, now.album, now.durationMs)
|
||||
value = found?.let { LyricsState.Found(it) } ?: LyricsState.Missing
|
||||
// Cached lyrics show at once; if a source now has better ones, they replace them.
|
||||
if (found != null) {
|
||||
Lyrics.upgrade(context, now.title, now.artist, now.album, now.durationMs, found)
|
||||
?.let { value = LyricsState.Found(it) }
|
||||
}
|
||||
}
|
||||
|
||||
when (val state = lyrics) {
|
||||
LyricsState.Loading -> Notice("Looking for lyrics…", null)
|
||||
LyricsState.Loading -> Notice(stringResource(R.string.media_looking_lyrics), null)
|
||||
LyricsState.Missing -> Notice(
|
||||
if (now.title.isEmpty()) "Nothing playing." else "No lyrics found for this track.",
|
||||
stringResource(if (now.title.isEmpty()) R.string.media_nothing_playing_dot else R.string.media_no_lyrics),
|
||||
null,
|
||||
)
|
||||
is LyricsState.Found -> {
|
||||
val lines = state.result.lines
|
||||
val current = if (state.result.synced) lines.indexOfLast { it.timeMs <= position } else -1
|
||||
LaunchedEffect(current) {
|
||||
if (current >= 0) list.animateScrollToItem((current - 1).coerceAtLeast(0))
|
||||
is LyricsState.Found -> Column(modifier) {
|
||||
if (state.result.synced) SyncedLyrics(now, state.result, lineStyle, Modifier.weight(1f).fillMaxWidth())
|
||||
else PlainLyrics(state.result, lineStyle, Modifier.weight(1f).fillMaxWidth())
|
||||
// Debug builds name the service the lyrics came from, to see which one answers.
|
||||
if (app.seb3thehacker.gearslip.BuildConfig.DEBUG) {
|
||||
val from = state.result.source.ifEmpty { "an older cache" }
|
||||
val kind = when {
|
||||
state.result.lines.any { it.words.isNotEmpty() } -> "word-timed"
|
||||
state.result.synced -> "synced"
|
||||
else -> "plain"
|
||||
}
|
||||
Text(
|
||||
"From $from · $kind",
|
||||
Modifier.padding(top = 4.dp),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
)
|
||||
}
|
||||
LazyColumn(modifier, state = list, verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
itemsIndexed(lines) { index, line ->
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SyncedLyrics(
|
||||
now: NowPlaying,
|
||||
result: LyricsResult,
|
||||
lineStyle: androidx.compose.ui.text.TextStyle,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val lines = result.lines
|
||||
val wordTimed = lines.any { it.words.isNotEmpty() }
|
||||
// Every frame while words are filling in, so a word doesn't light up in steps; otherwise a
|
||||
// few times a second is enough to move the line along.
|
||||
var position by remember { mutableLongStateOf(now.currentPosition()) }
|
||||
LaunchedEffect(now, wordTimed) {
|
||||
while (true) {
|
||||
position = now.currentPosition()
|
||||
if (wordTimed && now.state == PlaybackState.STATE_PLAYING) androidx.compose.runtime.withFrameMillis { }
|
||||
else delay(300)
|
||||
}
|
||||
}
|
||||
// Only the line changing recomposes the list; the moving position is read by the one line.
|
||||
val current by remember(lines) {
|
||||
androidx.compose.runtime.derivedStateOf { lines.indexOfLast { it.timeMs <= position } }
|
||||
}
|
||||
val list = rememberLazyListState()
|
||||
LaunchedEffect(current) {
|
||||
if (current >= 0) list.animateScrollToItem(current)
|
||||
}
|
||||
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val sung = scheme.onSurfaceVariant.copy(alpha = 0.55f)
|
||||
BoxWithConstraints(modifier.fadingEdges()) {
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize(),
|
||||
state = list,
|
||||
// The padding lets the first line start, and the last line end, near the middle.
|
||||
contentPadding = PaddingValues(top = maxHeight * 0.32f, bottom = maxHeight * 0.6f),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
itemsIndexed(lines) { index, line ->
|
||||
if (index == current) {
|
||||
CurrentLine(line, { position }, lineStyle)
|
||||
} else {
|
||||
Text(
|
||||
line.text.ifEmpty { " " },
|
||||
style = lineStyle,
|
||||
fontWeight = if (index == current) FontWeight.Bold else FontWeight.Normal,
|
||||
color = when {
|
||||
!state.result.synced -> MaterialTheme.colorScheme.onSurface
|
||||
index == current -> MaterialTheme.colorScheme.primary
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
color = if (index < current) sung else scheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -438,6 +534,72 @@ internal fun LyricsPanel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The line being sung, in bold and a size up. With word timings, words already sung are in the
|
||||
* accent colour, the one being sung fills in letter by letter, and the rest wait dimmed.
|
||||
*/
|
||||
@Composable
|
||||
private fun CurrentLine(line: app.seb3thehacker.gearslip.media.LyricLine, position: () -> Long, lineStyle: androidx.compose.ui.text.TextStyle) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val style = lineStyle.copy(fontWeight = FontWeight.Bold, fontSize = lineStyle.fontSize * 1.15f)
|
||||
if (line.words.isEmpty()) {
|
||||
Text(line.text.ifEmpty { " " }, style = style, color = scheme.primary)
|
||||
return
|
||||
}
|
||||
val lit = scheme.primary
|
||||
val waiting = scheme.onSurfaceVariant.copy(alpha = 0.7f)
|
||||
val now = position()
|
||||
val text = androidx.compose.ui.text.buildAnnotatedString {
|
||||
line.words.forEachIndexed { i, word ->
|
||||
// The line's first word has no leading space to trim, the last no trailing one.
|
||||
val shown = if (i == line.words.lastIndex) word.text.trimEnd() else word.text
|
||||
val progress = when {
|
||||
now >= word.endMs -> 1f
|
||||
now <= word.startMs -> 0f
|
||||
else -> (now - word.startMs).toFloat() / (word.endMs - word.startMs).coerceAtLeast(1)
|
||||
}
|
||||
val letters = shown.trimEnd().length
|
||||
val filled = progress * letters
|
||||
shown.forEachIndexed { c, ch ->
|
||||
val amount = (filled - c).coerceIn(0f, 1f)
|
||||
withStyle(androidx.compose.ui.text.SpanStyle(color = androidx.compose.ui.graphics.lerp(waiting, lit, amount))) {
|
||||
append(ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(text, style = style)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlainLyrics(result: LyricsResult, lineStyle: androidx.compose.ui.text.TextStyle, modifier: Modifier) {
|
||||
LazyColumn(
|
||||
modifier.fadingEdges(),
|
||||
contentPadding = PaddingValues(vertical = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(result.lines) { line ->
|
||||
Text(line.text.ifEmpty { " " }, style = lineStyle, color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Fades the top and bottom edges out, so a line scrolling past doesn't end in a hard cut. */
|
||||
private fun Modifier.fadingEdges(): Modifier = this
|
||||
.graphicsLayer { compositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen }
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
drawRect(
|
||||
androidx.compose.ui.graphics.Brush.verticalGradient(
|
||||
0f to androidx.compose.ui.graphics.Color.Transparent,
|
||||
0.14f to androidx.compose.ui.graphics.Color.Black,
|
||||
0.86f to androidx.compose.ui.graphics.Color.Black,
|
||||
1f to androidx.compose.ui.graphics.Color.Transparent,
|
||||
),
|
||||
blendMode = androidx.compose.ui.graphics.BlendMode.DstIn,
|
||||
)
|
||||
}
|
||||
|
||||
private sealed interface LyricsState {
|
||||
data object Loading : LyricsState
|
||||
data object Missing : LyricsState
|
||||
@@ -468,29 +630,39 @@ internal fun Notice(title: String, detail: String?, action: Pair<String, () -> U
|
||||
// --- now playing -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The now-playing view on its own, for places with no tabs around it (an app's own
|
||||
* now-playing template): the cover on the left, [NowPlayingControls] on the right.
|
||||
* The now-playing view on its own: an app's own now-playing template, or the Now playing tab on a
|
||||
* small screen. The cover on the left and [NowPlayingControls] on the right where there's room;
|
||||
* narrower, the cover sits above the controls, and only when they still fit under it.
|
||||
*/
|
||||
@Composable
|
||||
internal fun NowPlayingPage(now: NowPlaying, media: CarMedia, modifier: Modifier) {
|
||||
Row(
|
||||
modifier.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
CoverArt(rememberArt(now), Modifier.weight(0.8f).fillMaxHeight())
|
||||
NowPlayingControls(now, media, Modifier.weight(1.2f).fillMaxHeight())
|
||||
internal fun NowPlayingPage(now: NowPlaying, media: CarMedia, modifier: Modifier, padded: Boolean = true) {
|
||||
BoxWithConstraints(if (padded) modifier.padding(horizontal = 24.dp, vertical = 8.dp) else modifier) {
|
||||
val art = rememberArt(now)
|
||||
if (maxWidth >= 520.dp && maxHeight >= 260.dp) {
|
||||
val artWidth = minOf(maxHeight, (maxWidth - 24.dp) * 0.4f)
|
||||
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(24.dp)) {
|
||||
CoverArt(art, Modifier.width(artWidth).fillMaxHeight())
|
||||
NowPlayingControls(now, media, Modifier.weight(1f).fillMaxHeight())
|
||||
}
|
||||
} else {
|
||||
val artHeight = if (maxHeight >= 400.dp) minOf(maxHeight * 0.25f, 160.dp) else 0.dp
|
||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (artHeight > 0.dp) CoverArt(art, Modifier.fillMaxWidth().height(artHeight))
|
||||
NowPlayingControls(now, media, Modifier.weight(1f).fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The cover, as big a square as its space allows, centred in it. */
|
||||
@Composable
|
||||
private fun CoverArt(art: android.graphics.Bitmap?, modifier: Modifier) {
|
||||
BoxWithConstraints(modifier, contentAlignment = Alignment.Center) {
|
||||
private fun CoverArt(art: android.graphics.Bitmap?, modifier: Modifier, alignment: Alignment = Alignment.Center) {
|
||||
BoxWithConstraints(modifier, contentAlignment = alignment) {
|
||||
val shape = RoundedCornerShape(22.dp)
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shadowElevation = 6.dp,
|
||||
modifier = Modifier.size(minOf(maxWidth, maxHeight)),
|
||||
shape = shape,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
modifier = Modifier.size(minOf(maxWidth, maxHeight)).gsPanelShadow(shape),
|
||||
) {
|
||||
if (art != null) {
|
||||
Image(art.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
||||
@@ -504,65 +676,138 @@ private fun CoverArt(art: android.graphics.Bitmap?, modifier: Modifier) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Now playing beside the big cover: the title, artist and seek bar along the top, and the
|
||||
* controls in two rows under them, as big as the height left over allows - the transport, then
|
||||
* like, shuffle, repeat and the app's own buttons.
|
||||
*/
|
||||
@Composable
|
||||
private fun StageControls(now: NowPlaying, media: CarMedia, modifier: Modifier) {
|
||||
BoxWithConstraints(modifier) {
|
||||
// Short of height (a small screen, or Extra large size), the title steps down a size.
|
||||
val compact = maxHeight < 360.dp
|
||||
val showTimes = maxHeight >= 260.dp
|
||||
val titleRoom = maxHeight * 0.4f
|
||||
Column(Modifier.fillMaxSize().padding(start = 4.dp, end = 4.dp, top = if (compact) 6.dp else 16.dp, bottom = 4.dp)) {
|
||||
// The title gives up its height first and scrolls, so the controls keep theirs.
|
||||
Column(Modifier.heightIn(max = titleRoom).verticalScroll(rememberScrollState())) {
|
||||
Text(
|
||||
now.title.ifEmpty { stringResource(R.string.media_nothing_playing) },
|
||||
style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Bold,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
autoSize = TextAutoSize.StepBased(
|
||||
minFontSize = if (compact) 20.sp else 22.sp,
|
||||
maxFontSize = MaterialTheme.typography.displaySmall.fontSize,
|
||||
stepSize = 2.sp,
|
||||
),
|
||||
)
|
||||
Text(
|
||||
listOf(now.artist, now.album).filter { it.isNotEmpty() }.joinToString(" · "),
|
||||
style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
now.error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, maxLines = 2) }
|
||||
if (now.title.isEmpty() && now.error == null) {
|
||||
Text(
|
||||
stringResource(R.string.media_press_play),
|
||||
style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
PlayerProgress(now, media, Modifier.fillMaxWidth().padding(top = if (compact) 4.dp else 12.dp), showTimes = showTimes)
|
||||
// What's left goes to the buttons: play takes about half of it, the row below a third,
|
||||
// and neither grows past what a row of them fits across.
|
||||
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
val gap = (maxHeight * 0.08f).coerceIn(6.dp, 20.dp)
|
||||
val play = minOf(maxHeight * 0.5f, maxWidth / 3.2f, 128.dp).coerceAtLeast(56.dp)
|
||||
val mode = minOf(maxHeight * 0.32f, maxWidth / 6.5f, 80.dp).coerceAtLeast(44.dp)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(gap), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
TransportRow(now, media, skip = play / 1.5f, playScale = 1.5f, modifier = Modifier.fillMaxWidth())
|
||||
PlayModeRow(now, media, size = mode, modifier = Modifier.fillMaxWidth(), maxOther = 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything about the track and every control for it, read top to bottom: title and artist,
|
||||
* the seek bar, the transport, then like, shuffle and repeat as big icon-only buttons.
|
||||
*/
|
||||
@Composable
|
||||
private fun NowPlayingControls(now: NowPlaying, media: CarMedia, modifier: Modifier) {
|
||||
Column(
|
||||
modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp, Alignment.CenterVertically),
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
now.title.ifEmpty { "Nothing playing" },
|
||||
style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
listOf(now.artist, now.album).filter { it.isNotEmpty() }.joinToString(" - "),
|
||||
style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
now.error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, maxLines = 2) }
|
||||
if (now.title.isEmpty() && now.error == null) {
|
||||
BoxWithConstraints(modifier) {
|
||||
val compact = maxHeight < 360.dp || maxWidth < 420.dp
|
||||
val showTimes = maxHeight >= 260.dp
|
||||
Column(
|
||||
Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(if (compact) 8.dp else 14.dp, Alignment.CenterVertically),
|
||||
) {
|
||||
// The title gives up its height first and scrolls, so the controls never leave the screen.
|
||||
Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) {
|
||||
Text(
|
||||
"Press play to pick up where you left off, or find something in Browse.",
|
||||
style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
now.title.ifEmpty { stringResource(R.string.media_nothing_playing) },
|
||||
style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
listOf(now.artist, now.album).filter { it.isNotEmpty() }.joinToString(" - "),
|
||||
style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
now.error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, maxLines = 2) }
|
||||
if (now.title.isEmpty() && now.error == null) {
|
||||
Text(
|
||||
stringResource(R.string.media_press_play),
|
||||
style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
PlayerProgress(now, media, Modifier.fillMaxWidth(), showTimes = showTimes)
|
||||
TransportRow(now, media, skip = if (compact) 48.dp else 60.dp, modifier = Modifier.fillMaxWidth())
|
||||
PlayModeRow(now, media, size = if (compact) 46.dp else 56.dp, modifier = Modifier.fillMaxWidth(), maxOther = 2)
|
||||
}
|
||||
PlayerProgress(now, media, Modifier.fillMaxWidth())
|
||||
TransportRow(now, media, skip = 60.dp, modifier = Modifier.fillMaxWidth())
|
||||
PlayModeRow(now, media, size = 56.dp, modifier = Modifier.fillMaxWidth(), maxOther = 2)
|
||||
}
|
||||
}
|
||||
|
||||
/** Below these, the media app drops to one column: see [MediaScreen]. */
|
||||
private val NARROW_WIDTH = 600.dp
|
||||
private val NARROW_HEIGHT = 300.dp
|
||||
|
||||
/**
|
||||
* The player beside the browse list, the queue and the lyrics: no art, which the list needs the
|
||||
* room for, just what's playing, the seek bar and the controls.
|
||||
* The player beside the browse list, the queue and the lyrics: the cover as big as the controls
|
||||
* leave room for, with the title and artist beside it rather than under it, so the cover gets the
|
||||
* height; then the seek bar and the controls along the bottom.
|
||||
*/
|
||||
@Composable
|
||||
private fun CompactPlayer(now: NowPlaying, media: CarMedia, modifier: Modifier) {
|
||||
Column(
|
||||
modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
now.title.ifEmpty { "Nothing playing" },
|
||||
style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
now.artist,
|
||||
style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
private fun CompactPlayer(now: NowPlaying, media: CarMedia, art: android.graphics.Bitmap?, modifier: Modifier) {
|
||||
Column(modifier) {
|
||||
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
|
||||
val cover = minOf(maxHeight, maxWidth * 0.62f).coerceAtLeast(0.dp)
|
||||
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.Bottom) {
|
||||
CoverArt(art, Modifier.size(cover))
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column(Modifier.weight(1f).padding(bottom = 4.dp)) {
|
||||
Text(
|
||||
now.title.ifEmpty { stringResource(R.string.media_nothing_playing) },
|
||||
style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold,
|
||||
maxLines = 3, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
now.artist,
|
||||
style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
PlayerProgress(now, media, Modifier.fillMaxWidth())
|
||||
TransportRow(now, media, skip = 52.dp, modifier = Modifier.fillMaxWidth())
|
||||
Spacer(Modifier.height(6.dp))
|
||||
TransportRow(now, media, skip = 54.dp, modifier = Modifier.fillMaxWidth())
|
||||
Spacer(Modifier.height(10.dp))
|
||||
PlayModeRow(now, media, size = 48.dp, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import android.os.SystemClock
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
@@ -83,9 +85,10 @@ internal fun ReplyGlyph(modifier: Modifier = Modifier) =
|
||||
Icon(ImageVector.vectorResource(R.drawable.voice_selection_24), null, modifier)
|
||||
|
||||
/** "Alex", or "Alex · 3 new" when a burst of messages from one chat has been merged. */
|
||||
@Composable
|
||||
private fun CarNotification.heading(): String {
|
||||
val who = title.ifBlank { appLabel }
|
||||
return if (burst > 1) "$who · $burst new" else who
|
||||
return if (burst > 1) pluralStringResource(R.plurals.msg_burst, burst, who, burst) else who
|
||||
}
|
||||
|
||||
/** The card. It shows the voice reply too, wherever that was started from. */
|
||||
@@ -106,17 +109,17 @@ fun NotificationPopup(showNew: Boolean, modifier: Modifier = Modifier) {
|
||||
Text(n.heading(), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(n.text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
GsIconButton(Icons.Filled.Close, "Dismiss", { CarNotifications.dismissPopup(n.id) }, size = 40.dp, iconSize = 20.dp)
|
||||
GsIconButton(Icons.Filled.Close, stringResource(R.string.msg_dismiss), { CarNotifications.dismissPopup(n.id) }, size = 40.dp, iconSize = 20.dp)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
GsButton(onClick = { VoiceReply.read(n) }, tone = GsTone.Neutral, modifier = Modifier.weight(1f)) {
|
||||
ReadGlyph(Modifier.size(20.dp))
|
||||
Text("Read", Modifier.padding(start = 8.dp))
|
||||
Text(stringResource(R.string.msg_read), Modifier.padding(start = 8.dp))
|
||||
}
|
||||
if (n.reply != null) {
|
||||
GsButton(onClick = { VoiceReply.reply(n) }, modifier = Modifier.weight(1f)) {
|
||||
ReplyGlyph(Modifier.size(20.dp))
|
||||
Text("Reply", Modifier.padding(start = 8.dp))
|
||||
Text(stringResource(R.string.crumb_reply), Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -191,36 +194,36 @@ private fun VoiceReplyBody(s: VoiceReply.State, n: CarNotification) {
|
||||
when (s.phase) {
|
||||
VoiceReply.Phase.READING, VoiceReply.Phase.READ -> {
|
||||
GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) {
|
||||
Text(if (s.phase == VoiceReply.Phase.READING) "Stop" else "Close")
|
||||
Text(if (s.phase == VoiceReply.Phase.READING) stringResource(R.string.msg_stop) else stringResource(R.string.close))
|
||||
}
|
||||
if (n.reply != null) {
|
||||
GsButton(onClick = { VoiceReply.reply(n, readFirst = false) }, modifier = wide) {
|
||||
ReplyGlyph(Modifier.size(20.dp))
|
||||
Text("Reply", Modifier.padding(start = 8.dp))
|
||||
Text(stringResource(R.string.crumb_reply), Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
VoiceReply.Phase.OFFERING -> {
|
||||
GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text("No") }
|
||||
GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text(stringResource(R.string.msg_no)) }
|
||||
GsButton(onClick = { VoiceReply.reply(n, readFirst = false) }, modifier = wide) {
|
||||
ReplyGlyph(Modifier.size(20.dp))
|
||||
Text("Reply", Modifier.padding(start = 8.dp))
|
||||
Text(stringResource(R.string.crumb_reply), Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
VoiceReply.Phase.ASKING, VoiceReply.Phase.LISTENING ->
|
||||
GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text("Cancel") }
|
||||
GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text(stringResource(R.string.cancel)) }
|
||||
VoiceReply.Phase.CONFIRMING -> {
|
||||
GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text("Cancel") }
|
||||
GsButton(onClick = VoiceReply::sendNow, modifier = wide) { Text("Send now") }
|
||||
GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text(stringResource(R.string.cancel)) }
|
||||
GsButton(onClick = VoiceReply::sendNow, modifier = wide) { Text(stringResource(R.string.msg_send_now)) }
|
||||
}
|
||||
VoiceReply.Phase.ERROR -> {
|
||||
GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text("Close") }
|
||||
GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text(stringResource(R.string.close)) }
|
||||
if (n.reply != null) {
|
||||
GsButton(onClick = { VoiceReply.reply(n, readFirst = false) }, tone = GsTone.Tonal, modifier = wide) { Text("Try again") }
|
||||
GsButton(onClick = { VoiceReply.reply(n, readFirst = false) }, tone = GsTone.Tonal, modifier = wide) { Text(stringResource(R.string.update_try_again)) }
|
||||
GsButton(
|
||||
onClick = { VoiceReply.cancel(); navigator.notifications(replyTo = n.key) },
|
||||
tone = GsTone.Neutral, modifier = wide,
|
||||
) { Text("Type") }
|
||||
) { Text(stringResource(R.string.msg_type)) }
|
||||
}
|
||||
}
|
||||
VoiceReply.Phase.SENT, VoiceReply.Phase.IDLE -> Unit
|
||||
@@ -228,13 +231,14 @@ private fun VoiceReplyBody(s: VoiceReply.State, n: CarNotification) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun voiceHeading(s: VoiceReply.State, who: String): String = when (s.phase) {
|
||||
VoiceReply.Phase.READING, VoiceReply.Phase.READ -> who
|
||||
VoiceReply.Phase.OFFERING, VoiceReply.Phase.ASKING -> "Reply to $who"
|
||||
VoiceReply.Phase.LISTENING -> "Listening"
|
||||
VoiceReply.Phase.CONFIRMING -> "Sending to $who"
|
||||
VoiceReply.Phase.SENT -> "Sent to $who"
|
||||
VoiceReply.Phase.ERROR -> "Couldn't reply"
|
||||
VoiceReply.Phase.OFFERING, VoiceReply.Phase.ASKING -> stringResource(R.string.msg_reply_to, who)
|
||||
VoiceReply.Phase.LISTENING -> stringResource(R.string.msg_listening)
|
||||
VoiceReply.Phase.CONFIRMING -> stringResource(R.string.msg_sending_to, who)
|
||||
VoiceReply.Phase.SENT -> stringResource(R.string.msg_sent_to, who)
|
||||
VoiceReply.Phase.ERROR -> stringResource(R.string.msg_reply_failed)
|
||||
VoiceReply.Phase.IDLE -> ""
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import android.content.Intent
|
||||
import android.provider.Settings
|
||||
@@ -8,6 +11,8 @@ import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -60,14 +65,14 @@ fun MessagesScreen(packageName: String) {
|
||||
val navigator = LocalCarNavigator.current
|
||||
val context = LocalContext.current.applicationContext
|
||||
val entry = remember(packageName) { findEntry(MESSAGING_PREFIX + packageName) }
|
||||
val label = entry?.label ?: history.firstOrNull { it.packageName == packageName }?.appLabel ?: "Messages"
|
||||
val label = entry?.label ?: history.firstOrNull { it.packageName == packageName }?.appLabel ?: stringResource(R.string.msg_messages)
|
||||
val threads = history.filter { it.packageName == packageName }
|
||||
|
||||
LaunchedEffect(history) { CarNotifications.dismissPopupFrom(packageName) }
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Column(Modifier.fillMaxSize().padding(start = 16.dp, end = 16.dp, top = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", navigator::back)
|
||||
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back), navigator::back)
|
||||
entry?.icon?.let { Image(it, contentDescription = null, modifier = Modifier.size(36.dp).clip(RoundedCornerShape(8.dp))) }
|
||||
Text(label, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
@@ -82,7 +87,7 @@ fun MessagesScreen(packageName: String) {
|
||||
|
||||
if (threads.isEmpty()) {
|
||||
Text(
|
||||
"No recent messages. New $label messages appear here as they arrive on the phone.",
|
||||
stringResource(R.string.msg_none_recent, label),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -104,6 +109,7 @@ fun MessagesScreen(packageName: String) {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun ConversationCard(n: CarNotification, onRead: () -> Unit, onReply: () -> Unit, onMarkRead: () -> Unit) {
|
||||
var expanded by rememberSaveable(n.key) { mutableStateOf(false) }
|
||||
@@ -143,40 +149,48 @@ private fun ConversationCard(n: CarNotification, onRead: () -> Unit, onReply: ()
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
if (n.replied) {
|
||||
Icon(
|
||||
Icons.Filled.Check,
|
||||
contentDescription = stringResource(R.string.notif_replied),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 8.dp).size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!expanded && lines.size > COLLAPSED_LINES) {
|
||||
Text(
|
||||
"${lines.size - COLLAPSED_LINES} earlier",
|
||||
pluralStringResource(R.plurals.msg_earlier, lines.size - COLLAPSED_LINES, lines.size - COLLAPSED_LINES),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
shown.forEach { MessageLine(it, showSender = n.isGroup) }
|
||||
// Below the messages and side by side, so the text keeps the card's whole width.
|
||||
FlowRow(
|
||||
Modifier.padding(top = 6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
GsButton(onClick = onRead, tone = GsTone.Neutral) {
|
||||
ReadGlyph(Modifier.size(20.dp))
|
||||
Text(stringResource(R.string.msg_read), Modifier.padding(start = 8.dp))
|
||||
}
|
||||
if (n.reply != null) {
|
||||
GsButton(onClick = onReply, tone = GsTone.Tonal) {
|
||||
ReplyGlyph(Modifier.size(20.dp))
|
||||
Text(stringResource(R.string.crumb_reply), Modifier.padding(start = 8.dp))
|
||||
}
|
||||
GsButton(onClick = { quick = !quick }, tone = GsTone.Neutral) {
|
||||
Text(stringResource(if (quick) R.string.msg_hide_quick else R.string.msg_quick))
|
||||
}
|
||||
}
|
||||
if (n.markRead != null) {
|
||||
GsButton(onClick = onMarkRead, tone = GsTone.Neutral) { Text(stringResource(R.string.msg_mark_read)) }
|
||||
}
|
||||
}
|
||||
if (quick) QuickReplies(n, Modifier.padding(top = 6.dp))
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (n.replied) {
|
||||
Icon(Icons.Filled.Check, contentDescription = "Replied", tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
GsButton(onClick = onRead, tone = GsTone.Neutral) {
|
||||
ReadGlyph(Modifier.size(20.dp))
|
||||
Text("Read", Modifier.padding(start = 8.dp))
|
||||
}
|
||||
if (n.reply != null) {
|
||||
GsButton(onClick = onReply, tone = GsTone.Tonal) {
|
||||
ReplyGlyph(Modifier.size(20.dp))
|
||||
Text("Reply", Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
if (n.reply != null) {
|
||||
GsButton(onClick = { quick = !quick }, tone = GsTone.Neutral) {
|
||||
Text(if (quick) "Hide quick replies" else "Quick reply")
|
||||
}
|
||||
}
|
||||
if (n.markRead != null) {
|
||||
GsButton(onClick = onMarkRead, tone = GsTone.Neutral) { Text("Mark read") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -184,8 +198,8 @@ private fun ConversationCard(n: CarNotification, onRead: () -> Unit, onReply: ()
|
||||
@Composable
|
||||
private fun MessageLine(line: ChatLine, showSender: Boolean) {
|
||||
val who = when {
|
||||
line.sender == null -> "You: "
|
||||
showSender -> "${line.sender}: "
|
||||
line.sender == null -> stringResource(R.string.msg_you)
|
||||
showSender -> stringResource(R.string.msg_sender, line.sender)
|
||||
else -> ""
|
||||
}
|
||||
Text(
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -16,6 +19,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.List
|
||||
import androidx.compose.material.icons.filled.Call
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
@@ -65,26 +69,49 @@ private val CarGlyph: ImageVector = ImageVector.Builder("Car", 24.dp, 24.dp, 24f
|
||||
*/
|
||||
internal class BuiltInApp(
|
||||
val id: String,
|
||||
/** English, for matching what a driver says; [labelRes] is what the screen shows. */
|
||||
val label: String,
|
||||
@param:StringRes val labelRes: Int,
|
||||
val glyph: ImageVector,
|
||||
val screen: CarScreen?,
|
||||
val open: (CarNavigator) -> Unit,
|
||||
)
|
||||
|
||||
internal object BuiltInApps {
|
||||
val Web = BuiltInApp("web", "Browser", Icons.Filled.Search, CarScreen.App("web")) { it.open("web") }
|
||||
val ScreenSharing = BuiltInApp("phone", "Screen sharing", Icons.Filled.Share, CarScreen.App("phone")) { it.open("phone") }
|
||||
val Phone = BuiltInApp("dialer", "Phone", Icons.Filled.Call, CarScreen.App("dialer")) { it.open("dialer") }
|
||||
val VehicleData = BuiltInApp("vehicle", "Vehicle data", Icons.Filled.Info, CarScreen.VehicleData) { it.vehicleData() }
|
||||
val Settings = BuiltInApp("settings", "Settings", Icons.Filled.Settings, CarScreen.Settings) { it.settings() }
|
||||
val Web = BuiltInApp("web", "Browser", R.string.app_browser, Icons.Filled.Search, CarScreen.App("web")) { it.open("web") }
|
||||
val ScreenSharing = BuiltInApp("phone", "Screen sharing", R.string.app_screen_sharing, Icons.Filled.Share, CarScreen.App("phone")) { it.open("phone") }
|
||||
val Phone = BuiltInApp("dialer", "Phone", R.string.carset_phone, Icons.Filled.Call, CarScreen.App("dialer")) { it.open("dialer") }
|
||||
val VehicleData = BuiltInApp("vehicle", "Vehicle data", R.string.car_vehicle_data, Icons.Filled.Info, CarScreen.VehicleData) { it.vehicleData() }
|
||||
val Settings = BuiltInApp("settings", "Settings", R.string.settings_title, Icons.Filled.Settings, CarScreen.Settings) { it.settings() }
|
||||
|
||||
/** Launcher mode only: every app on the device, opened full screen. */
|
||||
val AllApps = BuiltInApp("allapps", "All apps", R.string.app_all_apps, Icons.Filled.List, CarScreen.App("allapps")) { it.open("allapps") }
|
||||
|
||||
/** Hands the screen back to the car's own interface, like Android Auto's Exit. */
|
||||
val Exit = BuiltInApp("exit", "Exit", CarGlyph, screen = null) { CarFocus.exitToCar() }
|
||||
val Exit = BuiltInApp("exit", "Exit", R.string.app_exit, CarGlyph, screen = null) { CarFocus.exitToCar() }
|
||||
|
||||
val all = listOf(Web, ScreenSharing, Phone, VehicleData, Settings, Exit)
|
||||
val entries: List<Entry> = all.map { Entry(it.label, icon = null, builtIn = it) }
|
||||
val all = listOf(Web, ScreenSharing, Phone, VehicleData, Settings, Exit, AllApps)
|
||||
private val byApp = all.associateWith { Entry(it.label, icon = null, builtIn = it) }
|
||||
val everyEntry: List<Entry> = byApp.values.toList()
|
||||
|
||||
/**
|
||||
* The ones that make sense where the car UI is showing. As a device's own home screen there
|
||||
* is no car to share the screen with or exit to, and no phone line on most head units.
|
||||
*/
|
||||
fun entries(launcher: Boolean, canCall: Boolean): List<Entry> = byApp.filterKeys { app ->
|
||||
when (app) {
|
||||
AllApps -> launcher
|
||||
ScreenSharing, Exit -> !launcher
|
||||
Phone -> !launcher || canCall
|
||||
else -> true
|
||||
}
|
||||
}.values.toList()
|
||||
}
|
||||
|
||||
/** The name a driver sees: translated for Gearslip's own apps, the app's own name otherwise. */
|
||||
@Composable
|
||||
internal fun Entry.shownLabel(): String = builtIn?.let { stringResource(it.labelRes) } ?: label
|
||||
|
||||
/** Vehicle data is experimental: raw, unverified sensor labels (see [CarSensors]). */
|
||||
@Composable
|
||||
internal fun rememberVehicleDataShown(): Boolean {
|
||||
@@ -215,7 +242,7 @@ internal fun AppMenu(navigator: CarNavigator, running: Map<String, RunningSlot>)
|
||||
entry.icon?.let { Image(it, null, Modifier.size(44.dp).clip(RoundedCornerShape(10.dp))) }
|
||||
entry.builtIn?.let { Icon(it.glyph, null, Modifier.size(36.dp)) }
|
||||
Text(
|
||||
entry.label,
|
||||
entry.shownLabel(),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(start = 14.dp),
|
||||
@@ -232,9 +259,9 @@ internal fun AppMenu(navigator: CarNavigator, running: Map<String, RunningSlot>)
|
||||
) {
|
||||
Text(
|
||||
when {
|
||||
isPinned -> "Unpin from nav bar"
|
||||
barFull -> "Nav bar is full (${CarSettings.MAX_PINNED} pinned)"
|
||||
else -> "Pin to nav bar"
|
||||
isPinned -> stringResource(R.string.navapps_unpin)
|
||||
barFull -> stringResource(R.string.navapps_full, CarSettings.MAX_PINNED)
|
||||
else -> stringResource(R.string.navapps_pin)
|
||||
},
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
@@ -248,13 +275,13 @@ internal fun AppMenu(navigator: CarNavigator, running: Map<String, RunningSlot>)
|
||||
},
|
||||
tone = GsTone.Danger,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) { Text("Close app", style = MaterialTheme.typography.titleMedium) }
|
||||
) { Text(stringResource(R.string.navapps_close), style = MaterialTheme.typography.titleMedium) }
|
||||
}
|
||||
GsButton(
|
||||
onClick = navigator::dismissAppMenu,
|
||||
tone = GsTone.Neutral,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) { Text("Cancel", style = MaterialTheme.typography.titleMedium) }
|
||||
) { Text(stringResource(R.string.cancel), style = MaterialTheme.typography.titleMedium) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -267,7 +294,8 @@ internal fun AppMenu(navigator: CarNavigator, running: Map<String, RunningSlot>)
|
||||
@Composable
|
||||
private fun MoveButtons(id: String) {
|
||||
val custom by CarSettings.appOrder.collectAsState()
|
||||
val order = launcherOrder(rememberAllEntries(), rememberVehicleDataShown(), custom).mapNotNull { it.componentId }
|
||||
val hidden by CarSettings.hiddenApps.collectAsState()
|
||||
val order = launcherOrder(rememberAllEntries(), rememberVehicleDataShown(), custom, hidden).mapNotNull { it.componentId }
|
||||
val index = order.indexOf(id)
|
||||
if (index < 0) return
|
||||
fun move(by: Int) {
|
||||
@@ -281,19 +309,19 @@ private fun MoveButtons(id: String) {
|
||||
tone = GsTone.Tonal,
|
||||
enabled = index > 0,
|
||||
modifier = Modifier.weight(1f).height(56.dp),
|
||||
) { Text("Move left", style = MaterialTheme.typography.titleMedium) }
|
||||
) { Text(stringResource(R.string.navapps_move_left), style = MaterialTheme.typography.titleMedium) }
|
||||
GsButton(
|
||||
onClick = { move(1) },
|
||||
tone = GsTone.Tonal,
|
||||
enabled = index < order.lastIndex,
|
||||
modifier = Modifier.weight(1f).height(56.dp),
|
||||
) { Text("Move right", style = MaterialTheme.typography.titleMedium) }
|
||||
) { Text(stringResource(R.string.navapps_move_right), style = MaterialTheme.typography.titleMedium) }
|
||||
}
|
||||
if (custom.isNotEmpty()) {
|
||||
GsButton(
|
||||
onClick = { CarSettings.setAppOrder(emptyList()) },
|
||||
tone = GsTone.Neutral,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) { Text("Reset app order", style = MaterialTheme.typography.titleMedium) }
|
||||
) { Text(stringResource(R.string.navapps_reset), style = MaterialTheme.typography.titleMedium) }
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import android.content.Intent
|
||||
import android.provider.Settings
|
||||
@@ -79,12 +81,12 @@ fun NotificationsSection() {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"Notifications",
|
||||
stringResource(R.string.notif_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (history.isNotEmpty()) TextButton(onClick = CarNotifications::clear) { Text("Clear all") }
|
||||
if (history.isNotEmpty()) TextButton(onClick = CarNotifications::clear) { Text(stringResource(R.string.notif_clear_all)) }
|
||||
}
|
||||
|
||||
if (!listening) {
|
||||
@@ -97,7 +99,7 @@ fun NotificationsSection() {
|
||||
|
||||
if (history.isEmpty()) {
|
||||
Text(
|
||||
"Nothing yet. New notifications from the phone will show up here.",
|
||||
stringResource(R.string.notif_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -124,11 +126,11 @@ internal fun AccessBanner(onOpen: () -> Unit) {
|
||||
) {
|
||||
Icon(Icons.Filled.Warning, contentDescription = null)
|
||||
Text(
|
||||
"Notifications are off. Allow Gearslip under Settings > Notifications > Notification access on the phone.",
|
||||
stringResource(R.string.notif_off),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
GsButton(onClick = onOpen) { Text("Open") }
|
||||
GsButton(onClick = onOpen) { Text(stringResource(R.string.guide_open)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -160,10 +162,10 @@ private fun NotificationRow(n: CarNotification, onReply: (CarNotification) -> Un
|
||||
}
|
||||
}
|
||||
when {
|
||||
n.replied -> Icon(Icons.Filled.Check, contentDescription = "Replied", tint = MaterialTheme.colorScheme.primary)
|
||||
n.replied -> Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.notif_replied), tint = MaterialTheme.colorScheme.primary)
|
||||
n.reply != null -> GsButton(onClick = { VoiceReply.reply(n) }, tone = GsTone.Tonal) {
|
||||
ReplyGlyph(Modifier.size(20.dp))
|
||||
Text("Reply", Modifier.padding(start = 8.dp))
|
||||
Text(stringResource(R.string.crumb_reply), Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -198,14 +200,14 @@ private fun ReplyPane(n: CarNotification, onDone: () -> Unit) {
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", onDone)
|
||||
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back), onDone)
|
||||
Surface(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(
|
||||
if (text.isEmpty()) "Reply to ${n.title.ifBlank { n.appLabel }}" else "$text|",
|
||||
if (text.isEmpty()) stringResource(R.string.msg_reply_to, n.title.ifBlank { n.appLabel }) else "$text|",
|
||||
color = if (text.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -52,8 +54,8 @@ fun PhoneAppsScreen() {
|
||||
value = PhoneApps.cached() ?: withContext(Dispatchers.IO) { PhoneApps.installed(context).also { PhoneApps.warm(context) } }
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 12.dp)) {
|
||||
ScreenHeader("Screen sharing")
|
||||
Column(Modifier.fillMaxSize().padding(start = 20.dp, end = 20.dp, top = 12.dp)) {
|
||||
ScreenHeader(stringResource(R.string.app_screen_sharing))
|
||||
Spacer(Modifier.height(12.dp))
|
||||
if (!TouchRelayService.enabled) {
|
||||
TouchRelayWarning()
|
||||
@@ -63,7 +65,7 @@ fun PhoneAppsScreen() {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 108.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 8.dp),
|
||||
contentPadding = PaddingValues(bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
@@ -94,17 +96,17 @@ private fun TouchRelayWarning() {
|
||||
) {
|
||||
Icon(Icons.Filled.Warning, contentDescription = null)
|
||||
Text(
|
||||
"Touch is off. Enable Gearslip under Settings > Accessibility on the phone.",
|
||||
stringResource(R.string.phoneapps_touch_off),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
GsButton(onClick = { PhoneApps.openAccessibilitySettings(context) }) { Text("Open") }
|
||||
GsButton(onClick = { PhoneApps.openAccessibilitySettings(context) }) { Text(stringResource(R.string.guide_open)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PhoneAppTile(app: PhoneApp, onClick: () -> Unit) {
|
||||
internal fun PhoneAppTile(app: PhoneApp, onClick: () -> Unit) {
|
||||
GsIconBox(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.height(120.dp),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
@@ -12,6 +14,7 @@ import app.seb3thehacker.gearslip.GearslipLog
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -136,13 +139,13 @@ fun PhoneDialerScreen() {
|
||||
var hasPermissions by remember { mutableStateOf(hasCallPermissions(context)) }
|
||||
var tab by remember { mutableStateOf(TAB_CONTACTS) }
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Column(Modifier.fillMaxSize().padding(start = 16.dp, end = 16.dp, top = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
// The tabs share the title's row, so the list and keypad get the height back.
|
||||
ScreenHeader("Phone") {
|
||||
ScreenHeader(stringResource(R.string.carset_phone)) {
|
||||
if (hasPermissions) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
DialerTab("Contacts", tab == TAB_CONTACTS, Modifier.width(140.dp)) { tab = TAB_CONTACTS }
|
||||
DialerTab("Keypad", tab == TAB_KEYPAD, Modifier.width(140.dp)) { tab = TAB_KEYPAD }
|
||||
DialerTab(stringResource(R.string.dialer_contacts), tab == TAB_CONTACTS, Modifier.width(140.dp)) { tab = TAB_CONTACTS }
|
||||
DialerTab(stringResource(R.string.dialer_keypad), tab == TAB_KEYPAD, Modifier.width(140.dp)) { tab = TAB_KEYPAD }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,7 +156,7 @@ fun PhoneDialerScreen() {
|
||||
}
|
||||
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||
if (tab == TAB_CONTACTS) ContactsList(context) else Keypad(context)
|
||||
if (tab == TAB_CONTACTS) ContactsList(context) else Box(Modifier.padding(bottom = 16.dp)) { Keypad(context) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -168,14 +171,13 @@ private fun MissingPermission(context: Context, onGranted: () -> Unit) {
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
"Gearslip needs the Phone and Contacts permissions to dial from here. Allow them on " +
|
||||
"the phone when you're parked.",
|
||||
stringResource(R.string.dialer_needs_permission),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
GsButton(tone = GsTone.Tonal, onClick = {
|
||||
app.seb3thehacker.gearslip.PermissionActivity.ask(context, CALL_PERMISSIONS)
|
||||
}) { Text("Ask on the phone") }
|
||||
}) { Text(stringResource(R.string.dialer_ask)) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,7 +213,7 @@ private fun Keypad(context: Context) {
|
||||
|
||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
number.ifEmpty { "Enter a number" },
|
||||
number.ifEmpty { stringResource(R.string.dialer_enter_number) },
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = if (number.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
@@ -240,7 +242,7 @@ private fun Keypad(context: Context) {
|
||||
colors = gsColors(GsTone.Primary),
|
||||
enabled = number.isNotEmpty(),
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
) { Icon(Icons.Filled.Call, "Call") }
|
||||
) { Icon(Icons.Filled.Call, stringResource(R.string.dialer_call)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -282,16 +284,16 @@ private fun ContactsList(context: Context) {
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||
when {
|
||||
contacts.isEmpty() -> Text(
|
||||
"No contacts with a phone number found.",
|
||||
stringResource(R.string.dialer_no_contacts),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
filtered.isEmpty() -> Text(
|
||||
"No contacts match \"$query\".",
|
||||
stringResource(R.string.dialer_no_match, query),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
else -> LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
else -> LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(filtered) { contact ->
|
||||
ContactRow(contact) { pendingCall = contact }
|
||||
}
|
||||
@@ -344,14 +346,14 @@ private fun ConfirmCallOverlay(contact: Contact, onConfirm: () -> Unit, onDismis
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text("Call ${contact.name}?", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||
Text(stringResource(R.string.dialer_call_who, contact.name), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||
Text(contact.number, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
GsButton(onClick = onDismiss, tone = GsTone.Outline, modifier = Modifier.weight(1f).height(56.dp)) {
|
||||
Text("Cancel", style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.cancel), style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
GsButton(onClick = onConfirm, modifier = Modifier.weight(1f).height(56.dp)) {
|
||||
Text("Call", style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.dialer_call), style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -368,7 +370,7 @@ private fun ContactSearchField(query: String, active: Boolean, onToggle: () -> U
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
query.ifEmpty { "Search contacts" },
|
||||
query.ifEmpty { stringResource(R.string.dialer_search) },
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (query.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
@@ -406,7 +408,7 @@ private fun ContactRow(contact: Contact, onClick: () -> Unit) {
|
||||
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)
|
||||
Icon(Icons.Filled.Call, contentDescription = stringResource(R.string.dialer_call), tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import android.media.session.PlaybackState
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -94,23 +96,23 @@ internal fun formatDuration(ms: Long): String {
|
||||
return if (hours > 0) "%d:%02d:%02d".format(hours, minutes, seconds) else "%d:%02d".format(minutes, seconds)
|
||||
}
|
||||
|
||||
/** Previous, play/pause, next. [skip] sizes the outer two; play is a step bigger. */
|
||||
/** Previous, play/pause, next. [skip] sizes the outer two; play is [playScale] times bigger. */
|
||||
@Composable
|
||||
internal fun TransportRow(now: NowPlaying, media: CarMedia, skip: Dp, modifier: Modifier = Modifier) {
|
||||
val play = skip * 1.25f
|
||||
internal fun TransportRow(now: NowPlaying, media: CarMedia, skip: Dp, modifier: Modifier = Modifier, playScale: Float = 1.25f) {
|
||||
val play = skip * playScale
|
||||
Row(modifier, horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically) {
|
||||
GsIconButton(
|
||||
MediaIcons.Previous, "Previous", media::previous,
|
||||
MediaIcons.Previous, stringResource(R.string.player_previous), media::previous,
|
||||
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), size = skip, iconSize = skip * 0.58f, shape = CircleShape,
|
||||
)
|
||||
GsIconButton(
|
||||
if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow,
|
||||
if (now.playing) "Pause" else "Play",
|
||||
stringResource(if (now.playing) R.string.player_pause else R.string.media_play),
|
||||
media::togglePlay,
|
||||
tone = GsTone.Primary, size = play, iconSize = play * 0.58f, shape = CircleShape,
|
||||
)
|
||||
GsIconButton(
|
||||
MediaIcons.Next, "Next", media::next,
|
||||
MediaIcons.Next, stringResource(R.string.player_next), media::next,
|
||||
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_NEXT), size = skip, iconSize = skip * 0.58f, shape = CircleShape,
|
||||
)
|
||||
}
|
||||
@@ -143,11 +145,11 @@ internal fun PlayModeRow(
|
||||
// Gearslip's own stand in for apps that report a heart rating or shuffle and repeat modes.
|
||||
val likeAction = now.likeAction
|
||||
if (likeAction != null) {
|
||||
CustomActionButton(likeAction, media, size, toggle = true)
|
||||
CustomActionButton(likeAction, media, size, on = liked == true)
|
||||
} else {
|
||||
GsIconButton(
|
||||
if (liked == true) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
|
||||
if (liked == true) "Unlike" else "Like",
|
||||
stringResource(if (liked == true) R.string.player_unlike else R.string.player_like),
|
||||
media::toggleLike,
|
||||
enabled = liked != null,
|
||||
tone = if (liked == true) GsTone.Primary else GsTone.Neutral,
|
||||
@@ -156,10 +158,10 @@ internal fun PlayModeRow(
|
||||
}
|
||||
val shuffleAction = now.shuffleAction
|
||||
if (shuffleAction != null) {
|
||||
CustomActionButton(shuffleAction, media, size, toggle = true)
|
||||
CustomActionButton(shuffleAction, media, size, on = shuffleAction.on)
|
||||
} else {
|
||||
GsIconButton(
|
||||
MediaIcons.Shuffle, if (shuffleOn) "Shuffle off" else "Shuffle on",
|
||||
MediaIcons.Shuffle, stringResource(if (shuffleOn) R.string.player_shuffle_off else R.string.player_shuffle_on),
|
||||
media::toggleShuffle,
|
||||
enabled = shuffleKnown,
|
||||
tone = if (shuffleOn) GsTone.Primary else GsTone.Neutral,
|
||||
@@ -168,11 +170,11 @@ internal fun PlayModeRow(
|
||||
}
|
||||
val repeatAction = now.repeatAction
|
||||
if (repeatAction != null) {
|
||||
CustomActionButton(repeatAction, media, size, toggle = true)
|
||||
CustomActionButton(repeatAction, media, size, on = repeatAction.on)
|
||||
} else {
|
||||
GsIconButton(
|
||||
if (modes.repeat == PlaybackStateCompat.REPEAT_MODE_ONE) MediaIcons.RepeatOne else MediaIcons.Repeat,
|
||||
"Repeat",
|
||||
stringResource(R.string.player_repeat),
|
||||
media::cycleRepeat,
|
||||
enabled = repeatKnown,
|
||||
tone = if (repeatOn) GsTone.Primary else GsTone.Neutral,
|
||||
@@ -189,12 +191,12 @@ internal fun PlayModeRow(
|
||||
* not Gearslip's, so it's loaded from there. Its name stands in if the icon won't load.
|
||||
*/
|
||||
@Composable
|
||||
private fun CustomActionButton(action: CustomAction, media: CarMedia, size: Dp, toggle: Boolean = false) {
|
||||
private fun CustomActionButton(action: CustomAction, media: CarMedia, size: Dp, on: Boolean = false) {
|
||||
val icon = rememberActionIcon(action)
|
||||
GsIconBox(
|
||||
onClick = { media.custom(action) },
|
||||
modifier = Modifier.size(size),
|
||||
colors = gsColors(if (toggle && action.on) GsTone.Primary else GsTone.Neutral),
|
||||
colors = gsColors(if (on) GsTone.Primary else GsTone.Neutral),
|
||||
) {
|
||||
if (icon != null) {
|
||||
Icon(icon, action.name, Modifier.size(size * 0.52f))
|
||||
@@ -229,26 +231,48 @@ internal fun rememberArt(now: NowPlaying): android.graphics.Bitmap? {
|
||||
}
|
||||
|
||||
/**
|
||||
* A background taken from the album art's main colour, in place of [fallback]. Only the hue and a
|
||||
* capped amount of its saturation come from the art; the lightness stays close to the theme's
|
||||
* own ([darkLightness] or [lightLightness]), so text drawn in the theme's colours reads the same
|
||||
* on any cover. Art with no real colour in it keeps [fallback]. Fades between songs rather than
|
||||
* jumping.
|
||||
* Three soft pools of the cover's colours: top left, top right and the middle, read off a 3x3
|
||||
* shrink of it. Still, and redrawn only while a new song's colours fade in, so it costs the video
|
||||
* stream nothing between songs. Fainter on a light card, where colour shows more. [wide] is for
|
||||
* a whole screen: the pools gather on its left, behind the cover, and reach less far across it.
|
||||
*/
|
||||
@Composable
|
||||
internal fun rememberArtColor(
|
||||
art: android.graphics.Bitmap?,
|
||||
fallback: Color,
|
||||
darkLightness: Float = 0.2f,
|
||||
lightLightness: Float = 0.86f,
|
||||
): Color {
|
||||
val dark = fallback.luminance() < 0.5f
|
||||
val tint by produceState<ArtColor.Tint?>(null, art) {
|
||||
value = art?.let { withContext(Dispatchers.Default) { runCatching { ArtColor.of(it) }.getOrNull() } }
|
||||
internal fun AlbumGlow(art: android.graphics.Bitmap?, modifier: Modifier, wide: Boolean = false) {
|
||||
val spots by produceState<List<Color>>(emptyList(), art) {
|
||||
value = art?.let { kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Default) { glowColours(it) } }.orEmpty()
|
||||
}
|
||||
val strength = if (MaterialTheme.colorScheme.surface.luminance() < 0.5f) 0.30f else 0.18f
|
||||
val fade = tween<Color>(600)
|
||||
val first by androidx.compose.animation.animateColorAsState(spots.getOrNull(0)?.copy(alpha = strength) ?: Color.Transparent, fade, label = "glow")
|
||||
val second by androidx.compose.animation.animateColorAsState(spots.getOrNull(1)?.copy(alpha = strength) ?: Color.Transparent, fade, label = "glow")
|
||||
val third by androidx.compose.animation.animateColorAsState(spots.getOrNull(2)?.copy(alpha = strength * 0.8f) ?: Color.Transparent, fade, label = "glow")
|
||||
androidx.compose.foundation.Canvas(modifier) {
|
||||
fun pool(colour: Color, x: Float, y: Float, reach: Float) {
|
||||
if (colour.alpha <= 0f) return
|
||||
drawRect(
|
||||
androidx.compose.ui.graphics.Brush.radialGradient(
|
||||
listOf(colour, Color.Transparent),
|
||||
center = androidx.compose.ui.geometry.Offset(size.width * x, size.height * y),
|
||||
radius = size.width * reach,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (wide) {
|
||||
pool(third, 0.22f, 0.8f, 0.35f)
|
||||
pool(first, 0.1f, 0.25f, 0.42f)
|
||||
pool(second, 0.3f, 0.45f, 0.38f)
|
||||
} else {
|
||||
pool(third, 0.5f, 0.5f, 0.85f)
|
||||
pool(first, 0.2f, 0.1f, 0.9f)
|
||||
pool(second, 0.85f, 0.22f, 0.8f)
|
||||
}
|
||||
}
|
||||
val target = tint?.let {
|
||||
if (dark) Color.hsl(it.hue, it.saturation.coerceIn(0.3f, 0.5f), darkLightness)
|
||||
else Color.hsl(it.hue, it.saturation.coerceIn(0.35f, 0.55f), lightLightness)
|
||||
} ?: fallback
|
||||
return animateColorAsState(target, tween(600), label = "art colour").value
|
||||
}
|
||||
|
||||
/** The cover's colours at its top left, top right and middle. */
|
||||
private fun glowColours(art: android.graphics.Bitmap): List<Color> {
|
||||
// A hardware bitmap can't be scaled or read on the CPU, so copy it out first.
|
||||
val readable = if (art.config == android.graphics.Bitmap.Config.HARDWARE) art.copy(android.graphics.Bitmap.Config.ARGB_8888, false) else art
|
||||
val small = android.graphics.Bitmap.createScaledBitmap(readable ?: return emptyList(), 3, 3, true)
|
||||
return listOf(small.getPixel(0, 0), small.getPixel(2, 0), small.getPixel(1, 1)).map { Color(it).copy(alpha = 1f) }
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
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.AppSettings
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
/** One saved place. Home and Work keep a blank [label]; their names come from strings.xml. */
|
||||
data class SavedPlace(val label: String = "", val address: String = "")
|
||||
|
||||
/**
|
||||
* Up to [COUNT] places a driver can name by voice in any map app: slot [HOME], slot [WORK],
|
||||
* then three they name themselves. "Navigate home" sends the saved address as the search.
|
||||
*/
|
||||
object SavedPlaces {
|
||||
|
||||
const val COUNT = 5
|
||||
const val HOME = 0
|
||||
const val WORK = 1
|
||||
|
||||
private const val KEY = "saved_places"
|
||||
|
||||
private val HOME_WORDS = setOf("home", "house", "place", "home address", "my home", "my house", "my place")
|
||||
private val WORK_WORDS = setOf("work", "office", "job", "work address", "my work", "my office", "my job", "the office")
|
||||
|
||||
fun all(context: Context): List<SavedPlace> {
|
||||
val saved = runCatching {
|
||||
val array = JSONArray(AppSettings.getString(context, KEY, "[]"))
|
||||
List(array.length()) { i ->
|
||||
array.getJSONObject(i).let { SavedPlace(it.optString("label"), it.optString("address")) }
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
return List(COUNT) { saved.getOrNull(it) ?: SavedPlace() }
|
||||
}
|
||||
|
||||
fun set(context: Context, index: Int, place: SavedPlace) {
|
||||
val next = all(context).toMutableList().also { it[index] = place }
|
||||
val array = JSONArray()
|
||||
next.forEach { array.put(JSONObject().put("label", it.label).put("address", it.address)) }
|
||||
AppSettings.putString(context, KEY, array.toString())
|
||||
}
|
||||
|
||||
/**
|
||||
* The slot [spoken] names, if any: "home", "my house", "work", or a custom label. Returns the
|
||||
* slot even when its address is blank, so the assistant can say it isn't set yet.
|
||||
*/
|
||||
fun match(context: Context, spoken: String): Pair<Int, SavedPlace>? {
|
||||
val said = spoken.lowercase().trim()
|
||||
val places = all(context)
|
||||
if (said in HOME_WORDS) return HOME to places[HOME]
|
||||
if (said in WORK_WORDS) return WORK to places[WORK]
|
||||
val squashed = squash(said.removePrefix("my ").removePrefix("the "))
|
||||
if (squashed.isEmpty()) return null
|
||||
places.forEachIndexed { i, place ->
|
||||
if (i > WORK && place.label.isNotBlank() && squash(place.label) == squashed) return i to place
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun squash(s: String) = s.lowercase().filter { it.isLetterOrDigit() }
|
||||
|
||||
fun hasLocationPermission(context: Context) =
|
||||
context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
|
||||
context.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
/** Where the phone is now, as a street address, or as coordinates when there's no geocoder. Null if unknown. */
|
||||
suspend fun here(context: Context): String? = withContext(Dispatchers.IO) {
|
||||
val location = locate(context) ?: return@withContext null
|
||||
val address = runCatching {
|
||||
if (!Geocoder.isPresent()) return@runCatching null
|
||||
@Suppress("DEPRECATION")
|
||||
Geocoder(context, Locale.getDefault()).getFromLocation(location.latitude, location.longitude, 1)
|
||||
?.firstOrNull()?.getAddressLine(0)
|
||||
}.getOrNull()
|
||||
address ?: "%.6f,%.6f".format(Locale.US, location.latitude, location.longitude)
|
||||
}
|
||||
|
||||
private suspend fun locate(context: Context): Location? {
|
||||
if (!hasLocationPermission(context)) return null
|
||||
val manager = context.getSystemService(LocationManager::class.java) ?: return null
|
||||
val provider = listOf(LocationManager.FUSED_PROVIDER, LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
|
||||
.firstOrNull { it in manager.allProviders && manager.isProviderEnabled(it) }
|
||||
// A fresh fix: saving the spot you're standing on wants better than an hour-old one.
|
||||
val fresh = provider?.let {
|
||||
withTimeoutOrNull(FIX_TIMEOUT_MS) {
|
||||
suspendCancellableCoroutine { done ->
|
||||
runCatching {
|
||||
manager.getCurrentLocation(it, null, context.mainExecutor) { fix -> done.resume(fix) }
|
||||
}.onFailure { e ->
|
||||
GearslipLog.w("places: fix failed: ${e.message}")
|
||||
done.resume(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return fresh ?: manager.allProviders
|
||||
.mapNotNull { runCatching { manager.getLastKnownLocation(it) }.getOrNull() }
|
||||
.maxByOrNull { it.time }
|
||||
}
|
||||
|
||||
private const val FIX_TIMEOUT_MS = 15_000L
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
@@ -29,7 +31,7 @@ fun ScreenHeader(title: String, modifier: Modifier = Modifier, trailing: @Compos
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", navigator::back)
|
||||
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back), navigator::back)
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import android.graphics.Rect
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
@@ -99,11 +101,12 @@ fun TemplateChrome(template: Template?, modifier: Modifier = Modifier) {
|
||||
* A template that isn't map-backed covers the surface completely, and reporting an empty
|
||||
* rectangle is how the app is told to stop bothering.
|
||||
*/
|
||||
fun visibleAreaFor(template: Template?, width: Int, height: Int, measured: Rect? = null): Rect = when (template) {
|
||||
fun visibleAreaFor(template: Template?, width: Int, height: Int, measured: Rect? = null, cardsAtEnd: Boolean = false): Rect = when (template) {
|
||||
is NavigationTemplate -> measured ?: Rect(0, 0, width, height)
|
||||
is MapWithContentTemplate, is MapTemplate, is PlaceListNavigationTemplate,
|
||||
is RoutePreviewNavigationTemplate, is PlaceListMapTemplate,
|
||||
-> measured ?: Rect((width * CARD_COLUMN_FRACTION).toInt(), 0, width, height)
|
||||
-> measured ?: if (cardsAtEnd) Rect(0, 0, (width * (1 - CARD_COLUMN_FRACTION)).toInt(), height)
|
||||
else Rect((width * CARD_COLUMN_FRACTION).toInt(), 0, width, height)
|
||||
null -> Rect(0, 0, width, height)
|
||||
else -> Rect(0, 0, 0, 0)
|
||||
}
|
||||
@@ -152,6 +155,24 @@ internal class ChromeCover {
|
||||
|
||||
internal val LocalChromeCover = compositionLocalOf<ChromeCover?> { null }
|
||||
|
||||
/**
|
||||
* The app's cards go to the far edge of the map and its buttons to the near one, set while the
|
||||
* player is docked on the left: the player, the map and the cards each keep their own space
|
||||
* instead of the cards crowding up against the player.
|
||||
*/
|
||||
internal val LocalCardsAtEnd = compositionLocalOf { false }
|
||||
|
||||
/**
|
||||
* How much of the map's width the docked player floats over, on the side opposite the cards. The
|
||||
* map itself runs underneath at full size; the app is told this part is covered, and the chrome
|
||||
* keeps clear of it.
|
||||
*/
|
||||
internal val LocalSidePanelInset = compositionLocalOf { 0.dp }
|
||||
|
||||
/** The edge the app's cards cover, and the one its map buttons do. */
|
||||
internal val cardEdge @Composable get() = if (LocalCardsAtEnd.current) CoverEdge.END else CoverEdge.START
|
||||
internal val buttonEdge @Composable get() = if (LocalCardsAtEnd.current) CoverEdge.START else CoverEdge.END
|
||||
|
||||
/**
|
||||
* The floating nav bar, as far as the app's map is concerned. Its full [height] at once rather
|
||||
* than following the slide, so the app is told once instead of every frame.
|
||||
@@ -196,12 +217,15 @@ private fun NavigationBars(
|
||||
) {
|
||||
val connection = LocalCarAppConnection.current
|
||||
val pan by connection.panMode.collectAsState()
|
||||
val atEnd = LocalCardsAtEnd.current
|
||||
val cardEdge = cardEdge
|
||||
val buttonEdge = buttonEdge
|
||||
androidx.compose.foundation.layout.BoxWithConstraints(modifier.fillMaxSize()) {
|
||||
val column = cardColumnWidth(maxWidth)
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
val cards = @Composable {
|
||||
if (routing != null || estimate != null) {
|
||||
Column(
|
||||
Modifier.width(column).fillMaxHeight().covers("nav-cards", CoverEdge.START).padding(12.dp),
|
||||
Modifier.width(column).fillMaxHeight().covers("nav-cards", cardEdge).padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
routing?.let { RoutingCard(it, Modifier.fillMaxWidth()) }
|
||||
@@ -209,14 +233,21 @@ private fun NavigationBars(
|
||||
estimate?.let { TravelEstimateCard(it, Modifier.fillMaxWidth()) }
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
}
|
||||
val buttons = @Composable {
|
||||
RightEdgeControls(
|
||||
if (pan) null else template.actionStrip,
|
||||
template.mapActionStrip,
|
||||
Modifier.padding(12.dp),
|
||||
mapActionsModifier = Modifier.covers("nav-buttons", CoverEdge.END),
|
||||
mapActionsModifier = Modifier.covers("nav-buttons", buttonEdge),
|
||||
alignment = if (atEnd) Alignment.Start else Alignment.End,
|
||||
)
|
||||
}
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
if (atEnd) buttons() else cards()
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (atEnd) cards() else buttons()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,7 +259,7 @@ private fun NavigationBars(
|
||||
private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) {
|
||||
if (info.isLoading) {
|
||||
ChromeSurface(modifier) {
|
||||
Text("Finding a route…", Modifier.padding(horizontal = 18.dp, vertical = 16.dp), style = ChromeType.title)
|
||||
Text(stringResource(R.string.nav_finding_route), Modifier.padding(horizontal = 18.dp, vertical = 16.dp), style = ChromeType.title)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -283,7 +314,7 @@ private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) {
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("Then", style = ChromeType.label, color = scheme.onSurfaceVariant)
|
||||
Text(stringResource(R.string.nav_then), style = ChromeType.label, color = scheme.onSurfaceVariant)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
CarGlyph(next.maneuver?.icon, Modifier.size(28.dp))
|
||||
val nextText = next.cue.text().ifEmpty { next.road.text() }
|
||||
@@ -345,7 +376,7 @@ private fun LaneTile(lane: Lane) {
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.KeyboardArrowUp,
|
||||
contentDescription = if (recommended) "Recommended lane" else "Lane",
|
||||
contentDescription = stringResource(if (recommended) R.string.nav_lane_recommended else R.string.nav_lane),
|
||||
tint = if (recommended) MaterialTheme.colorScheme.onPrimaryContainer
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(24.dp).rotate(shown.turnDegrees()),
|
||||
@@ -398,7 +429,7 @@ private fun TravelEstimateCard(estimate: TravelEstimate, modifier: Modifier = Mo
|
||||
)
|
||||
}
|
||||
val detail = listOfNotNull(
|
||||
arrival.takeIf { it.isNotEmpty() }?.let { "Arrive $it" },
|
||||
arrival.takeIf { it.isNotEmpty() }?.let { stringResource(R.string.nav_arrive, it) },
|
||||
distance.takeIf { it.isNotEmpty() },
|
||||
).joinToString(" • ")
|
||||
if (detail.isNotEmpty()) {
|
||||
@@ -413,7 +444,7 @@ private fun TravelEstimateCard(estimate: TravelEstimate, modifier: Modifier = Mo
|
||||
// pops the on-screen card, leaving the app still believing it is navigating.
|
||||
GsIconButton(
|
||||
Icons.Filled.Close,
|
||||
"Stop navigating",
|
||||
stringResource(R.string.nav_stop),
|
||||
{ connection.stopNavigating() },
|
||||
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface),
|
||||
size = 52.dp,
|
||||
@@ -445,12 +476,14 @@ private fun MapPaneLayout(
|
||||
// Pan mode hands the whole screen to the map, leaving only its own controls.
|
||||
val pan by connection.panMode.collectAsState()
|
||||
val edgeActions = (actionStrip?.actions.orEmpty() + mapActionStrip?.actions.orEmpty()).map { it.identity() }.toSet()
|
||||
val atEnd = LocalCardsAtEnd.current
|
||||
val cardEdge = cardEdge
|
||||
androidx.compose.foundation.layout.BoxWithConstraints(modifier.fillMaxWidth()) {
|
||||
if (!pan) {
|
||||
ChromeSurface(
|
||||
Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.covers("map-pane", CoverEdge.START)
|
||||
.align(if (atEnd) Alignment.TopEnd else Alignment.TopStart)
|
||||
.covers("map-pane", cardEdge)
|
||||
.padding(12.dp)
|
||||
.width(cardColumnWidth(maxWidth) - 24.dp)
|
||||
.let { if (fullHeight) it.height(maxHeight - 24.dp) else it.heightIn(max = maxHeight - 24.dp) },
|
||||
@@ -463,7 +496,8 @@ private fun MapPaneLayout(
|
||||
RightEdgeControls(
|
||||
if (pan) null else actionStrip,
|
||||
mapActionStrip,
|
||||
Modifier.align(Alignment.TopEnd).padding(12.dp),
|
||||
Modifier.align(if (atEnd) Alignment.TopStart else Alignment.TopEnd).padding(12.dp),
|
||||
alignment = if (atEnd) Alignment.Start else Alignment.End,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -543,7 +577,7 @@ private fun RoutePreviewChrome(template: RoutePreviewNavigationTemplate, modifie
|
||||
)
|
||||
Box(Modifier.weight(1f, fill = false)) {
|
||||
if (template.isLoading) {
|
||||
Text("Finding routes…", Modifier.padding(14.dp), style = ChromeType.title)
|
||||
Text(stringResource(R.string.nav_finding_routes), Modifier.padding(14.dp), style = ChromeType.title)
|
||||
} else {
|
||||
ItemListColumn(template.itemList)
|
||||
}
|
||||
@@ -567,7 +601,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), style = ChromeType.title)
|
||||
if (loading) Text(stringResource(R.string.media_loading), Modifier.padding(14.dp), style = ChromeType.title)
|
||||
else ItemListColumn(itemList)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
@@ -317,7 +319,7 @@ internal fun SearchField(
|
||||
Spacer(Modifier.width(12.dp))
|
||||
}
|
||||
Text(
|
||||
query.ifEmpty { hint.ifEmpty { "Search" } },
|
||||
query.ifEmpty { hint.ifEmpty { stringResource(R.string.template_search) } },
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = if (query.isEmpty()) muted else MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
@@ -327,7 +329,7 @@ internal fun SearchField(
|
||||
if (onClear != null && query.isNotEmpty()) {
|
||||
GsIconButton(
|
||||
androidx.compose.material.icons.Icons.Filled.Close,
|
||||
"Clear",
|
||||
stringResource(R.string.storage_clear),
|
||||
onClear,
|
||||
colors = GsColors(androidx.compose.ui.graphics.Color.Transparent, muted),
|
||||
size = 42.dp,
|
||||
@@ -623,7 +625,7 @@ private fun MediaPlaybackContent(template: MediaPlaybackTemplate, modifier: Modi
|
||||
} else {
|
||||
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
"Nothing playing yet.",
|
||||
stringResource(R.string.template_nothing_playing),
|
||||
style = ChromeType.body,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
@@ -647,6 +649,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 = ChromeType.title)
|
||||
Text(stringResource(R.string.media_loading), style = ChromeType.title)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.input.pointer.changedToUp
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -34,6 +37,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Call
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -228,6 +232,8 @@ internal fun RightEdgeControls(
|
||||
modifier: Modifier = Modifier,
|
||||
/** For the stack of map buttons alone, without the row of the app's own above it. */
|
||||
mapActionsModifier: Modifier = Modifier,
|
||||
/** End normally; Start when the app's cards have moved to the far edge and these to the near one. */
|
||||
alignment: Alignment.Horizontal = Alignment.End,
|
||||
) {
|
||||
val connection = LocalCarAppConnection.current
|
||||
val actions = actionStrip?.actions.orEmpty().filter { it.isDrawable() }
|
||||
@@ -244,6 +250,11 @@ internal fun RightEdgeControls(
|
||||
// Kept up while the knob is on one of them, or the ring would fade out along with the button.
|
||||
var holdsFocus by remember { mutableStateOf(false) }
|
||||
val visible = awake || pan || holdsFocus
|
||||
// With the player beside the map there is no room for the app's row and the map controls
|
||||
// both, so the app's own buttons fold behind one; opening it swaps them in for the map's.
|
||||
val folded = LocalSidePanelInset.current > 0.dp && actions.size > 1
|
||||
var open by remember { mutableStateOf(false) }
|
||||
androidx.compose.runtime.LaunchedEffect(visible, folded) { if (!visible || !folded) open = false }
|
||||
val alpha by androidx.compose.animation.core.animateFloatAsState(
|
||||
targetValue = if (visible) 1f else RESTING_ALPHA,
|
||||
animationSpec = androidx.compose.animation.core.tween(if (visible) 150 else 500),
|
||||
@@ -262,18 +273,45 @@ internal fun RightEdgeControls(
|
||||
}
|
||||
}
|
||||
},
|
||||
horizontalAlignment = Alignment.End,
|
||||
horizontalAlignment = alignment,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (actions.isNotEmpty()) {
|
||||
if (folded) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
GsIconButton(
|
||||
Icons.Filled.MoreVert,
|
||||
stringResource(R.string.car_group_more),
|
||||
onClick = { open = !open },
|
||||
colors = GsColors(if (open) scheme.primary else scheme.surfaceContainerHighest, if (open) scheme.onPrimary else scheme.onSurface),
|
||||
size = ICON_BUTTON_SIZE,
|
||||
iconSize = ICON_BUTTON_GLYPH,
|
||||
shape = androidx.compose.foundation.shape.CircleShape,
|
||||
)
|
||||
if (open) {
|
||||
// A tap on any of them closes the stack again; the tap still reaches the button.
|
||||
Column(
|
||||
Modifier.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
if (awaitPointerEvent(PointerEventPass.Initial).changes.any { it.changedToUp() }) open = false
|
||||
}
|
||||
}
|
||||
},
|
||||
horizontalAlignment = alignment,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
actions.forEach { ActionButton(it) }
|
||||
}
|
||||
}
|
||||
} else if (actions.isNotEmpty()) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
actions.forEach { ActionButton(it) }
|
||||
}
|
||||
}
|
||||
if (mapActions.isNotEmpty()) {
|
||||
if (mapActions.isNotEmpty() && !(folded && open)) {
|
||||
Column(
|
||||
mapActionsModifier,
|
||||
horizontalAlignment = Alignment.End,
|
||||
horizontalAlignment = alignment,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
mapActions.forEach { ActionButton(it) }
|
||||
@@ -423,7 +461,7 @@ internal fun AutoStartButton(action: Action, modifier: Modifier = Modifier) {
|
||||
) {
|
||||
if (hasGlyph) CarGlyph(action.icon, Modifier.size(24.dp).align(Alignment.CenterStart))
|
||||
Text(
|
||||
action.title.text().ifEmpty { "Start" },
|
||||
action.title.text().ifEmpty { stringResource(R.string.template_start) },
|
||||
style = ChromeType.title,
|
||||
maxLines = 1,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -300,7 +302,7 @@ private fun DetailRow(row: CarRow, placeName: String) {
|
||||
Row(Modifier.padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
androidx.compose.material3.Icon(Icons.Filled.Call, null, Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Call", style = ChromeType.body)
|
||||
Text(stringResource(R.string.dialer_call), style = ChromeType.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -27,7 +28,7 @@ import androidx.compose.ui.unit.dp
|
||||
fun VehicleDataScreen() {
|
||||
val readings by CarSensors.readings.collectAsState()
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 16.dp)) {
|
||||
Column(Modifier.fillMaxSize().padding(start = 24.dp, end = 24.dp, top = 16.dp)) {
|
||||
ScreenHeader("Vehicle data")
|
||||
Text(
|
||||
"Raw sensor channel from the connected head unit. Field meaning is a best guess - " +
|
||||
@@ -46,7 +47,7 @@ fun VehicleDataScreen() {
|
||||
return@Column
|
||||
}
|
||||
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
LazyColumn(contentPadding = PaddingValues(bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(readings, key = { it.typeId }) { reading -> ReadingRow(reading) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.annotation.StringRes
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
@@ -38,7 +42,7 @@ class WeatherData(
|
||||
|
||||
sealed interface WeatherState {
|
||||
data object Loading : WeatherState
|
||||
data class Unavailable(val reason: String) : WeatherState
|
||||
data class Unavailable(@param:StringRes val reason: Int) : WeatherState
|
||||
data class Ready(val data: WeatherData) : WeatherState
|
||||
}
|
||||
|
||||
@@ -78,7 +82,7 @@ object Weather {
|
||||
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.",
|
||||
if (allowed) R.string.weather_waiting_fix else R.string.weather_allow_location,
|
||||
)
|
||||
}
|
||||
return
|
||||
@@ -92,7 +96,7 @@ object Weather {
|
||||
// No place name: logs get shared in bug reports.
|
||||
GearslipLog.i("weather: loaded")
|
||||
}
|
||||
else if (flow.value !is WeatherState.Ready) flow.value = WeatherState.Unavailable("Could not reach the weather service.")
|
||||
else if (flow.value !is WeatherState.Ready) flow.value = WeatherState.Unavailable(R.string.weather_unreachable)
|
||||
}
|
||||
|
||||
// --- location -------------------------------------------------------------------------
|
||||
@@ -197,6 +201,26 @@ object Weather {
|
||||
)
|
||||
}
|
||||
|
||||
/** Open-Meteo's WMO weather codes, in words on the screen. [describe] is the English the assistant says. */
|
||||
@StringRes
|
||||
fun conditionRes(code: Int): Int = when (code) {
|
||||
0 -> R.string.weather_clear
|
||||
1 -> R.string.weather_mostly_clear
|
||||
2 -> R.string.weather_partly_cloudy
|
||||
3 -> R.string.weather_overcast
|
||||
45, 48 -> R.string.weather_fog
|
||||
in 51..57 -> R.string.weather_drizzle
|
||||
in 61..67 -> R.string.weather_rain
|
||||
in 71..77 -> R.string.weather_snow
|
||||
in 80..82 -> R.string.weather_rain_showers
|
||||
in 85..86 -> R.string.weather_snow_showers
|
||||
in 95..99 -> R.string.weather_thunderstorm
|
||||
else -> 0
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun condition(code: Int): String = conditionRes(code).let { if (it == 0) "-" else stringResource(it) }
|
||||
|
||||
/** Open-Meteo's WMO weather codes, in words. */
|
||||
fun describe(code: Int): String = when (code) {
|
||||
0 -> "Clear"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -41,11 +43,11 @@ fun WeatherScreen() {
|
||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
ScreenHeader("Weather")
|
||||
ScreenHeader(stringResource(R.string.crumb_weather))
|
||||
when (val s = state) {
|
||||
WeatherState.Loading -> Text("Getting the weather…", style = MaterialTheme.typography.titleMedium)
|
||||
WeatherState.Loading -> Text(stringResource(R.string.weather_getting), style = MaterialTheme.typography.titleMedium)
|
||||
is WeatherState.Unavailable -> Text(
|
||||
s.reason, style = MaterialTheme.typography.titleMedium,
|
||||
stringResource(s.reason), style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
is WeatherState.Ready -> Forecast(s.data)
|
||||
@@ -66,18 +68,18 @@ private fun Forecast(w: WeatherData) {
|
||||
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)
|
||||
Text(Weather.condition(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(stringResource(R.string.weather_feels_like, "${w.feelsLike}${w.tempUnit}"), style = MaterialTheme.typography.bodyLarge)
|
||||
Text(stringResource(R.string.weather_humidity, w.humidity), style = MaterialTheme.typography.bodyLarge)
|
||||
Text(stringResource(R.string.weather_wind, "${w.wind} ${w.windUnit}"), style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text("Next hours", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Text(stringResource(R.string.weather_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) {
|
||||
@@ -101,7 +103,7 @@ private fun Forecast(w: WeatherData) {
|
||||
}
|
||||
}
|
||||
|
||||
Text("This week", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Text(stringResource(R.string.weather_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) {
|
||||
@@ -111,11 +113,11 @@ private fun Forecast(w: WeatherData) {
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
if (i == 0) "Today" else SimpleDateFormat("EEEE", Locale.getDefault()).format(Date(d.date)),
|
||||
if (i == 0) stringResource(R.string.weather_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)
|
||||
Text(Weather.condition(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)
|
||||
}
|
||||
@@ -123,7 +125,7 @@ private fun Forecast(w: WeatherData) {
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"Weather data by Open-Meteo.com (CC BY 4.0)",
|
||||
stringResource(R.string.weather_credit),
|
||||
style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.View
|
||||
@@ -95,19 +97,19 @@ fun WebApp(content: String) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
// Back steps through the page's history first (the interceptor above), then leaves.
|
||||
if (!fullscreen) {
|
||||
ScreenHeader(pageTitle.ifEmpty { "Browser" }, Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
GsIconButton(androidx.compose.material.icons.Icons.Filled.Search, "Search or type an address", {
|
||||
ScreenHeader(pageTitle.ifEmpty { stringResource(R.string.app_browser) }, Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
GsIconButton(androidx.compose.material.icons.Icons.Filled.Search, stringResource(R.string.web_search), {
|
||||
fieldText = null
|
||||
address = ""
|
||||
})
|
||||
if (canGoForward) {
|
||||
GsIconButton(androidx.compose.material.icons.Icons.AutoMirrored.Filled.ArrowForward, "Forward", { web?.goForward() })
|
||||
GsIconButton(androidx.compose.material.icons.Icons.AutoMirrored.Filled.ArrowForward, stringResource(R.string.web_forward), { web?.goForward() })
|
||||
}
|
||||
GsIconButton(androidx.compose.material.icons.Icons.Filled.Refresh, "Reload", { web?.reload() })
|
||||
GsIconButton(androidx.compose.material.icons.Icons.Filled.Home, "Start page", {
|
||||
GsIconButton(androidx.compose.material.icons.Icons.Filled.Refresh, stringResource(R.string.web_reload), { web?.reload() })
|
||||
GsIconButton(androidx.compose.material.icons.Icons.Filled.Home, stringResource(R.string.web_start_page), {
|
||||
web?.let { view -> loadContent(view, content) }
|
||||
})
|
||||
GsIconButton(MediaIcons.Fullscreen, "Full screen", CarFullscreen::enter)
|
||||
GsIconButton(MediaIcons.Fullscreen, stringResource(R.string.web_fullscreen), CarFullscreen::enter)
|
||||
}
|
||||
}
|
||||
Box(Modifier.weight(1f).fillMaxSize()) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package app.seb3thehacker.gearslip.car.theme
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
|
||||
/**
|
||||
* The phone's Material You colours, with its accent washed over the surfaces. Material You's own
|
||||
@@ -11,7 +12,8 @@ import androidx.compose.ui.graphics.compositeOver
|
||||
* Material 3's surface tint does, and it changes with the wallpaper like everything else.
|
||||
* Higher containers take more of the tint, so cards still stand off the background.
|
||||
*/
|
||||
fun ColorScheme.withTintedSurfaces(dark: Boolean): ColorScheme {
|
||||
fun ColorScheme.withTintedSurfaces(dark: Boolean, style: DarkStyle = DarkStyle.GREY): ColorScheme {
|
||||
if (dark && style != DarkStyle.GREY) return withDarkStyle(style)
|
||||
val base = if (dark) 0.03f else 0.02f
|
||||
fun tint(own: Color, step: Int) = primary.copy(alpha = base + step * (if (dark) 0.015f else 0.01f)).compositeOver(own)
|
||||
return copy(
|
||||
@@ -27,3 +29,56 @@ fun ColorScheme.withTintedSurfaces(dark: Boolean): ColorScheme {
|
||||
surfaceVariant = tint(surfaceVariant, 3),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* How dark the car's dark mode goes. [GREY] is Material You's own dark, with the tint above;
|
||||
* the others are darker, and the two colour ones take their hue from the phone's accent.
|
||||
*/
|
||||
enum class DarkStyle { GREY, BLACK, DEEP_COLOUR, BLACK_COLOUR }
|
||||
|
||||
/**
|
||||
* The surfaces rebuilt at [style]'s brightness, from the background up to the highest container.
|
||||
* The levels are 0-255 greys; each surface keeps Material You's own slight hue at that level.
|
||||
*/
|
||||
private fun ColorScheme.withDarkStyle(style: DarkStyle): ColorScheme {
|
||||
val levels = when (style) {
|
||||
DarkStyle.BLACK -> listOf(0f, 12f, 17f, 25f, 34f)
|
||||
DarkStyle.DEEP_COLOUR -> listOf(11f, 19f, 24f, 32f, 41f)
|
||||
else -> listOf(0f, 17f, 22f, 30f, 39f)
|
||||
}
|
||||
val colour = style != DarkStyle.BLACK
|
||||
// The accent's saturated container colour, not the pastel accent itself: tinting with the
|
||||
// pastel only makes a surface lighter, where this one gives it colour at the same brightness.
|
||||
fun at(own: Color, step: Int): Color {
|
||||
val level = levels[step] / 255f
|
||||
if (level == 0f) return Color.Black
|
||||
val neutral = own.scaledTo(level)
|
||||
return if (colour) {
|
||||
lerp(neutral, primaryContainer.scaledTo(level), (0.6f + 0.08f * step).coerceAtMost(0.95f))
|
||||
} else {
|
||||
primary.copy(alpha = 0.02f + 0.015f * step).compositeOver(neutral)
|
||||
}
|
||||
}
|
||||
return copy(
|
||||
// Grey text that read fine on grey looks dim on black, so it moves a little towards white.
|
||||
onSurfaceVariant = lerp(onSurfaceVariant, onSurface, 0.3f),
|
||||
background = at(background, 0),
|
||||
surface = at(surface, 0),
|
||||
surfaceDim = at(surfaceDim, 0),
|
||||
surfaceContainerLowest = at(surfaceContainerLowest, 0),
|
||||
surfaceContainerLow = at(surfaceContainerLow, 1),
|
||||
surfaceContainer = at(surfaceContainer, 2),
|
||||
surfaceContainerHigh = at(surfaceContainerHigh, 3),
|
||||
surfaceContainerHighest = at(surfaceContainerHighest, 4),
|
||||
surfaceBright = at(surfaceBright, 4),
|
||||
surfaceVariant = at(surfaceVariant, 3),
|
||||
)
|
||||
}
|
||||
|
||||
/** This colour's hue at an average brightness of [level], 0 to 1. */
|
||||
private fun Color.scaledTo(level: Float): Color {
|
||||
val mean = (red + green + blue) / 3f
|
||||
if (mean <= 0f) return Color(level, level, level)
|
||||
val k = level / mean
|
||||
return Color((red * k).coerceAtMost(1f), (green * k).coerceAtMost(1f), (blue * k).coerceAtMost(1f))
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.dropShadow
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.drawOutline
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
@@ -172,6 +173,46 @@ fun Modifier.gsSurface(
|
||||
* outlines: cheap, works on every API level, and already soft enough that the car's video
|
||||
* compression has nothing crisp to smear.
|
||||
*/
|
||||
/**
|
||||
* The current theme's shadow under a panel floating over the map, such as the docked player.
|
||||
* Draws nothing in a look without one.
|
||||
*/
|
||||
@Composable
|
||||
fun Modifier.gsPanelShadow(shape: Shape): Modifier {
|
||||
val look = gsLook()
|
||||
if (look.panelShadow <= 0.dp || look.panelShadowAlpha <= 0f) return this
|
||||
// A real blur, as soft as the nav bar's gradient and as strong: stacked outlines showed as
|
||||
// rings around a card this big.
|
||||
return dropShadow(
|
||||
shape,
|
||||
androidx.compose.ui.graphics.shadow.Shadow(
|
||||
radius = look.panelShadow * 3,
|
||||
color = Color.Black.copy(alpha = look.panelShadowAlpha * 0.6f),
|
||||
offset = androidx.compose.ui.unit.DpOffset(0.dp, look.panelShadow / 2),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** The same shadow cast upwards from the top edge of a bar along the bottom of the screen. */
|
||||
@Composable
|
||||
fun Modifier.gsBarShadow(): Modifier {
|
||||
val look = gsLook()
|
||||
if (look.panelShadow <= 0.dp || look.panelShadowAlpha <= 0f) return this
|
||||
return drawBehind {
|
||||
val reach = look.panelShadow.toPx() * 1.5f
|
||||
drawRect(
|
||||
Brush.verticalGradient(
|
||||
0f to Color.Transparent,
|
||||
1f to Color.Black.copy(alpha = look.panelShadowAlpha * 0.6f),
|
||||
startY = -reach,
|
||||
endY = 0f,
|
||||
),
|
||||
topLeft = androidx.compose.ui.geometry.Offset(0f, -reach),
|
||||
size = Size(size.width, reach),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawSoftShadow(shape: Shape, elevation: Float, alpha: Float, lift: Float) {
|
||||
if (alpha <= 0f) return
|
||||
val blur = elevation * 2f
|
||||
|
||||
@@ -64,6 +64,12 @@ data class ControlLook(
|
||||
* from; a flat look takes the dot.
|
||||
*/
|
||||
val holdMarker: HoldMarkerStyle = HoldMarkerStyle.PEEL,
|
||||
/**
|
||||
* The shadow a panel floating over the map casts onto it (the docked player, the lyrics), and
|
||||
* the nav bar casts up. 0 for none, so a flat look stays flat.
|
||||
*/
|
||||
val panelShadow: Dp = 0.dp,
|
||||
val panelShadowAlpha: Float = 0f,
|
||||
/** Replaces this look when the UI is light, for effects that only work on one background. */
|
||||
val light: ControlLook? = null,
|
||||
)
|
||||
@@ -72,7 +78,11 @@ object GsThemes {
|
||||
val Keycap = GsTheme(
|
||||
id = "keycap",
|
||||
name = "Keycap",
|
||||
control = ControlLook(lip = 4.dp, pressedScale = 1f, holdMarker = HoldMarkerStyle.PEEL),
|
||||
// Short and dark, like the keys' own solid base.
|
||||
control = ControlLook(
|
||||
lip = 4.dp, pressedScale = 1f, holdMarker = HoldMarkerStyle.PEEL,
|
||||
panelShadow = 4.dp, panelShadowAlpha = 0.45f,
|
||||
),
|
||||
)
|
||||
|
||||
val Tactile = GsTheme(
|
||||
@@ -89,6 +99,8 @@ object GsThemes {
|
||||
pressedDim = 0.12f,
|
||||
pressedScale = 1f,
|
||||
holdMarker = HoldMarkerStyle.PEEL,
|
||||
panelShadow = 12.dp,
|
||||
panelShadowAlpha = 0.42f,
|
||||
// A wide soft shadow bands once the car's video stream compresses it on a light
|
||||
// background, so light mode gets a tight one and leans on the bevel instead.
|
||||
light = ControlLook(
|
||||
@@ -102,6 +114,8 @@ object GsThemes {
|
||||
pressedDim = 0.12f,
|
||||
pressedScale = 1f,
|
||||
holdMarker = HoldMarkerStyle.PEEL,
|
||||
panelShadow = 4.dp,
|
||||
panelShadowAlpha = 0.22f,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -227,6 +227,7 @@ class CarAppConnection(private val context: Context) {
|
||||
|
||||
private fun disconnect(keepLocation: Boolean) {
|
||||
main.removeCallbacks(handshakeTimeout)
|
||||
hardware.stop()
|
||||
clearToasts()
|
||||
lent?.let { sendSurfaceDestroyed(it) }
|
||||
lent = null
|
||||
@@ -599,6 +600,7 @@ class CarAppConnection(private val context: Context) {
|
||||
fun reportAreas(visible: Rect, stable: Rect) {
|
||||
areas = visible to stable
|
||||
val callback = surfaceCallback ?: return
|
||||
GearslipLog.i("host: areas for $appPackage: visible=${visible.toShortString()} stable=${stable.toShortString()}")
|
||||
runCatching { callback.onStableAreaChanged(stable, noop("onStableAreaChanged")) }
|
||||
runCatching { callback.onVisibleAreaChanged(visible, noop("onVisibleAreaChanged")) }
|
||||
}
|
||||
@@ -657,8 +659,16 @@ class CarAppConnection(private val context: Context) {
|
||||
}
|
||||
|
||||
override fun sendLocation(location: Location?) = Unit
|
||||
override fun showAlert(alert: Bundleable?) = Unit
|
||||
override fun dismissAlert(alertId: Int) = Unit
|
||||
override fun showAlert(alert: Bundleable?) {
|
||||
val shown = runCatching { alert?.get() as? androidx.car.app.model.Alert }
|
||||
.onFailure { GearslipLog.w("host: couldn't read an alert: ${it.message}") }
|
||||
.getOrNull() ?: return
|
||||
main.post { if (toastOwner === owner) app.seb3thehacker.gearslip.car.CarAlerts.show(owner, shown) }
|
||||
}
|
||||
|
||||
override fun dismissAlert(alertId: Int) {
|
||||
main.post { app.seb3thehacker.gearslip.car.CarAlerts.dismiss(owner, alertId) }
|
||||
}
|
||||
override fun openMicrophone(request: Bundleable?): Bundleable? = null
|
||||
}
|
||||
|
||||
@@ -684,38 +694,25 @@ class CarAppConnection(private val context: Context) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Answers car-hardware questions with "this car doesn't have that".
|
||||
* Answers car-hardware questions from what the car itself sends: see [CarHardwareFeed].
|
||||
*
|
||||
* A phone knows nothing about a car's sensors, so every answer here is "unsupported" - but
|
||||
* it has to be an actual answer. Returning no binder for this service at all is what made
|
||||
* Organic Maps crash in onAppStart: its CarSensorsManager assumes a host that offers the
|
||||
* service, and the library's own "unsupported" path only runs once a reply arrives.
|
||||
*
|
||||
* The reply's Bundleable is ignored by the library whenever isSupported is false, but the
|
||||
* IBinder must be a real IOnDoneCallback - the app dispatches the reply through it.
|
||||
* Every question has to get an actual answer, even "unsupported". Returning no binder for
|
||||
* this service at all is what made Organic Maps crash in onAppStart: its CarSensorsManager
|
||||
* assumes a host that offers the service, and the library's own "unsupported" path only runs
|
||||
* once a reply arrives. The reply's IBinder must be a real IOnDoneCallback: the app
|
||||
* dispatches the reply through it.
|
||||
*/
|
||||
private val hardware = CarHardwareFeed { name -> noop(name).asBinder() }
|
||||
|
||||
private val hardwareHost = object : ICarHardwareHost.Stub() {
|
||||
override fun getInterfaceVersion(): Int = ICarHardwareHost.VERSION
|
||||
override fun getCarHardwareResult(
|
||||
type: Int,
|
||||
params: Bundleable?,
|
||||
result: ICarHardwareResult?,
|
||||
) = unsupported(type, result)
|
||||
override fun getCarHardwareResult(type: Int, params: Bundleable?, result: ICarHardwareResult?) =
|
||||
hardware.get(type, result)
|
||||
|
||||
override fun subscribeCarHardwareResult(
|
||||
type: Int,
|
||||
params: Bundleable?,
|
||||
result: ICarHardwareResult?,
|
||||
) = unsupported(type, result)
|
||||
override fun subscribeCarHardwareResult(type: Int, params: Bundleable?, result: ICarHardwareResult?) =
|
||||
hardware.subscribe(type, result)
|
||||
|
||||
override fun unsubscribeCarHardwareResult(type: Int, params: Bundleable?) = Unit
|
||||
|
||||
private fun unsupported(type: Int, result: ICarHardwareResult?) {
|
||||
val target = result ?: return
|
||||
runCatching {
|
||||
target.onCarHardwareResult(type, false, null, noop("carHardwareResult").asBinder())
|
||||
}.onFailure { GearslipLog.w("host: could not answer hardware result $type") }
|
||||
}
|
||||
override fun unsubscribeCarHardwareResult(type: Int, params: Bundleable?) = hardware.unsubscribe(type)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -850,6 +847,7 @@ class CarAppConnection(private val context: Context) {
|
||||
|
||||
private fun clearToasts() {
|
||||
CarToasts.clear(toastOwner)
|
||||
app.seb3thehacker.gearslip.car.CarAlerts.clear(toastOwner)
|
||||
toastOwner = Any()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package app.seb3thehacker.gearslip.host
|
||||
|
||||
import android.os.IBinder
|
||||
import androidx.car.app.hardware.ICarHardwareResult
|
||||
import androidx.car.app.hardware.common.CarUnit
|
||||
import androidx.car.app.hardware.common.CarValue
|
||||
import androidx.car.app.hardware.info.CarHardwareLocation
|
||||
import androidx.car.app.hardware.info.Compass
|
||||
import androidx.car.app.hardware.info.EnergyLevel
|
||||
import androidx.car.app.hardware.info.Mileage
|
||||
import androidx.car.app.hardware.info.Model
|
||||
import androidx.car.app.hardware.info.Speed
|
||||
import androidx.car.app.serialization.Bundleable
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.car.CarData
|
||||
import app.seb3thehacker.gearslip.car.CarSettings
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* What a car app gets when it asks the car hardware API (CarInfo, CarSensors) about the car:
|
||||
* the model, speed, odometer, fuel and range, compass and the car's own GPS, read from [CarData].
|
||||
* Anything else, or anything the car never sends, is answered "this car doesn't have that".
|
||||
*
|
||||
* A one-off question ([get]) is answered at once. A subscription gets the current value, if
|
||||
* there is one, and then each new one as the car sends it, until the app unsubscribes or the
|
||||
* connection ends ([stop]).
|
||||
*/
|
||||
internal class CarHardwareFeed(private val callback: (name: String) -> IBinder) {
|
||||
|
||||
private val subscribers = ConcurrentHashMap<Int, CopyOnWriteArrayList<ICarHardwareResult>>()
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
@Volatile private var watching: Job? = null
|
||||
|
||||
fun get(type: Int, result: ICarHardwareResult?) {
|
||||
val target = result ?: return
|
||||
send(target, type, valueOf(type, CarData.state.value) ?: return unsupported(target, type))
|
||||
}
|
||||
|
||||
fun subscribe(type: Int, result: ICarHardwareResult?) {
|
||||
val target = result ?: return
|
||||
if (type !in SENSOR_OF) return unsupported(target, type)
|
||||
subscribers.getOrPut(type) { CopyOnWriteArrayList() }.add(target)
|
||||
GearslipLog.i("host: an app subscribed to car hardware ${NAMES[type] ?: type}")
|
||||
valueOf(type, CarData.state.value)?.let { send(target, type, it) }
|
||||
watch()
|
||||
}
|
||||
|
||||
fun unsubscribe(type: Int) {
|
||||
subscribers.remove(type)
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
subscribers.clear()
|
||||
watching?.cancel()
|
||||
watching = null
|
||||
}
|
||||
|
||||
fun close() {
|
||||
stop()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
/** Sends each subscribed kind again when the car's reading of it changes. */
|
||||
private fun watch() {
|
||||
if (watching?.isActive == true) return
|
||||
watching = scope.launch {
|
||||
var seen = CarData.state.value.updatedAt
|
||||
CarData.state.collect { s ->
|
||||
for ((type, targets) in subscribers) {
|
||||
val sensor = SENSOR_OF[type] ?: continue
|
||||
if (s.updatedAt[sensor] == seen[sensor]) continue
|
||||
val value = valueOf(type, s) ?: continue
|
||||
targets.forEach { send(it, type, value) }
|
||||
}
|
||||
seen = s.updatedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun valueOf(type: Int, s: CarData.Snapshot): Any? {
|
||||
val imperial = CarSettings.imperial()
|
||||
val distanceUnit = known(if (imperial) CarUnit.MILE else CarUnit.KILOMETER)
|
||||
val at = s.updatedAt[SENSOR_OF[type]] ?: System.currentTimeMillis()
|
||||
fun <T : Any> v(value: T) = CarValue(value, at, CarValue.STATUS_SUCCESS)
|
||||
return when (type) {
|
||||
TYPE_MODEL -> CarData.model?.let { m ->
|
||||
Model.Builder().setName(v(m.name)).apply { m.year?.let { setYear(v(it)) } }.build()
|
||||
}
|
||||
TYPE_SPEED -> s.speedMps?.let {
|
||||
Speed.Builder()
|
||||
.setRawSpeedMetersPerSecond(v(it))
|
||||
.setDisplaySpeedMetersPerSecond(v(it))
|
||||
.setSpeedDisplayUnit(known(if (imperial) CarUnit.MILES_PER_HOUR else CarUnit.KILOMETERS_PER_HOUR))
|
||||
.build()
|
||||
}
|
||||
TYPE_MILEAGE -> s.odometerKm?.let {
|
||||
Mileage.Builder().setOdometerMeters(v(it * 1000f)).setDistanceDisplayUnit(distanceUnit).build()
|
||||
}
|
||||
TYPE_ENERGY_LEVEL -> if (s.fuelPercent == null && s.rangeKm == null && s.fuelLow == null) null else {
|
||||
EnergyLevel.Builder().apply {
|
||||
s.fuelPercent?.let { setFuelPercent(v(it.toFloat())) }
|
||||
s.rangeKm?.let { setRangeRemainingMeters(v(it * 1000f)) }
|
||||
s.fuelLow?.let { setEnergyIsLow(v(it)) }
|
||||
setDistanceDisplayUnit(distanceUnit)
|
||||
}.build()
|
||||
}
|
||||
TYPE_COMPASS -> s.compassDegrees?.let { Compass(v(listOf(it))) }
|
||||
TYPE_CAR_LOCATION -> s.location?.let { l ->
|
||||
val location = android.location.Location("car").apply {
|
||||
latitude = l.latitude
|
||||
longitude = l.longitude
|
||||
l.accuracyMeters?.let { accuracy = it }
|
||||
l.altitudeMeters?.let { altitude = it }
|
||||
l.speedMps?.let { speed = it }
|
||||
l.bearingDegrees?.let { bearing = it }
|
||||
time = at
|
||||
}
|
||||
CarHardwareLocation(v(location))
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun known(value: Int) = CarValue(value, System.currentTimeMillis(), CarValue.STATUS_SUCCESS)
|
||||
|
||||
private fun send(target: ICarHardwareResult, type: Int, value: Any) {
|
||||
runCatching {
|
||||
target.onCarHardwareResult(type, true, Bundleable.create(value), callback("carHardwareResult"))
|
||||
}.onFailure {
|
||||
// The app went away: stop telling it.
|
||||
subscribers.values.forEach { list -> list.remove(target) }
|
||||
GearslipLog.w("host: could not send car hardware ${NAMES[type] ?: type}: ${it.javaClass.simpleName}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun unsupported(target: ICarHardwareResult, type: Int) {
|
||||
runCatching { target.onCarHardwareResult(type, false, null, callback("carHardwareResult")) }
|
||||
.onFailure { GearslipLog.w("host: could not answer hardware result $type") }
|
||||
}
|
||||
|
||||
companion object {
|
||||
// ICarHardwareResultTypes in the car library.
|
||||
private const val TYPE_MODEL = 1
|
||||
private const val TYPE_ENERGY_LEVEL = 4
|
||||
private const val TYPE_SPEED = 5
|
||||
private const val TYPE_MILEAGE = 6
|
||||
private const val TYPE_COMPASS = 21
|
||||
private const val TYPE_CAR_LOCATION = 23
|
||||
|
||||
/** Which of the car's sensors each kind comes from, for knowing when it changed. */
|
||||
private val SENSOR_OF = mapOf(
|
||||
TYPE_ENERGY_LEVEL to CarData.FUEL,
|
||||
TYPE_SPEED to CarData.SPEED,
|
||||
TYPE_MILEAGE to CarData.ODOMETER,
|
||||
TYPE_COMPASS to CarData.COMPASS,
|
||||
TYPE_CAR_LOCATION to CarData.LOCATION,
|
||||
)
|
||||
|
||||
private val NAMES = mapOf(
|
||||
TYPE_MODEL to "model", TYPE_ENERGY_LEVEL to "energy level", TYPE_SPEED to "speed",
|
||||
TYPE_MILEAGE to "mileage", TYPE_COMPASS to "compass", TYPE_CAR_LOCATION to "car location",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -306,6 +306,20 @@ fun OnContentRefreshDelegate?.refresh() {
|
||||
.onFailure { GearslipLog.w("host: could not request a refresh: ${it.message}") }
|
||||
}
|
||||
|
||||
/** An alert left the screen without the app taking it down: timed out, or closed by the driver. */
|
||||
fun androidx.car.app.model.AlertCallbackDelegate?.cancel(reason: Int) {
|
||||
val delegate = this ?: return
|
||||
runCatching { delegate.sendCancel(reason, doneCallback("an alert cancel")) }
|
||||
.onFailure { GearslipLog.w("host: could not cancel an alert: ${it.message}") }
|
||||
}
|
||||
|
||||
/** An alert left the screen another way: a newer one replaced it, the app took it down, or a tap. */
|
||||
fun androidx.car.app.model.AlertCallbackDelegate?.dismissed() {
|
||||
val delegate = this ?: return
|
||||
runCatching { delegate.sendDismiss(doneCallback("an alert dismiss")) }
|
||||
.onFailure { GearslipLog.w("host: could not dismiss an alert: ${it.message}") }
|
||||
}
|
||||
|
||||
private fun doneCallback(label: String) = object : OnDoneCallback {
|
||||
// onSuccess is annotated nullable and onFailure isn't - the asymmetry is real.
|
||||
override fun onSuccess(response: Bundleable?) = Unit
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package app.seb3thehacker.gearslip.media
|
||||
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -37,17 +38,33 @@ class MediaEntry(
|
||||
val iconBitmap: Bitmap?,
|
||||
val browsable: Boolean,
|
||||
val playable: Boolean,
|
||||
/** Shown as a cover in a grid rather than a row, as the app asked. */
|
||||
val grid: Boolean = false,
|
||||
/** The heading the app files this under, when it splits a list into sections. */
|
||||
val group: String? = null,
|
||||
)
|
||||
|
||||
/** [icon] is a drawable id inside the media app's own package, not Gearslip's; 0 for none. */
|
||||
class CustomAction(val id: String, val name: String, val extras: Bundle?, val icon: Int = 0, val iconName: String = "") {
|
||||
/**
|
||||
* [icon] is a drawable id inside the media app's own package, not Gearslip's; 0 for none.
|
||||
* [hasOutline] says the app also has an outline of the same icon (ic_heart beside ic_heart_outline).
|
||||
*/
|
||||
class CustomAction(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val extras: Bundle?,
|
||||
val icon: Int = 0,
|
||||
val iconName: String = "",
|
||||
val hasOutline: Boolean = false,
|
||||
) {
|
||||
/**
|
||||
* Whether a toggle like heart or shuffle is on, read from the icon's name - Tidal swaps
|
||||
* ic_favorite for ic_favorite_filled and ic_shuffle for ic_shuffle_active, and keeps the
|
||||
* button's name the same. Whole words only, so "inactive" doesn't count as "active".
|
||||
* Metrolist swaps ic_heart_outline for a plain ic_heart, so a plain icon that has an outline
|
||||
* twin counts as on too.
|
||||
*/
|
||||
val on: Boolean
|
||||
get() = iconName.lowercase().split('_', '.', '-').any { it in ON_WORDS }
|
||||
get() = hasOutline || iconName.lowercase().split('_', '.', '-').any { it in ON_WORDS }
|
||||
|
||||
private companion object {
|
||||
val ON_WORDS = setOf("filled", "fill", "active", "on", "selected", "checked", "enabled", "solid", "one", "all")
|
||||
@@ -113,8 +130,8 @@ class NowPlaying(
|
||||
* only says which way it goes next by its name ("Remove from Liked Songs"), so that's read.
|
||||
*/
|
||||
val liked: Boolean?
|
||||
get() = heart ?: likeAction?.name?.let { name ->
|
||||
listOf("remove", "unlike", "unfav", "unheart").any { name.contains(it, ignoreCase = true) }
|
||||
get() = heart ?: likeAction?.let { action ->
|
||||
action.on || listOf("remove", "unlike", "unfav", "unheart").any { action.name.contains(it, ignoreCase = true) }
|
||||
}
|
||||
|
||||
val playing get() = state == PlaybackState.STATE_PLAYING || state == PlaybackState.STATE_BUFFERING
|
||||
@@ -271,7 +288,10 @@ class CarMedia(private val context: Context) {
|
||||
GearslipLog.w("media: ${app.label} went away")
|
||||
_phase.value = Phase.REJECTED
|
||||
}
|
||||
}, null)
|
||||
}, Bundle().apply {
|
||||
// Tells the app it may ask for grids and section headings, as it would of Android Auto.
|
||||
putBoolean(STYLE_SUPPORTED, true)
|
||||
})
|
||||
browser = mb
|
||||
GearslipLog.i("media: connecting to ${app.label}'s library")
|
||||
runCatching { mb.connect() }.onFailure {
|
||||
@@ -342,6 +362,8 @@ class CarMedia(private val context: Context) {
|
||||
GearslipLog.i("media: connected to ${app.label}, root=${mb.root}")
|
||||
attach(MediaController(context, mb.sessionToken))
|
||||
rootId = mb.root
|
||||
childStyles.clear()
|
||||
mb.extras?.let { childStyles[mb.root] = it.getInt(STYLE_BROWSABLE, 0) to it.getInt(STYLE_PLAYABLE, 0) }
|
||||
path.clear()
|
||||
_phase.value = Phase.READY
|
||||
open(mb.root)
|
||||
@@ -382,7 +404,10 @@ class CarMedia(private val context: Context) {
|
||||
speed = state?.playbackSpeed ?: 1f,
|
||||
updatedAt = state?.lastPositionUpdateTime ?: 0,
|
||||
actions = state?.actions ?: 0,
|
||||
custom = state?.customActions.orEmpty().map { CustomAction(it.action, it.name.toString(), it.extras, it.icon, iconName(it.icon)) },
|
||||
custom = withNextChapter(state?.customActions.orEmpty().map {
|
||||
val iconName = iconName(it.icon)
|
||||
CustomAction(it.action, it.name.toString(), it.extras, it.icon, iconName, hasOutline(it.icon, iconName))
|
||||
}),
|
||||
error = state?.errorMessage?.toString(),
|
||||
activeQueueItemId = state?.activeQueueItemId ?: MediaSession.QueueItem.UNKNOWN_ID.toLong(),
|
||||
heart = if (controller?.ratingType == Rating.RATING_HEART) {
|
||||
@@ -395,16 +420,48 @@ class CarMedia(private val context: Context) {
|
||||
|
||||
private var appResources: Pair<String, android.content.res.Resources?>? = null
|
||||
|
||||
/**
|
||||
* AntennaPod takes a Next chapter command whenever it's playing, but offers the button only
|
||||
* when the driver turns it on in its notification settings. Gearslip offers it either way,
|
||||
* with AntennaPod's own icon; on an episode without chapters it does nothing.
|
||||
*/
|
||||
private fun withNextChapter(actions: List<CustomAction>): List<CustomAction> {
|
||||
if (controller?.packageName != ANTENNAPOD) return actions
|
||||
if (actions.any { it.id in ANTENNAPOD_NEXT_CHAPTER }) return actions
|
||||
val icon = resourcesOf(ANTENNAPOD)?.getIdentifier("ic_notification_next_chapter", "drawable", ANTENNAPOD) ?: 0
|
||||
val nextChapter = CustomAction(ANTENNAPOD_NEXT_CHAPTER.first(), context.getString(R.string.player_next_chapter), null, icon)
|
||||
// The player shows only the first two of an app's own buttons, and AntennaPod's are
|
||||
// Rewind and Fast-forward: second place keeps Rewind beside it.
|
||||
return actions.toMutableList().apply { add(minOf(1, size), nextChapter) }
|
||||
}
|
||||
|
||||
private fun resourcesOf(pkg: String): android.content.res.Resources? {
|
||||
if (appResources?.first != pkg) {
|
||||
appResources = pkg to runCatching { context.packageManager.getResourcesForApplication(pkg) }.getOrNull()
|
||||
outlines.clear()
|
||||
}
|
||||
return appResources?.second
|
||||
}
|
||||
|
||||
/** The icon's resource name in the app's package, or "" when it can't be read. */
|
||||
private fun iconName(icon: Int): String {
|
||||
val pkg = controller?.packageName ?: return ""
|
||||
if (appResources?.first != pkg) {
|
||||
appResources = pkg to runCatching { context.packageManager.getResourcesForApplication(pkg) }.getOrNull()
|
||||
}
|
||||
val res = appResources?.second ?: return ""
|
||||
val res = resourcesOf(pkg) ?: return ""
|
||||
return runCatching { res.getResourceEntryName(icon) }.getOrDefault("")
|
||||
}
|
||||
|
||||
/** Whether each icon has an outline twin, by icon id: looked up once, since state changes often. */
|
||||
private val outlines = HashMap<Int, Boolean>()
|
||||
|
||||
private fun hasOutline(icon: Int, name: String): Boolean {
|
||||
val pkg = controller?.packageName ?: return false
|
||||
val res = resourcesOf(pkg) ?: return false
|
||||
if (name.isEmpty() || OUTLINE_WORDS.any { name.contains(it, ignoreCase = true) }) return false
|
||||
return outlines.getOrPut(icon) {
|
||||
OUTLINE_WORDS.any { res.getIdentifier("${name}_$it", "drawable", pkg) != 0 }
|
||||
}
|
||||
}
|
||||
|
||||
private var loggedButtons: String? = null
|
||||
|
||||
/** The app's own buttons, by id, name and icon, each time they change. */
|
||||
@@ -427,7 +484,7 @@ class CarMedia(private val context: Context) {
|
||||
if (parentId != subscribed) return
|
||||
_browse.value = BrowseState(
|
||||
trail = path.map { it.second },
|
||||
entries = children.map(::entryOf),
|
||||
entries = children.map { entryOf(it, childStyles[parentId]) },
|
||||
loading = false,
|
||||
// An empty top level is an app keeping its library to itself, not an empty one.
|
||||
unavailable = path.isEmpty() && children.isEmpty(),
|
||||
@@ -441,8 +498,21 @@ class CarMedia(private val context: Context) {
|
||||
})
|
||||
}
|
||||
|
||||
private fun entryOf(item: MediaBrowser.MediaItem): MediaEntry {
|
||||
/**
|
||||
* How a folder asked for its children to be shown, browsable ones and playable ones: by
|
||||
* folder id, read off the folder itself (or the root's extras) before it's opened.
|
||||
*/
|
||||
private val childStyles = HashMap<String, Pair<Int, Int>>()
|
||||
|
||||
private fun entryOf(item: MediaBrowser.MediaItem, parentStyle: Pair<Int, Int>?): MediaEntry {
|
||||
val d = item.description
|
||||
val extras = d.extras
|
||||
if (extras != null && item.isBrowsable) {
|
||||
childStyles[item.mediaId.orEmpty()] = extras.getInt(STYLE_BROWSABLE, 0) to extras.getInt(STYLE_PLAYABLE, 0)
|
||||
}
|
||||
// The item's own hint first, then what its folder asked for its kind of child.
|
||||
val style = extras?.getInt(STYLE_SINGLE_ITEM, 0)?.takeIf { it != 0 }
|
||||
?: (if (item.isBrowsable) parentStyle?.first else parentStyle?.second) ?: 0
|
||||
return MediaEntry(
|
||||
id = item.mediaId.orEmpty(),
|
||||
title = d.title?.toString().orEmpty(),
|
||||
@@ -451,6 +521,8 @@ class CarMedia(private val context: Context) {
|
||||
iconBitmap = d.iconBitmap,
|
||||
browsable = item.isBrowsable,
|
||||
playable = item.isPlayable,
|
||||
grid = style == STYLE_GRID || style == STYLE_CATEGORY_GRID,
|
||||
group = extras?.getString(STYLE_GROUP_TITLE)?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -491,7 +563,15 @@ class CarMedia(private val context: Context) {
|
||||
fun next() = controller?.transportControls?.skipToNext()
|
||||
fun previous() = controller?.transportControls?.skipToPrevious()
|
||||
fun seek(ms: Long) = controller?.transportControls?.seekTo(ms)
|
||||
fun custom(action: CustomAction) = controller?.transportControls?.sendCustomAction(action.id, action.extras)
|
||||
fun custom(action: CustomAction) {
|
||||
val controls = controller?.transportControls ?: return
|
||||
// AntennaPod's media3 service and its older one each know only their own name for it.
|
||||
if (controller?.packageName == ANTENNAPOD && action.id in ANTENNAPOD_NEXT_CHAPTER) {
|
||||
ANTENNAPOD_NEXT_CHAPTER.forEach { controls.sendCustomAction(it, null) }
|
||||
} else {
|
||||
controls.sendCustomAction(action.id, action.extras)
|
||||
}
|
||||
}
|
||||
fun playQueueItem(track: QueueTrack) = controller?.transportControls?.skipToQueueItem(track.queueId)
|
||||
|
||||
/**
|
||||
@@ -770,5 +850,20 @@ class CarMedia(private val context: Context) {
|
||||
/** How long an app gets to start playing a search before Gearslip says it didn't. */
|
||||
const val SEARCH_WAIT_MS = 6_000L
|
||||
const val SEARCH_NOTICE_MS = 6_000L
|
||||
/** How apps name the empty version of an icon. */
|
||||
val OUTLINE_WORDS = listOf("outline", "outlined", "border", "empty")
|
||||
}
|
||||
}
|
||||
|
||||
/** Android Auto's browse layout hints, which any app built for it already sends. */
|
||||
private const val ANTENNAPOD = "de.danoeh.antennapod"
|
||||
/** The media3 service's name first: that's the one offered as the button's own. */
|
||||
private val ANTENNAPOD_NEXT_CHAPTER = listOf("next_chapter", "action.de.danoeh.antennapod.core.service.next_chapter")
|
||||
|
||||
private const val STYLE_SUPPORTED = "android.media.browse.CONTENT_STYLE_SUPPORTED"
|
||||
private const val STYLE_BROWSABLE = "android.media.browse.CONTENT_STYLE_BROWSABLE_HINT"
|
||||
private const val STYLE_PLAYABLE = "android.media.browse.CONTENT_STYLE_PLAYABLE_HINT"
|
||||
private const val STYLE_SINGLE_ITEM = "android.media.browse.CONTENT_STYLE_SINGLE_ITEM_HINT"
|
||||
private const val STYLE_GROUP_TITLE = "android.media.browse.CONTENT_STYLE_GROUP_TITLE_HINT"
|
||||
private const val STYLE_GRID = 2
|
||||
private const val STYLE_CATEGORY_GRID = 4
|
||||
@@ -85,7 +85,9 @@ class CarMediaStatus(
|
||||
}
|
||||
|
||||
private fun sendTrack(now: NowPlaying) {
|
||||
val key = "${now.title}\u0000${now.artist}\u0000${now.album}\u0000${now.durationMs}\u0000${now.art?.generationId}"
|
||||
// Whether there's a cover, not which bitmap: some apps (Metrolist) hand over a fresh copy
|
||||
// of the same cover with every update, and resending it each second wastes the link.
|
||||
val key = "${now.title}\u0000${now.artist}\u0000${now.album}\u0000${now.durationMs}\u0000${now.art != null}"
|
||||
if (key == sentTrack) return
|
||||
sentTrack = key
|
||||
var body = Protobuf.stringField(1, now.title) +
|
||||
|
||||
@@ -1,28 +1,55 @@
|
||||
package app.seb3thehacker.gearslip.media
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Base64
|
||||
import android.util.Xml
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.car.CarSettings
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.io.File
|
||||
import java.io.StringReader
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.net.URLEncoder
|
||||
import java.security.MessageDigest
|
||||
|
||||
class LyricLine(val timeMs: Long, val text: String)
|
||||
|
||||
/** [synced] means every line carries a time, so the current one can be followed along. */
|
||||
class LyricsResult(val lines: List<LyricLine>, val synced: Boolean)
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* Looks lyrics up on LRCLIB (lrclib.net), a free community database that needs no key.
|
||||
* One line. [words] is filled only for word-timed lyrics, and joined back together it reads as
|
||||
* [text]; each word carries the space after it, if any, so a word split into syllables stays whole.
|
||||
*/
|
||||
class LyricLine(val timeMs: Long, val text: String, val words: List<LyricWord> = emptyList())
|
||||
|
||||
/** A word, or a syllable of one, sung from [startMs] to [endMs]. */
|
||||
class LyricWord(val startMs: Long, val endMs: Long, val text: String)
|
||||
|
||||
/**
|
||||
* [synced] means every line carries a time, so the current one can be followed along. [source]
|
||||
* names the service that had them, shown on debug builds.
|
||||
*/
|
||||
class LyricsResult(val lines: List<LyricLine>, val synced: Boolean, val source: String = "")
|
||||
|
||||
/**
|
||||
* Looks lyrics up online, in the order Metrolist uses: BetterLyrics, then LRCLIB, then KuGou.
|
||||
* Metrolist's other sources match songs by YouTube video id, which a song from another app
|
||||
* doesn't have, and Paxsenix calls Apple Music with a borrowed token, so they're left out.
|
||||
*
|
||||
* Media apps almost never publish lyrics in their session metadata, so this is the only general
|
||||
* source. It sends the track's title, artist, album and length to lrclib.net, which is why the
|
||||
* lookup happens only when the driver opens the lyrics view and never in the background.
|
||||
* - BetterLyrics (lyrics-api.boidu.dev): word-timed lyrics, but only for songs its server has
|
||||
* already fetched; anything else needs an API key and falls through.
|
||||
* - LRCLIB (lrclib.net): a free community database that needs no key.
|
||||
* - KuGou (kugou.com): a large Chinese music service, strong on Asian music.
|
||||
*
|
||||
* Media apps almost never publish lyrics in their session metadata, so these are the only general
|
||||
* source. Each lookup sends the track's title, artist, album and length to these services, which
|
||||
* is why it happens only when the driver opens the lyrics view, never in the background. With
|
||||
* [CarSettings.moreLyrics] off, only LRCLIB is asked.
|
||||
*
|
||||
* Results are cached twice over: in memory for the life of the process, and on disk under the
|
||||
* cache directory so a song looked up on a past drive doesn't need the network again on this one.
|
||||
@@ -33,33 +60,104 @@ object Lyrics {
|
||||
|
||||
private val cache = HashMap<String, LyricsResult?>()
|
||||
|
||||
/** Longest any one source may take, DNS included: a server that never answers can't hold the rest up. */
|
||||
private const val SOURCE_TIMEOUT_MS = 12_000L
|
||||
/** How long a cached song waits before its sources are asked again for better lyrics. */
|
||||
private const val RECHECK_MS = 24 * 60 * 60 * 1000L
|
||||
private val io = CoroutineScope(Dispatchers.IO)
|
||||
|
||||
/** Word-timed beats line-timed, which beats plain text. */
|
||||
private const val BEST = 3
|
||||
private fun quality(result: LyricsResult?): Int = when {
|
||||
result == null -> 0
|
||||
!result.synced -> 1
|
||||
result.lines.any { it.words.isNotEmpty() } -> BEST
|
||||
else -> 2
|
||||
}
|
||||
|
||||
/** One place to look, with the best [quality] it can give. */
|
||||
private class Source(val name: String, val best: Int, val lookup: () -> LyricsResult?)
|
||||
|
||||
private fun sources(title: String, artist: String, album: String, durationMs: Long): List<Source> {
|
||||
val more = CarSettings.moreLyrics.value
|
||||
val seconds = (durationMs / 1000).toInt()
|
||||
return buildList {
|
||||
if (more) add(Source("BetterLyrics", BEST) { betterLyrics(title, artist, album, seconds) })
|
||||
add(Source("LRCLIB", 2) { exact(title, artist, album, durationMs) ?: search(title, artist) })
|
||||
if (more) add(Source("KuGou", 2) { kuGou(title, artist, album, seconds) })
|
||||
}
|
||||
}
|
||||
|
||||
private fun key(title: String, artist: String, album: String, durationMs: Long) = "$title|$artist|$album|$durationMs"
|
||||
|
||||
suspend fun find(context: Context, title: String, artist: String, album: String, durationMs: Long): LyricsResult? {
|
||||
if (title.isBlank()) return null
|
||||
val key = "$title|$artist|$album|$durationMs"
|
||||
synchronized(cache) { if (cache.containsKey(key)) return cache[key] }
|
||||
val key = key(title, artist, album, durationMs)
|
||||
// A miss with only LRCLIB isn't a miss once more sources are on, so the mode is part of it.
|
||||
val memoryKey = "${CarSettings.moreLyrics.value}|$key"
|
||||
synchronized(cache) { if (cache.containsKey(memoryKey)) return cache[memoryKey] }
|
||||
|
||||
val fromDisk = withContext(Dispatchers.IO) { readDisk(context, key) }
|
||||
if (fromDisk != null) {
|
||||
synchronized(cache) { cache[key] = fromDisk }
|
||||
synchronized(cache) { cache[memoryKey] = fromDisk }
|
||||
return fromDisk
|
||||
}
|
||||
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
exact(title, artist, album, durationMs) ?: search(title, artist)
|
||||
}.onFailure { GearslipLog.w("lyrics: lookup failed: ${it.message}") }.getOrNull()
|
||||
}
|
||||
synchronized(cache) { cache[key] = result }
|
||||
if (result != null) withContext(Dispatchers.IO) { writeDisk(context, key, result) }
|
||||
val result = firstBetter(sources(title, artist, album, durationMs), than = 0)
|
||||
synchronized(cache) { cache[memoryKey] = result }
|
||||
if (result != null) withContext(Dispatchers.IO) { writeDisk(context, key, result, System.currentTimeMillis()) }
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* After [find] answered with [current], looks for better lyrics than it: word-timed where it has
|
||||
* line-timed, timed where it has plain. Only the sources that could beat it are asked, and only
|
||||
* once a day per song, so an old cache entry improves when a source gains the song without every
|
||||
* play sending the song out again. Returns the better lyrics, or null to keep [current].
|
||||
*/
|
||||
suspend fun upgrade(
|
||||
context: Context, title: String, artist: String, album: String, durationMs: Long, current: LyricsResult,
|
||||
): LyricsResult? {
|
||||
val have = quality(current)
|
||||
if (have >= BEST || title.isBlank()) return null
|
||||
val key = key(title, artist, album, durationMs)
|
||||
val checked = withContext(Dispatchers.IO) { checkedAt(context, key) }
|
||||
if (System.currentTimeMillis() - checked < RECHECK_MS) return null
|
||||
|
||||
val candidates = sources(title, artist, album, durationMs).filter { it.best > have && it.name != current.source }
|
||||
val better = firstBetter(candidates, than = have)
|
||||
withContext(Dispatchers.IO) { writeDisk(context, key, better ?: current, System.currentTimeMillis()) }
|
||||
if (better == null) return null
|
||||
GearslipLog.i("lyrics: replaced ${current.source.ifEmpty { "cached" }} lyrics with ${better.source}'s")
|
||||
synchronized(cache) { cache["${CarSettings.moreLyrics.value}|$key"] = better }
|
||||
return better
|
||||
}
|
||||
|
||||
/** Asks [sources] in order and returns the first answer of a higher [quality] than [than]. */
|
||||
private suspend fun firstBetter(sources: List<Source>, than: Int): LyricsResult? {
|
||||
for (source in sources) {
|
||||
val job = io.async {
|
||||
runCatching(source.lookup).onFailure { GearslipLog.w("lyrics: ${source.name} failed: ${it.message}") }.getOrNull()
|
||||
}
|
||||
val result = withTimeoutOrNull(SOURCE_TIMEOUT_MS) { job.await() }
|
||||
?.let { LyricsResult(it.lines, it.synced, source.name) }
|
||||
if (quality(result) > than) {
|
||||
GearslipLog.i("lyrics: found on ${source.name} (${if (result!!.synced) "synced" else "plain"})")
|
||||
return result
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun cacheFile(context: Context, key: String): File {
|
||||
val digest = MessageDigest.getInstance("SHA-1").digest(key.toByteArray())
|
||||
val name = digest.joinToString("") { "%02x".format(it) }
|
||||
return File(context.cacheDir, "lyrics").apply { mkdirs() }.resolve("$name.json")
|
||||
return File(context.cacheDir, LYRICS_DIR).apply { mkdirs() }.resolve("$name.json")
|
||||
}
|
||||
|
||||
/** Where the lyrics cache lives under the app's cache directory, for the storage screen too. */
|
||||
const val LYRICS_DIR = "lyrics"
|
||||
|
||||
private fun readDisk(context: Context, key: String): LyricsResult? = runCatching {
|
||||
val file = cacheFile(context, key)
|
||||
if (!file.exists()) return null
|
||||
@@ -67,23 +165,153 @@ object Lyrics {
|
||||
val lines = json.getJSONArray("lines").let { arr ->
|
||||
(0 until arr.length()).map { i ->
|
||||
val line = arr.getJSONObject(i)
|
||||
LyricLine(line.getLong("t"), line.getString("x"))
|
||||
val words = line.optJSONArray("w")?.let { w ->
|
||||
(0 until w.length()).map { j ->
|
||||
val word = w.getJSONArray(j)
|
||||
LyricWord(word.getLong(0), word.getLong(1), word.getString(2))
|
||||
}
|
||||
}.orEmpty()
|
||||
LyricLine(line.getLong("t"), line.getString("x"), words)
|
||||
}
|
||||
}
|
||||
LyricsResult(lines, json.getBoolean("synced"))
|
||||
val source = json.optString("source")
|
||||
// Saved before word timings were kept: fetch again rather than wait a day for an upgrade
|
||||
// that skips the source it came from.
|
||||
if (source == "BetterLyrics" && lines.none { it.words.isNotEmpty() }) return null
|
||||
LyricsResult(lines, json.getBoolean("synced"), source)
|
||||
}.getOrNull()
|
||||
|
||||
private fun writeDisk(context: Context, key: String, result: LyricsResult) {
|
||||
/** When the song's sources were last asked; 0 for entries from before the date was kept. */
|
||||
private fun checkedAt(context: Context, key: String): Long =
|
||||
runCatching { JSONObject(cacheFile(context, key).readText()).optLong("checked") }.getOrDefault(0L)
|
||||
|
||||
private fun writeDisk(context: Context, key: String, result: LyricsResult, checked: Long) {
|
||||
runCatching {
|
||||
val lines = JSONArray()
|
||||
result.lines.forEach { line ->
|
||||
lines.put(JSONObject().put("t", line.timeMs).put("x", line.text))
|
||||
val entry = JSONObject().put("t", line.timeMs).put("x", line.text)
|
||||
if (line.words.isNotEmpty()) {
|
||||
entry.put("w", JSONArray(line.words.map { JSONArray().put(it.startMs).put(it.endMs).put(it.text) }))
|
||||
}
|
||||
lines.put(entry)
|
||||
}
|
||||
val json = JSONObject().put("synced", result.synced).put("lines", lines)
|
||||
val json = JSONObject().put("synced", result.synced).put("source", result.source)
|
||||
.put("checked", checked).put("lines", lines)
|
||||
cacheFile(context, key).writeText(json.toString())
|
||||
}.onFailure { GearslipLog.w("lyrics: could not write disk cache: ${it.message}") }
|
||||
}
|
||||
|
||||
/** Forgets every cached song, on disk and in memory. */
|
||||
fun clearCache(context: Context) {
|
||||
synchronized(cache) { cache.clear() }
|
||||
File(context.cacheDir, LYRICS_DIR).deleteRecursively()
|
||||
}
|
||||
|
||||
// --- BetterLyrics ---
|
||||
|
||||
private fun betterLyrics(title: String, artist: String, album: String, seconds: Int): LyricsResult? {
|
||||
if (artist.isBlank()) return null
|
||||
val query = buildString {
|
||||
append("s=").append(enc(title)).append("&a=").append(enc(artist))
|
||||
if (seconds > 0) append("&d=").append(seconds)
|
||||
if (album.isNotBlank()) append("&al=").append(enc(album))
|
||||
}
|
||||
val body = get("https://lyrics-api.boidu.dev/getLyrics?$query") ?: return null
|
||||
val ttml = JSONObject(body).optString("ttml").takeIf { it.isNotBlank() && it != "null" } ?: return null
|
||||
return parseTtml(ttml)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apple-style TTML: one `<p begin=…>` per line, its words in `<span begin=… end=…>`s with
|
||||
* spaces between; a word sung in syllables is several spans with no space. Background vocals,
|
||||
* translations and romanisations are left out, so a line reads as sung.
|
||||
*/
|
||||
private fun parseTtml(ttml: String): LyricsResult? {
|
||||
val parser = Xml.newPullParser().apply {
|
||||
setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
|
||||
setInput(StringReader(ttml))
|
||||
}
|
||||
val lines = mutableListOf<LyricLine>()
|
||||
var inP = false
|
||||
var lineBegin: Long? = null
|
||||
var firstSpanBegin: Long? = null
|
||||
var skipDepth = 0
|
||||
val text = StringBuilder()
|
||||
// The span being read, and the words of the line so far, each with the space after it.
|
||||
var spanBegin: Long? = null
|
||||
var spanEnd: Long? = null
|
||||
val words = mutableListOf<Triple<Long, Long, StringBuilder>>()
|
||||
var event = parser.eventType
|
||||
while (event != XmlPullParser.END_DOCUMENT) {
|
||||
when (event) {
|
||||
XmlPullParser.START_TAG -> when {
|
||||
parser.name == "p" -> {
|
||||
inP = true; text.clear(); firstSpanBegin = null; words.clear()
|
||||
lineBegin = attr(parser, "begin")?.let(::ttmlMs)
|
||||
}
|
||||
inP && skipDepth > 0 -> skipDepth++
|
||||
inP && parser.name == "span" -> {
|
||||
val role = attr(parser, "role")
|
||||
if (role == "x-bg" || role == "x-translation" || role == "x-roman") skipDepth = 1
|
||||
else {
|
||||
spanBegin = attr(parser, "begin")?.let(::ttmlMs)
|
||||
spanEnd = attr(parser, "end")?.let(::ttmlMs)
|
||||
if (firstSpanBegin == null) firstSpanBegin = spanBegin
|
||||
}
|
||||
}
|
||||
}
|
||||
XmlPullParser.TEXT -> if (inP && skipDepth == 0) {
|
||||
val raw = parser.text
|
||||
text.append(raw)
|
||||
val begin = spanBegin
|
||||
val end = spanEnd
|
||||
if (begin != null && end != null && raw.isNotBlank()) {
|
||||
val word = raw.replace(Regex("\\s+"), " ").trimStart()
|
||||
words.add(Triple(begin, end, StringBuilder(word)))
|
||||
} else if (raw.isNotEmpty() && raw.isBlank() && words.isNotEmpty() && !words.last().third.endsWith(" ")) {
|
||||
words.last().third.append(' ')
|
||||
}
|
||||
}
|
||||
XmlPullParser.END_TAG -> when {
|
||||
inP && skipDepth > 0 -> skipDepth--
|
||||
inP && parser.name == "span" -> { spanBegin = null; spanEnd = null }
|
||||
parser.name == "p" -> {
|
||||
inP = false
|
||||
val timed = words.map { (b, e, w) -> LyricWord(b, e, w.toString()) }
|
||||
val line = if (timed.isNotEmpty()) timed.joinToString("") { it.text }.trim()
|
||||
else text.toString().replace(Regex("\\s+"), " ").trim()
|
||||
if (line.isNotEmpty()) lines.add(LyricLine(lineBegin ?: firstSpanBegin ?: -1, line, timed))
|
||||
}
|
||||
}
|
||||
}
|
||||
event = parser.next()
|
||||
}
|
||||
if (lines.isEmpty()) return null
|
||||
val synced = lines.all { it.timeMs >= 0 } && lines.any { it.timeMs > 0 }
|
||||
return if (synced) LyricsResult(lines.sortedBy { it.timeMs }, synced = true)
|
||||
else LyricsResult(lines.map { LyricLine(0, it.text) }, synced = false)
|
||||
}
|
||||
|
||||
private fun attr(parser: XmlPullParser, localName: String): String? =
|
||||
(0 until parser.attributeCount).firstOrNull { parser.getAttributeName(it) == localName }
|
||||
?.let { parser.getAttributeValue(it) }
|
||||
|
||||
/** TTML times: `1:23.456`, `1:02:03.4`, `83.456s`, `250ms` or a bare number of seconds. */
|
||||
private fun ttmlMs(raw: String): Long? {
|
||||
val t = raw.trim()
|
||||
val seconds = when {
|
||||
':' in t -> t.split(':').fold(0.0) { acc, part -> acc * 60 + (part.toDoubleOrNull() ?: return null) }
|
||||
t.endsWith("ms") -> (t.dropLast(2).toDoubleOrNull() ?: return null) / 1000
|
||||
t.endsWith("s") -> t.dropLast(1).toDoubleOrNull() ?: return null
|
||||
t.endsWith("m") -> (t.dropLast(1).toDoubleOrNull() ?: return null) * 60
|
||||
t.endsWith("h") -> (t.dropLast(1).toDoubleOrNull() ?: return null) * 3600
|
||||
else -> t.toDoubleOrNull() ?: return null
|
||||
}
|
||||
return (seconds * 1000).toLong()
|
||||
}
|
||||
|
||||
// --- LRCLIB ---
|
||||
|
||||
private fun exact(title: String, artist: String, album: String, durationMs: Long): LyricsResult? {
|
||||
if (artist.isBlank()) return null
|
||||
val query = buildString {
|
||||
@@ -111,6 +339,73 @@ object Lyrics {
|
||||
return LyricsResult(plain.lines().map { LyricLine(0, it) }, synced = false)
|
||||
}
|
||||
|
||||
// --- KuGou ---
|
||||
|
||||
/** Songs within this many seconds of the playing one count as the same recording. */
|
||||
private const val KUGOU_DURATION_SLACK = 8
|
||||
private val KUGOU_TIMED = Regex("""\[(\d\d):(\d\d)\.(\d{2,3})].*""")
|
||||
/** A credit line such as `[00:01.00]作词:…` or `[00:02.00]Composer: …`. */
|
||||
private val KUGOU_CREDIT = Regex(""".+].+[::].+""")
|
||||
private const val KUGOU_CREDIT_LINES = 30
|
||||
|
||||
private fun kuGou(title: String, artist: String, album: String, seconds: Int): LyricsResult? {
|
||||
val keyword = buildString {
|
||||
append(kuGouTitle(title)).append(" - ").append(kuGouArtist(artist))
|
||||
if (album.isNotBlank()) append(' ').append(album)
|
||||
}
|
||||
val encoded = URLEncoder.encode(keyword, "UTF-8").replace("+", "%20")
|
||||
// First the song itself, matched by length, then its lyrics by the song's hash.
|
||||
val songs = get(
|
||||
"https://mobileservice.kugou.com/api/v3/search/song?version=9108&plat=0&pagesize=8&showtype=0&keyword=$encoded",
|
||||
)?.let { JSONObject(it).optJSONObject("data")?.optJSONArray("info") }
|
||||
if (songs != null) for (i in 0 until songs.length()) {
|
||||
val song = songs.getJSONObject(i)
|
||||
if (seconds > 0 && abs(song.optInt("duration") - seconds) > KUGOU_DURATION_SLACK) continue
|
||||
val hash = song.optString("hash").takeIf { it.isNotBlank() } ?: continue
|
||||
kuGouCandidate("hash=" + enc(hash))?.let { return it }
|
||||
}
|
||||
// Then a plain lyrics search by name.
|
||||
val byName = "keyword=$encoded" + if (seconds > 0) "&duration=${seconds * 1000}" else ""
|
||||
return kuGouCandidate(byName)
|
||||
}
|
||||
|
||||
private fun kuGouCandidate(query: String): LyricsResult? {
|
||||
val body = get("https://lyrics.kugou.com/search?ver=1&man=yes&client=pc&$query") ?: return null
|
||||
val candidate = JSONObject(body).optJSONArray("candidates")?.optJSONObject(0) ?: return null
|
||||
val id = candidate.optLong("id").takeIf { it != 0L } ?: return null
|
||||
val accessKey = candidate.optString("accesskey").takeIf { it.isNotBlank() } ?: return null
|
||||
val download = get(
|
||||
"https://lyrics.kugou.com/download?fmt=lrc&charset=utf8&client=pc&ver=1&id=$id&accesskey=${enc(accessKey)}",
|
||||
) ?: return null
|
||||
val content = JSONObject(download).optString("content").takeIf { it.isNotBlank() } ?: return null
|
||||
val lrc = String(Base64.decode(content, Base64.DEFAULT), Charsets.UTF_8)
|
||||
val lines = parseLrc(kuGouTrim(lrc).joinToString("\n")).filter { it.text.isNotEmpty() }
|
||||
return if (lines.isEmpty()) null else LyricsResult(lines, synced = true)
|
||||
}
|
||||
|
||||
/** Keeps the timed lines and drops the songwriter and producer credits KuGou puts at either end. */
|
||||
private fun kuGouTrim(lrc: String): List<String> {
|
||||
val timed = lrc.lines().filter { it.matches(KUGOU_TIMED) }
|
||||
if (timed.isEmpty()) return timed
|
||||
val headLimit = minOf(KUGOU_CREDIT_LINES, timed.lastIndex)
|
||||
val head = (headLimit downTo 0).firstOrNull { timed[it].matches(KUGOU_CREDIT) }?.plus(1) ?: 0
|
||||
val rest = timed.drop(head)
|
||||
if (rest.isEmpty()) return rest
|
||||
val tailLimit = minOf(KUGOU_CREDIT_LINES, rest.lastIndex)
|
||||
val tail = (tailLimit downTo 0).firstOrNull { rest[rest.lastIndex - it].matches(KUGOU_CREDIT) }?.plus(1) ?: 0
|
||||
return rest.dropLast(tail)
|
||||
}
|
||||
|
||||
/** KuGou's titles leave out bracketed extras such as "(feat. …)" or "(Live)". */
|
||||
private fun kuGouTitle(title: String) = listOf("\\(.*\\)", "(.*)", "「.*」", "『.*』", "<.*>", "《.*》", "〈.*〉", "<.*>")
|
||||
.fold(title) { acc, pattern -> acc.replace(Regex(pattern), "") }.trim()
|
||||
|
||||
/** KuGou joins several artists with "、". */
|
||||
private fun kuGouArtist(artist: String) = artist.replace(", ", "、").replace(" & ", "、").replace(".", "")
|
||||
.replace(Regex("\\(.*\\)"), "").replace(Regex("(.*)"), "").trim()
|
||||
|
||||
// --- shared ---
|
||||
|
||||
/** `[01:23.45] words` - a line may carry several stamps when a chorus repeats. */
|
||||
private fun parseLrc(text: String): List<LyricLine> {
|
||||
val stamp = Regex("""\[(\d+):(\d+(?:\.\d+)?)]""")
|
||||
@@ -129,6 +424,7 @@ object Lyrics {
|
||||
connectTimeout = 5_000
|
||||
readTimeout = 8_000
|
||||
setRequestProperty("User-Agent", "Gearslip (car media host)")
|
||||
setRequestProperty("Accept", "application/json")
|
||||
}
|
||||
return try {
|
||||
if (connection.responseCode != 200) null else connection.inputStream.bufferedReader().readText()
|
||||
|
||||
@@ -27,7 +27,10 @@ class GearslipNotificationListener : NotificationListenerService() {
|
||||
|
||||
override fun onNotificationPosted(sbn: StatusBarNotification) = ingest(sbn, quiet = false)
|
||||
|
||||
override fun onNotificationRemoved(sbn: StatusBarNotification) = CarNotifications.gone(sbn.key)
|
||||
override fun onNotificationRemoved(sbn: StatusBarNotification) {
|
||||
PhoneNavigation.removed(sbn.key)
|
||||
CarNotifications.gone(sbn.key)
|
||||
}
|
||||
|
||||
private fun ingest(sbn: StatusBarNotification, quiet: Boolean) {
|
||||
// Gearslip's own notifications are skipped, except a debug build's test message.
|
||||
@@ -35,6 +38,11 @@ class GearslipNotificationListener : NotificationListenerService() {
|
||||
!(app.seb3thehacker.gearslip.BuildConfig.DEBUG && sbn.notification.channelId == TestMessage.CHANNEL)
|
||||
) return
|
||||
val n = sbn.notification
|
||||
// Google Maps or Waze guiding on the phone: the turn goes to the nav bar and the cluster.
|
||||
if (n.category == Notification.CATEGORY_NAVIGATION && sbn.packageName in PhoneNavigation.APPS) {
|
||||
PhoneNavigation.posted(this, sbn)
|
||||
return
|
||||
}
|
||||
// Ongoing entries (media, navigation, downloads) and group headers are status, not news.
|
||||
if (n.flags and (Notification.FLAG_ONGOING_EVENT or Notification.FLAG_GROUP_SUMMARY) != 0) return
|
||||
if (n.category in QUIET_CATEGORIES) return
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package app.seb3thehacker.gearslip.notify
|
||||
|
||||
import android.app.Notification
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.service.notification.StatusBarNotification
|
||||
import androidx.car.app.model.Distance
|
||||
import androidx.car.app.navigation.model.Maneuver
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* The next turn from a map app guiding on the phone itself. Google Maps and Waze refuse to run on
|
||||
* Gearslip's car screen, but while one of them navigates on the phone it keeps a notification up
|
||||
* with the turn's arrow, the distance and the road. Gearslip reads it, through the notification
|
||||
* access it already has, and puts the turn in the nav bar and on the instrument cluster.
|
||||
*/
|
||||
object PhoneNavigation {
|
||||
|
||||
/** One turn as the notification described it. [maneuver] is a guess from the words, English only. */
|
||||
class Turn(
|
||||
val key: String,
|
||||
val packageName: String,
|
||||
val arrow: Bitmap?,
|
||||
val distance: Distance?,
|
||||
val distanceText: String,
|
||||
val road: String,
|
||||
val maneuver: Int,
|
||||
)
|
||||
|
||||
private val _turn = MutableStateFlow<Turn?>(null)
|
||||
val turn: StateFlow<Turn?> = _turn
|
||||
|
||||
/** The apps whose turns are read; any other map app runs on the car screen itself. */
|
||||
val APPS = setOf("com.google.android.apps.maps", "com.waze")
|
||||
|
||||
private val log = GearslipLog.tagged("nav")
|
||||
|
||||
fun posted(context: Context, sbn: StatusBarNotification) {
|
||||
val turn = parse(context, sbn)
|
||||
val was = _turn.value
|
||||
if (turn == null) {
|
||||
if (was?.key == sbn.key) _turn.value = null
|
||||
return
|
||||
}
|
||||
if (was == null || was.road != turn.road || was.maneuver != turn.maneuver) {
|
||||
log.i("phone navigation (${sbn.packageName}): ${turn.distanceText} | ${turn.road} | maneuver=${turn.maneuver}")
|
||||
}
|
||||
_turn.value = turn
|
||||
}
|
||||
|
||||
fun removed(key: String) {
|
||||
if (_turn.value?.key == key) {
|
||||
log.i("phone navigation ended")
|
||||
_turn.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Maps puts the distance in the title and the instruction in the text; Waze has been
|
||||
* seen the other way round. Whichever field starts with a distance is the distance, and the
|
||||
* other is the instruction.
|
||||
*/
|
||||
private fun parse(context: Context, sbn: StatusBarNotification): Turn? {
|
||||
val extras = sbn.notification.extras
|
||||
fun field(name: String) = extras.getCharSequence(name)?.toString()?.trim().orEmpty()
|
||||
val fields = listOf(field(Notification.EXTRA_TITLE), field(Notification.EXTRA_TEXT), field(Notification.EXTRA_BIG_TEXT))
|
||||
.filter { it.isNotEmpty() }
|
||||
if (fields.isEmpty()) return null
|
||||
val withDistance = fields.firstOrNull { DISTANCE.find(it)?.range?.first == 0 }
|
||||
val match = withDistance?.let { DISTANCE.find(it) }
|
||||
val rest = withDistance?.substring(match!!.range.last + 1)?.trimStart(' ', '·', '-', '•', ',')?.trim().orEmpty()
|
||||
val instruction = fields.firstOrNull { it != withDistance }?.takeIf { it.isNotEmpty() } ?: rest
|
||||
if (instruction.isEmpty() && match == null) return null
|
||||
return Turn(
|
||||
key = sbn.key,
|
||||
packageName = sbn.packageName,
|
||||
arrow = arrow(context, sbn.notification),
|
||||
distance = match?.let { distanceOf(it) },
|
||||
distanceText = match?.value?.trim().orEmpty(),
|
||||
road = roadOf(instruction),
|
||||
maneuver = maneuverOf(instruction),
|
||||
)
|
||||
}
|
||||
|
||||
private fun arrow(context: Context, n: Notification): Bitmap? = runCatching {
|
||||
n.getLargeIcon()?.loadDrawable(context)?.toBitmap(ARROW_PX, ARROW_PX)
|
||||
}.getOrNull()
|
||||
|
||||
private fun distanceOf(m: MatchResult): Distance? {
|
||||
val value = m.groupValues[1].replace(',', '.').toDoubleOrNull() ?: return null
|
||||
val unit = when (m.groupValues[2].lowercase()) {
|
||||
"km" -> Distance.UNIT_KILOMETERS
|
||||
"mi" -> Distance.UNIT_MILES
|
||||
"ft" -> Distance.UNIT_FEET
|
||||
"yd" -> Distance.UNIT_YARDS
|
||||
else -> Distance.UNIT_METERS
|
||||
}
|
||||
val meters = value * when (unit) {
|
||||
Distance.UNIT_KILOMETERS -> 1000.0
|
||||
Distance.UNIT_MILES -> 1609.344
|
||||
Distance.UNIT_FEET -> 0.3048
|
||||
Distance.UNIT_YARDS -> 0.9144
|
||||
else -> 1.0
|
||||
}
|
||||
return Distance.create(meters, unit)
|
||||
}
|
||||
|
||||
/** "Turn left onto Main St" shows as "Main St"; anything else shows as it came. */
|
||||
private fun roadOf(instruction: String): String {
|
||||
val lower = instruction.lowercase()
|
||||
for (word in ROAD_WORDS) {
|
||||
val at = lower.indexOf(word)
|
||||
if (at >= 0) return instruction.substring(at + word.length).trim()
|
||||
}
|
||||
return instruction
|
||||
}
|
||||
|
||||
private fun maneuverOf(instruction: String): Int {
|
||||
val s = instruction.lowercase()
|
||||
val left = "left" in s
|
||||
val right = "right" in s
|
||||
fun side(l: Int, r: Int) = if (left) l else if (right) r else Maneuver.TYPE_UNKNOWN
|
||||
return when {
|
||||
"u-turn" in s || "make a u" in s -> side(Maneuver.TYPE_U_TURN_LEFT, Maneuver.TYPE_U_TURN_RIGHT)
|
||||
"roundabout" in s -> Maneuver.TYPE_UNKNOWN // the car's icon needs an exit number we don't have
|
||||
"destination" in s || "arrive" in s -> side(Maneuver.TYPE_DESTINATION_LEFT, Maneuver.TYPE_DESTINATION_RIGHT)
|
||||
.takeIf { it != Maneuver.TYPE_UNKNOWN } ?: Maneuver.TYPE_DESTINATION
|
||||
"exit" in s -> side(Maneuver.TYPE_OFF_RAMP_NORMAL_LEFT, Maneuver.TYPE_OFF_RAMP_NORMAL_RIGHT)
|
||||
"merge" in s -> side(Maneuver.TYPE_MERGE_LEFT, Maneuver.TYPE_MERGE_RIGHT)
|
||||
.takeIf { it != Maneuver.TYPE_UNKNOWN } ?: Maneuver.TYPE_MERGE_SIDE_UNSPECIFIED
|
||||
"fork" in s -> side(Maneuver.TYPE_FORK_LEFT, Maneuver.TYPE_FORK_RIGHT)
|
||||
"keep" in s || "stay" in s -> side(Maneuver.TYPE_KEEP_LEFT, Maneuver.TYPE_KEEP_RIGHT)
|
||||
"slight" in s -> side(Maneuver.TYPE_TURN_SLIGHT_LEFT, Maneuver.TYPE_TURN_SLIGHT_RIGHT)
|
||||
"sharp" in s -> side(Maneuver.TYPE_TURN_SHARP_LEFT, Maneuver.TYPE_TURN_SHARP_RIGHT)
|
||||
"turn" in s -> side(Maneuver.TYPE_TURN_NORMAL_LEFT, Maneuver.TYPE_TURN_NORMAL_RIGHT)
|
||||
"continue" in s || "straight" in s || "head " in s -> Maneuver.TYPE_STRAIGHT
|
||||
else -> Maneuver.TYPE_UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
private const val ARROW_PX = 96
|
||||
private val DISTANCE = Regex("""^\s*(\d+(?:[.,]\d+)?)\s*(km|mi|ft|yd|m)\b""", RegexOption.IGNORE_CASE)
|
||||
private val ROAD_WORDS = listOf(" onto ", " on to ", " toward ", " towards ", " on ")
|
||||
}
|
||||
@@ -226,11 +226,11 @@ object UsageStats {
|
||||
* build names, not confirmed on a real phone.
|
||||
*/
|
||||
fun osName(context: Context): String {
|
||||
fun installed(pkg: String) = runCatching { context.packageManager.getPackageInfo(pkg, 0) }.isSuccess
|
||||
fun installed(pkg: String) = installed(context, pkg)
|
||||
val names = listOf("ro.build.flavor", "ro.build.display.id", "ro.lineage.version")
|
||||
.joinToString(" ") { prop(it) } + " " + Build.DISPLAY + " " + Build.FINGERPRINT
|
||||
return when {
|
||||
installed("app.grapheneos.setupwizard") -> "GrapheneOS"
|
||||
isGrapheneOs(context) -> "GrapheneOS"
|
||||
prop("ro.calyxos.version").isNotEmpty() -> "CalyxOS"
|
||||
installed("foundation.e.apps") -> "/e/OS"
|
||||
names.contains("iode", ignoreCase = true) -> "iodéOS"
|
||||
@@ -240,6 +240,12 @@ object UsageStats {
|
||||
}
|
||||
}
|
||||
|
||||
/** GrapheneOS, told apart from stock Pixel firmware by its own setup app. */
|
||||
fun isGrapheneOs(context: Context) = installed(context, "app.grapheneos.setupwizard")
|
||||
|
||||
private fun installed(context: Context, pkg: String) =
|
||||
runCatching { context.packageManager.getPackageInfo(pkg, 0) }.isSuccess
|
||||
|
||||
/** A system property, or "" when it isn't set or can't be read. */
|
||||
private fun prop(name: String): String = runCatching {
|
||||
Class.forName("android.os.SystemProperties").getMethod("get", String::class.java)
|
||||
|
||||
@@ -26,7 +26,7 @@ object WorkedPrompt {
|
||||
private var loaded = false
|
||||
private val flow = MutableStateFlow<String?>(null)
|
||||
|
||||
/** The car's name while the ask is waiting for an answer, otherwise null. */
|
||||
/** The car's name while the ask is waiting for an answer (empty when the car didn't say), otherwise null. */
|
||||
fun pending(context: Context): StateFlow<String?> {
|
||||
if (!loaded) {
|
||||
loaded = true
|
||||
@@ -99,17 +99,14 @@ object WorkedPrompt {
|
||||
private fun saved(context: Context): JSONObject? =
|
||||
prefs(context).getString(KEY_CAR, null)?.let { runCatching { JSONObject(it) }.getOrNull() }
|
||||
|
||||
/** "your 2019 Clio", "your Renault Media Nav", or "your car" when it didn't say. */
|
||||
/** "2019 Clio", "Renault Media Nav", or empty when the car didn't say. */
|
||||
private fun carName(car: JSONObject): String {
|
||||
val model = car.optString("car").trim()
|
||||
val year = car.optString("year").trim().takeIf { it.isNotEmpty() && it != "0" }
|
||||
val unit = "${car.optString("make")} ${car.optString("model")}".trim()
|
||||
return when {
|
||||
model.isNotEmpty() -> "your " + listOfNotNull(year, model).joinToString(" ")
|
||||
unit.isNotEmpty() -> "your $unit"
|
||||
else -> "your car"
|
||||
model.isNotEmpty() -> listOfNotNull(year, model).joinToString(" ")
|
||||
else -> unit
|
||||
}
|
||||
}
|
||||
|
||||
const val TITLE_START = "Gearslip worked with "
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
@@ -11,6 +13,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.Build
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -44,6 +47,7 @@ internal object GearslipLinks {
|
||||
const val MATRIX = "https://matrix.to/#/!mf0_mgBd5KqKpc9FfX_wHp1jFCcwAtPrndIn_oGIyMk?via=matrix.org"
|
||||
const val SOURCE = "https://github.com/twinksoftware/gearslip"
|
||||
const val CARS = "$SOURCE/blob/main/CARS.md"
|
||||
const val TRANSLATE = "https://hosted.weblate.org/engage/gearslip/"
|
||||
|
||||
/** This version's release notes on GitHub; every release is tagged v plus its version name. */
|
||||
val RELEASE_NOTES = "$SOURCE/releases/tag/v${app.seb3thehacker.gearslip.BuildConfig.VERSION_NAME}"
|
||||
@@ -59,14 +63,14 @@ internal object GearslipLinks {
|
||||
runCatching {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("monero:$MONERO")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}.onFailure {
|
||||
Toast.makeText(context, "No Monero wallet app found. Copy the address instead.", Toast.LENGTH_LONG).show()
|
||||
Toast.makeText(context, context.getString(R.string.about_no_wallet), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
fun copyMonero(context: Context) {
|
||||
context.getSystemService(ClipboardManager::class.java)
|
||||
?.setPrimaryClip(ClipData.newPlainText("Monero address", MONERO))
|
||||
Toast.makeText(context, "Monero address copied", Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(context, context.getString(R.string.about_monero_copied), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +88,7 @@ internal fun DonateRows() {
|
||||
SettingsRow(
|
||||
"Liberapay",
|
||||
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.LIBERAPAY) },
|
||||
subtitle = "Give weekly or monthly",
|
||||
subtitle = stringResource(R.string.about_give_regularly),
|
||||
icon = Icons.Filled.Favorite,
|
||||
trailing = arrow,
|
||||
)
|
||||
@@ -92,16 +96,16 @@ internal fun DonateRows() {
|
||||
SettingsRow(
|
||||
"Buy Me a Coffee",
|
||||
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.BUY_ME_A_COFFEE) },
|
||||
subtitle = "Give once or monthly, by card. Buy Me a Coffee keeps 5%",
|
||||
subtitle = stringResource(R.string.about_bmac_sub),
|
||||
icon = Icons.Filled.Favorite,
|
||||
trailing = arrow,
|
||||
)
|
||||
if (GearslipLinks.PAYMENT_LINK.isNotEmpty()) {
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"Give once",
|
||||
stringResource(R.string.about_give_once),
|
||||
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.PAYMENT_LINK) },
|
||||
subtitle = "Any amount, by card",
|
||||
subtitle = stringResource(R.string.about_any_amount),
|
||||
icon = Icons.Filled.Favorite,
|
||||
trailing = arrow,
|
||||
)
|
||||
@@ -112,7 +116,7 @@ internal fun DonateRows() {
|
||||
SettingsRow(
|
||||
"Monero",
|
||||
Modifier.clickable { moneroOpen = true },
|
||||
subtitle = "Open your wallet or copy the address",
|
||||
subtitle = stringResource(R.string.about_monero_sub),
|
||||
icon = Icons.Filled.Favorite,
|
||||
trailing = arrow,
|
||||
)
|
||||
@@ -127,10 +131,10 @@ internal fun MoneroDialog(onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = { Icon(Icons.Filled.Favorite, contentDescription = null) },
|
||||
title = { Text("Support with Monero") },
|
||||
title = { Text(stringResource(R.string.about_monero_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("Send any amount to this address from your Monero wallet.")
|
||||
Text(stringResource(R.string.about_monero_body))
|
||||
SelectionContainer {
|
||||
Text(
|
||||
GearslipLinks.MONERO,
|
||||
@@ -143,20 +147,20 @@ internal fun MoneroDialog(onDismiss: () -> Unit) {
|
||||
confirmButton = {
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = { GearslipLinks.openMoneroWallet(context) }, Modifier.fillMaxWidth()) {
|
||||
Text("Open in wallet")
|
||||
Text(stringResource(R.string.about_open_wallet))
|
||||
}
|
||||
OutlinedButton(onClick = { GearslipLinks.copyMonero(context) }, Modifier.fillMaxWidth()) {
|
||||
Text("Copy address")
|
||||
Text(stringResource(R.string.about_copy_address))
|
||||
}
|
||||
TextButton(onClick = onDismiss, Modifier.fillMaxWidth()) {
|
||||
Text("Close")
|
||||
Text(stringResource(R.string.close))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** The About section's links: the chat room, the source code, and credits. */
|
||||
/** The About section's links: the chat room, translations, the source code, and credits. */
|
||||
@Composable
|
||||
internal fun AboutLinkRows() {
|
||||
val context = LocalContext.current
|
||||
@@ -168,25 +172,32 @@ internal fun AboutLinkRows() {
|
||||
)
|
||||
}
|
||||
SettingsRow(
|
||||
"Join the chat on Matrix",
|
||||
stringResource(R.string.about_matrix),
|
||||
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.MATRIX) },
|
||||
subtitle = "Talk with other drivers about cars, apps, and Gearslip",
|
||||
subtitle = stringResource(R.string.about_matrix_sub),
|
||||
icon = Icons.AutoMirrored.Filled.Send,
|
||||
trailing = arrow,
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"Source code",
|
||||
stringResource(R.string.about_translate),
|
||||
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.TRANSLATE) },
|
||||
subtitle = stringResource(R.string.about_translate_sub),
|
||||
icon = Icons.Filled.Edit,
|
||||
trailing = arrow,
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
stringResource(R.string.about_source),
|
||||
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.SOURCE) },
|
||||
subtitle = "Read or change Gearslip's code under the AGPL",
|
||||
subtitle = stringResource(R.string.about_source_sub),
|
||||
icon = Icons.Filled.Build,
|
||||
trailing = arrow,
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"Voice credits",
|
||||
subtitle = "Speech recognition by sherpa-onnx (Apache 2.0), with Kroko ASR by Banafo " +
|
||||
"(CC BY-SA 4.0), and Parakeet and Canary by NVIDIA (CC BY 4.0)",
|
||||
stringResource(R.string.about_voice_credits),
|
||||
subtitle = stringResource(R.string.about_voice_credits_sub),
|
||||
icon = Icons.Filled.Favorite,
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,16 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
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.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -24,27 +33,30 @@ fun AudioChoiceDialog(onUseBluetooth: () -> Unit, onKeepAsking: () -> Unit) {
|
||||
var showCommand by remember { mutableStateOf(false) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onKeepAsking,
|
||||
title = { Text("Play car audio over Bluetooth?") },
|
||||
title = { Text(stringResource(R.string.audio_bt_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
"Android treats music sent over USB like sharing your screen, so it asks every " +
|
||||
"drive. Over Bluetooth it never asks. You can change this later in Settings.",
|
||||
)
|
||||
Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(stringResource(R.string.audio_bt_body))
|
||||
if (showCommand) {
|
||||
Text(
|
||||
"Or keep USB and stop the question for good: plug the phone into a " +
|
||||
"computer with USB debugging on, and run this once.",
|
||||
stringResource(R.string.audio_keep_usb),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
CopyableCommand("adb shell appops set ${BuildConfig.APPLICATION_ID} PROJECT_MEDIA allow")
|
||||
} else {
|
||||
TextButton(onClick = { showCommand = true }) { Text("Never ask, using a computer") }
|
||||
}
|
||||
// Stacked full width: three labels this long never fit the dialog's one button row.
|
||||
Column(Modifier.fillMaxWidth().padding(top = 4.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = onUseBluetooth, Modifier.fillMaxWidth()) { Text(stringResource(R.string.audio_use_bt)) }
|
||||
OutlinedButton(onClick = onKeepAsking, Modifier.fillMaxWidth()) { Text(stringResource(R.string.audio_keep_asking)) }
|
||||
if (!showCommand) {
|
||||
TextButton(onClick = { showCommand = true }, Modifier.fillMaxWidth()) {
|
||||
Text(stringResource(R.string.audio_never_ask))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onUseBluetooth) { Text("Use Bluetooth") } },
|
||||
dismissButton = { TextButton(onClick = onKeepAsking) { Text("Keep asking") } },
|
||||
confirmButton = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -57,15 +69,13 @@ fun AudioChoiceDialog(onUseBluetooth: () -> Unit, onKeepAsking: () -> Unit) {
|
||||
fun MicBlockedDialog(onOpenSettings: () -> Unit, onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Car audio needs the microphone permission") },
|
||||
title = { Text(stringResource(R.string.audio_mic_title)) },
|
||||
text = {
|
||||
Text(
|
||||
"Android lets an app send other apps' sound to the car only if it has the " +
|
||||
"microphone permission. It's turned off for Gearslip, so music plays on the " +
|
||||
"phone. Turn it on under Permissions, then Microphone.",
|
||||
stringResource(R.string.audio_mic_body),
|
||||
)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onOpenSettings) { Text("Open app settings") } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Not now") } },
|
||||
confirmButton = { TextButton(onClick = onOpenSettings) { Text(stringResource(R.string.audio_open_settings)) } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.worked_not_now)) } },
|
||||
)
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -59,8 +60,7 @@ import app.seb3thehacker.gearslip.car.CarUi
|
||||
@Composable
|
||||
fun CarPreviewScreen(onBack: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val frame by CarEnvironment.frame.collectAsStateWithLifecycle()
|
||||
var viewSize by remember { mutableStateOf(0 to 0) }
|
||||
val frame by remember { CarEnvironment.frame.also { CarEnvironment.applyDensity() } }.collectAsStateWithLifecycle()
|
||||
// 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
|
||||
@@ -69,66 +69,28 @@ fun CarPreviewScreen(onBack: () -> Unit) {
|
||||
val custom by app.seb3thehacker.gearslip.car.CarSettings.previewCustom.collectAsStateWithLifecycle()
|
||||
val (width, height, density) = previewFrame(context, frame, previewSize, custom)
|
||||
|
||||
val projector = remember { ScreenProjector(context) }
|
||||
// The preview is something to watch, like a map on a dash mount, so the phone stays awake on
|
||||
// it. Only here: the flag goes with this screen, and every other screen sleeps as usual.
|
||||
val view = androidx.compose.ui.platform.LocalView.current
|
||||
DisposableEffect(view) {
|
||||
view.keepScreenOn = true
|
||||
onDispose { view.keepScreenOn = false }
|
||||
}
|
||||
|
||||
DisposableEffect(width, height, density) {
|
||||
CarEnvironment.start(context)
|
||||
// No surface yet: the SurfaceView below hands its own over once it exists, and the
|
||||
// display draws straight into it. Copying each frame into a Bitmap instead cost two
|
||||
// full-size allocations per frame on the main thread, and taps lagged with the size.
|
||||
projector.start(null, width, height, density, name = "gearslip-preview") {
|
||||
androidx.compose.runtime.CompositionLocalProvider(app.seb3thehacker.gearslip.car.LocalCarPreview provides true) { CarUi() }
|
||||
}
|
||||
onDispose { projector.stop() }
|
||||
// A launcher already shows the car UI on this screen, and two of it can't run at once.
|
||||
val launcher by app.seb3thehacker.gearslip.car.LauncherMode.active.collectAsStateWithLifecycle()
|
||||
|
||||
// Held here, not in the frame, so turning the phone between the two layouts keeps the car UI running.
|
||||
val projector = if (launcher) null else rememberCarProjector(width, height, density, "gearslip-preview") {
|
||||
CompositionLocalProvider(app.seb3thehacker.gearslip.car.LocalCarPreview provides true) { CarUi() }
|
||||
}
|
||||
|
||||
// The frame is drawn the same way in both layouts; only what surrounds it differs.
|
||||
val frameContent: @Composable (Modifier) -> Unit = { modifier ->
|
||||
Box(
|
||||
modifier
|
||||
.aspectRatio(width.toFloat() / height)
|
||||
.background(Color.Black)
|
||||
.onSizeChanged { viewSize = it.width to it.height }
|
||||
.pointerInteropFilter { event ->
|
||||
val (viewWidth, viewHeight) = viewSize
|
||||
if (viewWidth > 0 && viewHeight > 0) {
|
||||
projector.dispatchTouch(
|
||||
event.actionMasked.toCarAction(),
|
||||
event.actionIndex,
|
||||
List(event.pointerCount) { i ->
|
||||
TouchPoint(
|
||||
event.getPointerId(i),
|
||||
event.getX(i) * width / viewWidth,
|
||||
event.getY(i) * height / viewHeight,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
true
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
androidx.compose.runtime.key(width, height) {
|
||||
androidx.compose.ui.viewinterop.AndroidView(
|
||||
factory = { viewContext ->
|
||||
android.view.SurfaceView(viewContext).apply {
|
||||
// The car's own size; the compositor scales it to the view.
|
||||
holder.setFixedSize(width, height)
|
||||
holder.addCallback(object : android.view.SurfaceHolder.Callback {
|
||||
override fun surfaceCreated(holder: android.view.SurfaceHolder) =
|
||||
projector.setSurface(holder.surface)
|
||||
|
||||
override fun surfaceChanged(holder: android.view.SurfaceHolder, format: Int, w: Int, h: Int) =
|
||||
projector.setSurface(holder.surface)
|
||||
|
||||
override fun surfaceDestroyed(holder: android.view.SurfaceHolder) =
|
||||
projector.setSurface(null)
|
||||
})
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
if (projector == null) {
|
||||
Text("Gearslip is this device's home screen, so the car UI is already showing there.", modifier)
|
||||
} else {
|
||||
CarFrame(projector, width, height, modifier.aspectRatio(width.toFloat() / height))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +125,80 @@ fun CarPreviewScreen(onBack: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs [content] on a virtual display [width] x [height] pixels big, the way the car gets it,
|
||||
* until it leaves the composition. [CarFrame] shows it.
|
||||
*/
|
||||
@Composable
|
||||
internal fun rememberCarProjector(width: Int, height: Int, density: Int, name: String, content: @Composable () -> Unit): ScreenProjector {
|
||||
val context = LocalContext.current
|
||||
val projector = remember { ScreenProjector(context) }
|
||||
val latestContent by androidx.compose.runtime.rememberUpdatedState(content)
|
||||
DisposableEffect(width, height, density) {
|
||||
CarEnvironment.start(context)
|
||||
// No surface yet: the SurfaceView in CarFrame hands its own over once it exists, and the
|
||||
// display draws straight into it. Copying each frame into a Bitmap instead cost two
|
||||
// full-size allocations per frame on the main thread, and taps lagged with the size.
|
||||
projector.start(null, width, height, density, name = name) { latestContent() }
|
||||
onDispose { projector.stop() }
|
||||
}
|
||||
return projector
|
||||
}
|
||||
|
||||
/**
|
||||
* The [projector]'s display through a SurfaceView, with touches scaled back to the frame's own
|
||||
* [width] x [height]. The car preview letterboxes it; launcher mode fills the screen with it.
|
||||
*/
|
||||
@Composable
|
||||
internal fun CarFrame(projector: ScreenProjector, width: Int, height: Int, modifier: Modifier) {
|
||||
var viewSize by remember { mutableStateOf(0 to 0) }
|
||||
Box(
|
||||
modifier
|
||||
.background(Color.Black)
|
||||
.onSizeChanged { viewSize = it.width to it.height }
|
||||
.pointerInteropFilter { event ->
|
||||
val (viewWidth, viewHeight) = viewSize
|
||||
if (viewWidth > 0 && viewHeight > 0) {
|
||||
projector.dispatchTouch(
|
||||
event.actionMasked.toCarAction(),
|
||||
event.actionIndex,
|
||||
List(event.pointerCount) { i ->
|
||||
TouchPoint(
|
||||
event.getPointerId(i),
|
||||
event.getX(i) * width / viewWidth,
|
||||
event.getY(i) * height / viewHeight,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
true
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
androidx.compose.runtime.key(width, height) {
|
||||
androidx.compose.ui.viewinterop.AndroidView(
|
||||
factory = { viewContext ->
|
||||
android.view.SurfaceView(viewContext).apply {
|
||||
// The car's own size; the compositor scales it to the view.
|
||||
holder.setFixedSize(width, height)
|
||||
holder.addCallback(object : android.view.SurfaceHolder.Callback {
|
||||
override fun surfaceCreated(holder: android.view.SurfaceHolder) =
|
||||
projector.setSurface(holder.surface)
|
||||
|
||||
override fun surfaceChanged(holder: android.view.SurfaceHolder, format: Int, w: Int, h: Int) =
|
||||
projector.setSurface(holder.surface)
|
||||
|
||||
override fun surfaceDestroyed(holder: android.view.SurfaceHolder) =
|
||||
projector.setSurface(null)
|
||||
})
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills the phone screen with [content], centred and letterboxed.
|
||||
*
|
||||
@@ -255,7 +291,7 @@ private fun previewFrame(
|
||||
size: app.seb3thehacker.gearslip.car.PreviewSize,
|
||||
custom: app.seb3thehacker.gearslip.car.ScreenSize?,
|
||||
): Triple<Int, Int, Int> {
|
||||
fun carLike(w: Int, h: Int) = Triple(w, h, (160 * h / 480).coerceIn(120, 640))
|
||||
fun carLike(w: Int, h: Int) = Triple(w, h, app.seb3thehacker.gearslip.car.CarSettings.effectiveDensity((160 * h / 480).coerceIn(120, 640)))
|
||||
return when (size) {
|
||||
app.seb3thehacker.gearslip.car.PreviewSize.LAST_CAR -> Triple(lastCar.width, lastCar.height, lastCar.densityDpi)
|
||||
app.seb3thehacker.gearslip.car.PreviewSize.CUSTOM ->
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -41,19 +43,18 @@ internal fun CarScreenSection() {
|
||||
|
||||
if (editingCar) {
|
||||
SizeDialog(
|
||||
title = "Car screen size",
|
||||
note = "Use this if the car's picture looks stretched. Enter the screen's real size, " +
|
||||
"such as 1280x480. It takes effect the next time you connect.",
|
||||
title = stringResource(R.string.screen_car_size),
|
||||
note = stringResource(R.string.screen_car_size_note),
|
||||
initial = size,
|
||||
clearLabel = "Use the car's",
|
||||
clearLabel = stringResource(R.string.screen_use_cars),
|
||||
onDismiss = { editingCar = false },
|
||||
onSave = { CarSettings.setScreenSize(it); editingCar = false },
|
||||
)
|
||||
}
|
||||
if (editingPreview) {
|
||||
SizeDialog(
|
||||
title = "Preview size",
|
||||
note = "The size the car preview draws at, such as 1920x720.",
|
||||
title = stringResource(R.string.screen_preview_size),
|
||||
note = stringResource(R.string.screen_preview_note),
|
||||
initial = previewCustom,
|
||||
clearLabel = null,
|
||||
onDismiss = { editingPreview = false },
|
||||
@@ -65,62 +66,62 @@ internal fun CarScreenSection() {
|
||||
}
|
||||
|
||||
SettingsSection(
|
||||
"Car screen",
|
||||
footer = "Gearslip uses the size the car reports unless you set one.",
|
||||
stringResource(R.string.screen_car_screen),
|
||||
footer = stringResource(R.string.screen_footer),
|
||||
) {
|
||||
SettingsRow(
|
||||
"Screen size",
|
||||
stringResource(R.string.screen_size),
|
||||
Modifier.clickable { editingCar = true },
|
||||
subtitle = size?.let { "$it, set by you" } ?: "From the car",
|
||||
subtitle = size?.let { stringResource(R.string.screen_set_by_you, it.toString()) } ?: stringResource(R.string.screen_from_car),
|
||||
)
|
||||
if (size != null) {
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"Crop the edges",
|
||||
stringResource(R.string.screen_crop),
|
||||
Modifier.selectable(fit == ScreenFit.CROP, role = Role.RadioButton) { CarSettings.setScreenFit(ScreenFit.CROP) },
|
||||
subtitle = "For a car that cuts off the top and bottom, or the sides",
|
||||
subtitle = stringResource(R.string.screen_crop_sub),
|
||||
trailing = { RadioButton(selected = fit == ScreenFit.CROP, onClick = null) },
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"Squash to fit",
|
||||
stringResource(R.string.screen_squash),
|
||||
Modifier.selectable(fit == ScreenFit.SQUASH, role = Role.RadioButton) { CarSettings.setScreenFit(ScreenFit.SQUASH) },
|
||||
subtitle = "For a car that squeezes the whole picture, so circles look like ovals",
|
||||
subtitle = stringResource(R.string.screen_squash_sub),
|
||||
trailing = { RadioButton(selected = fit == ScreenFit.SQUASH, onClick = null) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsSection("Car preview size") {
|
||||
SettingsSection(stringResource(R.string.screen_preview_title)) {
|
||||
SettingsRow(
|
||||
"Last car",
|
||||
stringResource(R.string.screen_last_car),
|
||||
Modifier.selectable(preview == PreviewSize.LAST_CAR, role = Role.RadioButton) {
|
||||
CarSettings.setPreviewSize(PreviewSize.LAST_CAR)
|
||||
},
|
||||
subtitle = "The size of the last car you connected to",
|
||||
subtitle = stringResource(R.string.screen_last_car_sub),
|
||||
trailing = { RadioButton(selected = preview == PreviewSize.LAST_CAR, onClick = null) },
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"This screen",
|
||||
stringResource(R.string.screen_this_screen),
|
||||
Modifier.selectable(preview == PreviewSize.FILL, role = Role.RadioButton) {
|
||||
CarSettings.setPreviewSize(PreviewSize.FILL)
|
||||
},
|
||||
subtitle = "Fills this device's screen, for a head unit running Gearslip itself",
|
||||
subtitle = stringResource(R.string.screen_this_screen_sub),
|
||||
trailing = { RadioButton(selected = preview == PreviewSize.FILL, onClick = null) },
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"Custom",
|
||||
stringResource(R.string.screen_custom),
|
||||
Modifier.selectable(preview == PreviewSize.CUSTOM, role = Role.RadioButton) {
|
||||
if (previewCustom == null) editingPreview = true else CarSettings.setPreviewSize(PreviewSize.CUSTOM)
|
||||
},
|
||||
subtitle = previewCustom?.toString() ?: "Enter a size",
|
||||
subtitle = previewCustom?.toString() ?: stringResource(R.string.screen_enter_size),
|
||||
trailing = { RadioButton(selected = preview == PreviewSize.CUSTOM, onClick = null) },
|
||||
)
|
||||
if (preview == PreviewSize.CUSTOM) {
|
||||
SettingsDivider()
|
||||
SettingsRow("Change the custom size", Modifier.clickable { editingPreview = true })
|
||||
SettingsRow(stringResource(R.string.screen_change_custom), Modifier.clickable { editingPreview = true })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -145,14 +146,14 @@ private fun SizeDialog(
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(note)
|
||||
OutlinedTextField(width, { width = it.filter(Char::isDigit) }, label = { Text("Width") }, singleLine = true, keyboardOptions = number)
|
||||
OutlinedTextField(height, { height = it.filter(Char::isDigit) }, label = { Text("Height") }, singleLine = true, keyboardOptions = number)
|
||||
OutlinedTextField(width, { width = it.filter(Char::isDigit) }, label = { Text(stringResource(R.string.screen_width)) }, singleLine = true, keyboardOptions = number)
|
||||
OutlinedTextField(height, { height = it.filter(Char::isDigit) }, label = { Text(stringResource(R.string.screen_height)) }, singleLine = true, keyboardOptions = number)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { onSave(parsed) }, enabled = parsed != null) { Text("Save") } },
|
||||
confirmButton = { TextButton(onClick = { onSave(parsed) }, enabled = parsed != null) { Text(stringResource(R.string.save)) } },
|
||||
dismissButton = {
|
||||
if (clearLabel != null) TextButton(onClick = { onSave(null) }) { Text(clearLabel) }
|
||||
else TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
else TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
@@ -80,7 +82,7 @@ fun CertSetupScreen(onDone: () -> Unit) {
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
"Certificate Setup",
|
||||
stringResource(R.string.certsetup_title),
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
@@ -88,9 +90,9 @@ fun CertSetupScreen(onDone: () -> Unit) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
if (installed) {
|
||||
"Cars accept only Google-issued certificates, and you have one loaded."
|
||||
stringResource(R.string.certsetup_loaded)
|
||||
} else {
|
||||
"Cars accept only Google-issued certificates. Gearslip doesn't include one, so choose where yours comes from."
|
||||
stringResource(R.string.certsetup_choose)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
@@ -100,14 +102,24 @@ fun CertSetupScreen(onDone: () -> Unit) {
|
||||
|
||||
if (installed) {
|
||||
Text(
|
||||
if (manualProgress) "Certificate imported." else "Certificate installed.",
|
||||
if (manualProgress) stringResource(R.string.cert_imported) else stringResource(R.string.certsetup_installed),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
// A downloaded certificate works in most cars, not all: say where to go when it doesn't.
|
||||
if (!manualProgress) {
|
||||
Text(
|
||||
stringResource(R.string.certsetup_installed_hint),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Button(onClick = onDone, modifier = Modifier.fillMaxWidth().height(56.dp)) {
|
||||
Text("Continue")
|
||||
Text(stringResource(R.string.certsetup_continue))
|
||||
}
|
||||
Spacer(Modifier.height(40.dp))
|
||||
return@Column
|
||||
@@ -130,11 +142,10 @@ fun CertSetupScreen(onDone: () -> Unit) {
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
Text("Download from aasdk")
|
||||
Text(stringResource(R.string.cert_download))
|
||||
}
|
||||
Text(
|
||||
"The open-source aasdk project publishes this certificate at github.com/opencardev/aasdk. " +
|
||||
"Most cars accept it; some from around 2020 on don't.",
|
||||
stringResource(R.string.certsetup_aasdk_sub),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
@@ -146,7 +157,7 @@ fun CertSetupScreen(onDone: () -> Unit) {
|
||||
Box(Modifier.fillMaxWidth().height(56.dp), contentAlignment = Alignment.Center) {
|
||||
DownloadingRow {
|
||||
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
Text("Downloading…", style = MaterialTheme.typography.bodyLarge)
|
||||
Text(stringResource(R.string.cert_downloading), style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,7 +168,7 @@ fun CertSetupScreen(onDone: () -> Unit) {
|
||||
Surface(color = MaterialTheme.colorScheme.errorContainer, shape = MaterialTheme.shapes.medium) {
|
||||
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
"Download failed",
|
||||
stringResource(R.string.certsetup_failed),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
@@ -168,7 +179,7 @@ fun CertSetupScreen(onDone: () -> Unit) {
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Button(onClick = { downloadState = DownloadState.Idle }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Retry")
|
||||
Text(stringResource(R.string.certsetup_retry))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,9 +191,9 @@ fun CertSetupScreen(onDone: () -> Unit) {
|
||||
OutlinedButton(
|
||||
onClick = { importLauncher.launch(arrayOf("*/*")) },
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
) { Text("Import my own file") }
|
||||
) { Text(stringResource(R.string.cert_import)) }
|
||||
Text(
|
||||
"A .p12 file with its private key, from wherever you choose.",
|
||||
stringResource(R.string.certsetup_import_sub),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
@@ -197,7 +208,7 @@ fun CertSetupScreen(onDone: () -> Unit) {
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
) {
|
||||
Text("Skip car connection setup")
|
||||
Text(stringResource(R.string.certsetup_skip))
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(40.dp))
|
||||
@@ -216,7 +227,7 @@ fun CertSetupScreen(onDone: () -> Unit) {
|
||||
pendingUri = null
|
||||
manualProgress = true
|
||||
}.onFailure {
|
||||
reportError("Wrong password or not a valid PKCS#12 file with a private key.")
|
||||
reportError(context.getString(R.string.cert_import_failed))
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -224,7 +235,7 @@ fun CertSetupScreen(onDone: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
/** A tight horizontal row for the inline "Downloading…" spinner state. */
|
||||
/** A tight horizontal row for the inline stringResource(R.string.cert_downloading) spinner state. */
|
||||
@Composable
|
||||
private fun DownloadingRow(content: @Composable RowScope.() -> Unit) {
|
||||
Row(
|
||||
@@ -248,20 +259,20 @@ private fun PasswordDialog(onDismiss: () -> Unit, onConfirm: (String, (String) -
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Certificate password") },
|
||||
title = { Text(stringResource(R.string.cert_password_title)) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it; error = null },
|
||||
singleLine = true,
|
||||
label = { Text("Password") },
|
||||
label = { Text(stringResource(R.string.cert_password)) },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
isError = error != null,
|
||||
supportingText = { Text(error ?: "Leave empty if the file has none.") },
|
||||
supportingText = { Text(error ?: stringResource(R.string.cert_password_hint)) },
|
||||
)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { onConfirm(password) { error = it } }) { Text("Import") } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
confirmButton = { TextButton(onClick = { onConfirm(password) { error = it } }) { Text(stringResource(R.string.cert_import_button)) } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import android.content.Context
|
||||
import app.seb3thehacker.gearslip.CertProvider
|
||||
import java.text.DateFormat
|
||||
@@ -28,20 +29,20 @@ data class CertSummary(
|
||||
val identity = CertProvider.loadSupplied(context)
|
||||
?: return CertSummary(
|
||||
CertProvider.Kind.SELF_SIGNED,
|
||||
"None loaded (self-signed; cars reject it)",
|
||||
context.getString(R.string.cert_none),
|
||||
null, null, null,
|
||||
)
|
||||
val cert = identity.certificate
|
||||
val name = friendlyName(cert.subjectX500Principal.name)
|
||||
val prefix = when (identity.kind) {
|
||||
CertProvider.Kind.IMPORTED -> "Imported"
|
||||
CertProvider.Kind.DOWNLOADED -> "Downloaded"
|
||||
CertProvider.Kind.ADB_STAGED -> "Staged over adb"
|
||||
CertProvider.Kind.SELF_SIGNED -> "Self-signed"
|
||||
val headline = when (identity.kind) {
|
||||
CertProvider.Kind.IMPORTED -> R.string.cert_kind_imported
|
||||
CertProvider.Kind.DOWNLOADED -> R.string.cert_kind_downloaded
|
||||
CertProvider.Kind.ADB_STAGED -> R.string.cert_kind_adb
|
||||
CertProvider.Kind.SELF_SIGNED -> R.string.cert_kind_self
|
||||
}
|
||||
return CertSummary(
|
||||
identity.kind,
|
||||
"$prefix: $name",
|
||||
context.getString(headline, name),
|
||||
cert.subjectX500Principal.name,
|
||||
cert.issuerX500Principal.name,
|
||||
DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(cert.notAfter.time)),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -29,7 +31,7 @@ fun CopyableCommand(command: String) {
|
||||
shape = MaterialTheme.shapes.small,
|
||||
onClick = {
|
||||
clipboard.setText(AnnotatedString(command))
|
||||
Toast.makeText(context, "Command copied", Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(context, context.getString(R.string.command_copied), Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||
) {
|
||||
@@ -41,7 +43,7 @@ fun CopyableCommand(command: String) {
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
"Tap to copy",
|
||||
stringResource(R.string.command_tap_copy),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -100,31 +102,30 @@ internal fun DonateDialog(live: Boolean) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { DonatePrompt.answer(context, donated = false) },
|
||||
icon = { Icon(Icons.Filled.Favorite, contentDescription = null) },
|
||||
title = { Text("Glad that drive worked") },
|
||||
title = { Text(stringResource(R.string.donate_title)) },
|
||||
text = {
|
||||
Text(
|
||||
"Gearslip is free, with no ads and no tracking. If it gets you on the road, your " +
|
||||
"support keeps it going.",
|
||||
stringResource(R.string.donate_body),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = { give(GearslipLinks.LIBERAPAY) }, Modifier.fillMaxWidth()) {
|
||||
Text("Support on Liberapay")
|
||||
Text(stringResource(R.string.donate_liberapay))
|
||||
}
|
||||
OutlinedButton(onClick = { give(GearslipLinks.BUY_ME_A_COFFEE) }, Modifier.fillMaxWidth()) {
|
||||
Text("Buy me a coffee")
|
||||
Text(stringResource(R.string.donate_bmac))
|
||||
}
|
||||
if (GearslipLinks.PAYMENT_LINK.isNotEmpty()) {
|
||||
OutlinedButton(onClick = { give(GearslipLinks.PAYMENT_LINK) }, Modifier.fillMaxWidth()) {
|
||||
Text("Give once by card")
|
||||
Text(stringResource(R.string.donate_card))
|
||||
}
|
||||
}
|
||||
OutlinedButton(onClick = { monero = true }, Modifier.fillMaxWidth()) {
|
||||
Text("Monero")
|
||||
}
|
||||
TextButton(onClick = { DonatePrompt.answer(context, donated = false) }, Modifier.fillMaxWidth()) {
|
||||
Text("Not now")
|
||||
Text(stringResource(R.string.worked_not_now))
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||